+
+ Enter your account email. We'll send a link to set a new password.
+
@@ -84,86 +111,142 @@ export function AuthModal({ open, onClose }: AuthModalProps) {
/>
-
-
-
-
- setPassword(e.target.value)}
- placeholder="8+ characters"
- className="pl-10"
- minLength={8}
- />
-
-
{error &&
{error}
}
{success &&
{success}
}
-
-
-
-
-
- setEmail(e.target.value)}
- placeholder="you@example.com"
- className="mt-1"
- />
-
-
-
- setPassword(e.target.value)}
- className="mt-1"
- />
-
- {error && {error}
}
-
-
-
-
+ ) : (
+ <>
+
+
+ Sign Up
+ Log In
+
-
+
+
+
+
+
+ setEmail(e.target.value)}
+ placeholder="you@example.com"
+ className="pl-10"
+ />
+
+
+
+
+
+
+ setPassword(e.target.value)}
+ placeholder="8+ characters"
+ className="pl-10"
+ minLength={8}
+ />
+
+
+ {error && {error}
}
+ {success && {success}
}
+
+
-
- No credit card. No paid tier. Ever.
-
+
+
+
+ setEmail(e.target.value)}
+ placeholder="you@example.com"
+ className="mt-1"
+ />
+
+
+
+ setPassword(e.target.value)}
+ className="mt-1"
+ />
+
+
+
+
+ {error && {error}
}
+
+
+
+
+
+
+
+
+
+ No credit card. No paid tier. Ever.
+
+ >
+ )}
);
diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx
index 657b008..8faf9a4 100644
--- a/src/components/ChatPanel.tsx
+++ b/src/components/ChatPanel.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useEffect, useRef } from "react";
+import { useState, useEffect, useRef, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@@ -18,8 +18,19 @@ import { canViewerMessageTarget, guestViewerProfile } from "@/lib/visibility";
import { loadHideFromRules } from "@/lib/privacy-rules";
import { loadBlockList } from "@/lib/block-list";
import { loadPrivacySettings, messageBlockReason } from "@/lib/privacy-settings";
+import { useChatMessages } from "@/lib/supabase/hooks";
+import {
+ ensureRoomMember,
+ fetchRoomMemberCount,
+ getOrCreateCityRoom,
+ getOrCreateDmRoom,
+ getOrCreateSpotRoom,
+ senderDisplayName,
+} from "@/lib/chat-store";
+import { createClient } from "@/lib/supabase/client";
+import type { Profile, ChatMessage } from "@/types";
-interface Message {
+interface DisplayMessage {
id: string;
sender: string;
content: string;
@@ -30,7 +41,7 @@ interface Message {
const CITY = process.env.NEXT_PUBLIC_DEFAULT_CITY || "Fort Lauderdale";
-const MOCK_CITY_MESSAGES: Message[] = [
+const MOCK_CITY_MESSAGES: DisplayMessage[] = [
{
id: "1",
sender: "Mike_Top",
@@ -57,7 +68,7 @@ const MOCK_CITY_MESSAGES: Message[] = [
},
];
-const MOCK_DM_MESSAGES: Message[] = [
+const MOCK_DM_MESSAGES: DisplayMessage[] = [
{
id: "dm1",
sender: "You",
@@ -73,6 +84,17 @@ const MOCK_DM_MESSAGES: Message[] = [
},
];
+function mapChatMessage(msg: ChatMessage, currentUserId: string): DisplayMessage {
+ return {
+ id: msg.id,
+ sender: msg.sender_id === currentUserId ? "You" : senderDisplayName(msg.sender),
+ content: msg.content,
+ image_url: msg.image_url ?? undefined,
+ created_at: msg.created_at,
+ isMe: msg.sender_id === currentUserId,
+ };
+}
+
interface ChatPanelProps {
dmUserId?: string | null;
spotId?: string | null;
@@ -83,45 +105,132 @@ export function ChatPanel({ dmUserId, spotId, spotRoomName }: ChatPanelProps) {
const { capabilities } = useAccess();
const { user, profile: authProfile } = useAuth();
const { blockList } = usePrivacy();
- const [cityMessages, setCityMessages] = useState
(MOCK_CITY_MESSAGES);
- const [dmMessages, setDmMessages] = useState(MOCK_DM_MESSAGES);
const [input, setInput] = useState("");
const [activeTab, setActiveTab] = useState(
dmUserId ? "dm" : spotId ? "city" : "city"
);
const [showQuickReplies, setShowQuickReplies] = useState(false);
+ const [cityRoomId, setCityRoomId] = useState(null);
+ const [spotRoomId, setSpotRoomId] = useState(null);
+ const [dmRoomId, setDmRoomId] = useState(null);
+ const [memberCount, setMemberCount] = useState(0);
+ const [dmTarget, setDmTarget] = useState(null);
+ const [sending, setSending] = useState(false);
const bottomRef = useRef(null);
+ const isLive = !!user && capabilities.canSendMessages;
+ const publicRoomId = spotId && spotRoomId ? spotRoomId : cityRoomId;
+
+ const { messages: livePublicMessages, sendMessage: sendPublicMessage } = useChatMessages(
+ publicRoomId,
+ isLive
+ );
+ const { messages: liveDmMessages, sendMessage: sendDmMessage } = useChatMessages(
+ dmRoomId,
+ isLive && !!dmUserId
+ );
+
+ useEffect(() => {
+ if (!user) {
+ setCityRoomId(null);
+ setSpotRoomId(null);
+ setDmRoomId(null);
+ return;
+ }
+
+ let cancelled = false;
+
+ async function resolveRooms() {
+ const cityId = await getOrCreateCityRoom(CITY);
+ if (cancelled) return;
+ if (cityId) {
+ setCityRoomId(cityId);
+ await ensureRoomMember(cityId, user.id);
+ const count = await fetchRoomMemberCount(cityId);
+ if (!cancelled) setMemberCount(count);
+ }
+
+ if (spotId) {
+ const spotName = spotRoomName
+ ? decodeURIComponent(spotRoomName)
+ : "Spot Chat";
+ const sId = await getOrCreateSpotRoom(spotId, spotName);
+ if (!cancelled && sId) {
+ setSpotRoomId(sId);
+ await ensureRoomMember(sId, user.id);
+ }
+ }
+
+ if (dmUserId) {
+ const dId = await getOrCreateDmRoom(user.id, dmUserId);
+ if (!cancelled && dId) {
+ setDmRoomId(dId);
+ await ensureRoomMember(dId, user.id);
+ }
+ }
+ }
+
+ resolveRooms();
+ return () => {
+ cancelled = true;
+ };
+ }, [user, spotId, spotRoomName, dmUserId]);
+
+ useEffect(() => {
+ if (!dmUserId) {
+ setDmTarget(null);
+ return;
+ }
+
+ const mock = MOCK_PROFILES.find((p) => p.id === dmUserId);
+ if (mock) {
+ setDmTarget(mock);
+ return;
+ }
+
+ const supabase = createClient();
+ supabase
+ .from("profiles")
+ .select("*")
+ .eq("id", dmUserId)
+ .maybeSingle()
+ .then(({ data }) => {
+ if (data) setDmTarget(data as Profile);
+ });
+ }, [dmUserId]);
+
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
- }, [cityMessages, dmMessages, activeTab]);
+ }, [livePublicMessages, liveDmMessages, activeTab, isLive]);
- const sendMessage = (text?: string) => {
- if (!capabilities.canSendMessages) return;
+ const cityMessages = useMemo(() => {
+ if (!isLive) return MOCK_CITY_MESSAGES;
+ return livePublicMessages.map((m) => mapChatMessage(m, user!.id));
+ }, [isLive, livePublicMessages, user]);
+
+ const dmMessages = useMemo(() => {
+ if (!isLive || !dmUserId) return MOCK_DM_MESSAGES;
+ return liveDmMessages.map((m) => mapChatMessage(m, user!.id));
+ }, [isLive, liveDmMessages, dmUserId, user]);
+
+ const sendMessage = async (text?: string) => {
+ if (!capabilities.canSendMessages || !user) return;
const content = (text ?? input).trim();
- if (!content) return;
- const msg: Message = {
- id: Date.now().toString(),
- sender: "You",
- content,
- created_at: new Date().toISOString(),
- isMe: true,
- };
+ if (!content || sending) return;
+ setSending(true);
if (activeTab === "city") {
- setCityMessages((prev) => [...prev, msg]);
- } else {
- setDmMessages((prev) => [...prev, msg]);
+ await sendPublicMessage(content, user.id);
+ } else if (activeTab === "dm" && dmRoomId) {
+ await sendDmMessage(content, user.id);
}
+ setSending(false);
setInput("");
setShowQuickReplies(false);
};
const messages = activeTab === "city" ? cityMessages : dmMessages;
- const dmTarget = dmUserId
- ? MOCK_PROFILES.find((p) => p.id === dmUserId) ?? null
- : null;
const viewer = authProfile ?? guestViewerProfile();
const viewerId = user?.id ?? "guest";
const dmAllowed =
@@ -142,13 +251,17 @@ export function ChatPanel({ dmUserId, spotId, spotRoomName }: ChatPanelProps) {
(activeTab === "city" || activeTab === "dm") &&
(activeTab !== "dm" || dmAllowed);
+ const roomBadgeCount = isLive
+ ? Math.max(memberCount, cityMessages.length)
+ : cityMessages.length + 12;
+
return (
- {CITY}
+ {spotId && spotRoomName ? "Spot" : CITY}
@@ -162,12 +275,14 @@ export function ChatPanel({ dmUserId, spotId, spotRoomName }: ChatPanelProps) {
- {cityMessages.length + 12} in room
+ {roomBadgeCount} in room{isLive ? "" : " (preview)"}
{spotId && spotRoomName
- ? `Spot chat — ${decodeURIComponent(spotRoomName)}. Also connected to ${CITY} city room.`
- : "Auto-joined based on your location. Public city chat."}
+ ? `Spot chat — ${decodeURIComponent(spotRoomName)}. Live room connected to ${CITY}.`
+ : isLive
+ ? "Auto-joined based on your location. Public city chat."
+ : "Preview mode — sign up free to join the live room."}
@@ -183,7 +298,9 @@ export function ChatPanel({ dmUserId, spotId, spotRoomName }: ChatPanelProps) {
: "Private Messages"}
- Use quick replies for address, directions & hosting notes
+ {dmUserId
+ ? "Use quick replies for address, directions & hosting notes"
+ : "Open a profile from the map to start a DM"}
{!dmAllowed && dmBlockNote && (
@@ -191,7 +308,13 @@ export function ChatPanel({ dmUserId, spotId, spotRoomName }: ChatPanelProps) {
{dmBlockNote}
)}
-
+ Tap Message on a cruiser profile to open a private thread.
+
+ ) : (
+