MotorSignal-App/website/components/founder-portal/founder-chat.tsx

355 lines
14 KiB
TypeScript

'use client';
import React, { useState, useEffect, useRef } from 'react';
import {
type ChatMessage,
subscribeClubChat,
sendClubChatMessage,
FIXTURE_CHAT_MESSAGES,
} from '../../lib/club-community';
import { type FounderClubContext, sanitizePortalError } from '../../lib/club-data';
interface FounderChatProps {
context: FounderClubContext;
}
type ChannelId = 'general' | 'officers' | 'ride-intel';
interface ChannelInfo {
id: ChannelId;
name: string;
icon: string;
description: string;
requiresOfficer: boolean;
}
const CHANNELS: ChannelInfo[] = [
{
id: 'general',
name: 'general-squad',
icon: 'chat',
description: 'General discussions, weekend meetups, and squad banter for all club members.',
requiresOfficer: false,
},
{
id: 'officers',
name: 'officers-lounge',
icon: 'lock',
description: 'Private coordination channel restricted to Club Founder and Admins.',
requiresOfficer: true,
},
{
id: 'ride-intel',
name: 'road-intel',
icon: 'radar',
description: 'Live pavement scouting, weather warnings, radar alerts, and road hazard reports.',
requiresOfficer: false,
},
];
export function FounderChat({ context }: FounderChatProps) {
const { club, permissions, currentUser, isDemoFixture } = context;
const [activeChannelId, setActiveChannelId] = useState<ChannelId>('general');
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputText, setInputText] = useState('');
const [isSending, setIsSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const isOfficer = permissions.canManageSettings || currentUser.role === 'owner' || currentUser.role === 'admin';
const activeChannel = CHANNELS.find((c) => c.id === activeChannelId) || CHANNELS[0];
const isRestrictedChannel = activeChannel.requiresOfficer && !isOfficer;
const hasExampleMessages = messages.length > 0 && messages.every((m) => m.isExample);
useEffect(() => {
setError(null);
let unsubscribe: () => void = () => {};
if (isDemoFixture) {
const channelMsgs = FIXTURE_CHAT_MESSAGES[activeChannelId] || [];
setMessages(channelMsgs.map((m) => ({ ...m, clubId: club.id, channelId: activeChannelId, isExample: true })));
} else {
unsubscribe = subscribeClubChat(
club.id,
activeChannelId,
(newMsgs) => {
setMessages(newMsgs);
},
(err) => {
setError(sanitizePortalError(err, 'Failed to connect to chat channel.'));
}
);
}
return () => {
unsubscribe();
};
}, [club.id, activeChannelId, isDemoFixture]);
// Auto-scroll on new message
useEffect(() => {
if (typeof messagesEndRef.current?.scrollIntoView === 'function') {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [messages]);
async function handleSendMessage(e?: React.FormEvent) {
if (e) e.preventDefault();
const clean = inputText.trim();
if (!clean || isSending || isRestrictedChannel) return;
setIsSending(true);
setError(null);
try {
if (isDemoFixture) {
const optimisticMsg: ChatMessage = {
id: `demo-msg-${Date.now()}`,
clubId: club.id,
channelId: activeChannelId,
authorUid: currentUser.uid,
authorDisplayName: 'Club Founder',
authorHandle: '@founder',
authorDisplayRank: currentUser.role === 'owner' ? 'President' : 'Admin',
authorRole: currentUser.role,
content: clean,
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, optimisticMsg]);
} else {
await sendClubChatMessage(club.id, activeChannelId, clean);
}
setInputText('');
} catch (err) {
setError(sanitizePortalError(err, 'Failed to send message.'));
} finally {
setIsSending(false);
}
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}
return (
<div className="w-full space-y-6">
{/* Header Card */}
<div className="glass-card p-6 rounded-2xl flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 border border-brand-border/40">
<div>
<div className="flex items-center gap-3">
<h1 className="font-heading text-2xl font-bold tracking-wide text-white">
Squad Live Chat
</h1>
{hasExampleMessages ? (
<span className="font-mono text-xs px-2.5 py-0.5 rounded-full bg-amber-500/15 text-amber-300 border border-amber-500/30 font-semibold flex items-center gap-1.5">
<span className="material-symbols-outlined text-[14px]">visibility</span>
Example thread
</span>
) : (
<span className="font-mono text-xs px-2.5 py-0.5 rounded-full bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 font-semibold flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
Live Connected
</span>
)}
</div>
<p className="font-body text-xs text-slate-400 mt-1 max-w-xl">
Real-time squad channels for ride organization, road condition warnings, and administrative officer discussions.
</p>
</div>
{/* Channel quick tabs */}
<div className="flex items-center gap-1.5 p-1 rounded-xl bg-surface-container/60 border border-brand-border/30 shrink-0">
{CHANNELS.map((ch) => {
const isActive = ch.id === activeChannelId;
const isLocked = ch.requiresOfficer && !isOfficer;
return (
<button
key={ch.id}
onClick={() => setActiveChannelId(ch.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition ${
isActive
? 'bg-brand-cyan text-brand-bg shadow-sm'
: 'text-slate-400 hover:text-white hover:bg-surface-container'
}`}
>
<span className="material-symbols-outlined text-[15px]">
{isLocked ? 'lock' : ch.icon}
</span>
<span>#{ch.name}</span>
</button>
);
})}
</div>
</div>
{hasExampleMessages && (
<div className="p-3 rounded-xl bg-amber-500/10 border border-amber-500/30 text-amber-200/90 text-xs flex items-center gap-2.5">
<span className="material-symbols-outlined text-[16px] text-amber-400 shrink-0">info</span>
<span>
These are <strong className="font-semibold text-amber-100">example messages</strong> — this
channel has no posts yet. Send a message to start the real conversation.
</span>
</div>
)}
{error && (
<div className="p-4 rounded-xl bg-brand-crimson/10 border border-brand-crimson/30 text-brand-crimson text-xs flex items-center justify-between">
<span className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px]">error</span>
{error}
</span>
<button onClick={() => setError(null)} className="hover:text-white transition">
✕
</button>
</div>
)}
{/* Chat Room Area */}
<div className="glass-card rounded-2xl border border-brand-border/40 overflow-hidden flex flex-col h-[600px]">
{/* Channel Banner */}
<div className="p-4 bg-surface-container/60 border-b border-brand-border/30 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<span className="material-symbols-outlined text-brand-cyan text-[20px]">
{activeChannel.icon}
</span>
<div>
<span className="font-heading text-sm font-bold text-white">
#{activeChannel.name}
</span>
<p className="text-[11px] text-slate-400 font-body">
{activeChannel.description}
</p>
</div>
</div>
<div className="text-[11px] font-mono text-slate-500">
{messages.length} messages
</div>
</div>
{/* Message Stream */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{isRestrictedChannel ? (
<div className="h-full flex flex-col items-center justify-center text-center p-6 space-y-3">
<span className="material-symbols-outlined text-4xl text-amber-500">lock</span>
<h3 className="font-heading text-base font-bold text-white">
Officers Lounge Restricted
</h3>
<p className="font-body text-xs text-slate-400 max-w-sm">
This channel is strictly reserved for club Founders and Admins. Switch to <strong className="text-brand-cyan">#general-squad</strong> or <strong className="text-brand-cyan">#road-intel</strong> to chat with the club.
</p>
</div>
) : messages.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center p-6 space-y-2">
<span className="material-symbols-outlined text-3xl text-slate-600">chat_bubble_outline</span>
<p className="font-body text-xs text-slate-500">
No messages yet in #{activeChannel.name}. Start the conversation!
</p>
</div>
) : (
messages.map((msg) => {
const isMe = msg.authorUid === currentUser.uid;
const isOwnerRole = msg.authorRole === 'owner';
const isAdminRole = msg.authorRole === 'admin';
return (
<div
key={msg.id}
className={`flex items-start gap-3 ${isMe ? 'flex-row-reverse' : 'flex-row'}`}
>
{/* Avatar / Monogram */}
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold shrink-0 border ${
isOwnerRole
? 'bg-amber-500/20 text-amber-400 border-amber-500/40'
: isAdminRole
? 'bg-brand-cyan/20 text-brand-cyan border-brand-cyan/40'
: 'bg-surface-container text-slate-300 border-brand-border/40'
}`}
>
{msg.authorDisplayName.charAt(0).toUpperCase()}
</div>
{/* Message Bubble */}
<div
className={`max-w-[75%] rounded-2xl p-3 space-y-1 ${
isMe
? 'bg-brand-cyan/15 border border-brand-cyan/30 text-white'
: 'bg-surface-container/70 border border-brand-border/30 text-slate-200'
}`}
>
{/* Author Meta */}
<div className="flex items-center gap-2 text-[10px]">
<span className="font-bold text-white">
{msg.authorDisplayName}
</span>
{msg.authorHandle && (
<span className="text-slate-400 font-mono">
{msg.authorHandle}
</span>
)}
{msg.authorDisplayRank && (
<span className="font-mono px-1.5 py-0.2 rounded bg-surface-container border border-brand-border/40 text-brand-cyan text-[9px]">
{msg.authorDisplayRank}
</span>
)}
<span className="text-slate-500 font-mono ml-auto">
{msg.createdAt
? new Date(msg.createdAt).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
: ''}
</span>
</div>
{/* Content */}
<p className="font-body text-xs leading-relaxed break-words whitespace-pre-wrap">
{msg.content}
</p>
</div>
</div>
);
})
)}
<div ref={messagesEndRef} />
</div>
{/* Message Input Box */}
<div className="p-3 bg-surface-container/80 border-t border-brand-border/30">
<form onSubmit={handleSendMessage} className="flex items-center gap-2">
<input
type="text"
disabled={isRestrictedChannel || isSending}
placeholder={
isRestrictedChannel
? 'Channel restricted to officers'
: `Message #${activeChannel.name}...`
}
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={handleKeyDown}
maxLength={1000}
className="flex-1 bg-surface-container border border-brand-border/40 rounded-xl px-4 py-2.5 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-brand-cyan/60 disabled:opacity-50"
/>
<button
type="submit"
disabled={!inputText.trim() || isSending || isRestrictedChannel}
className="px-4 py-2.5 rounded-xl bg-brand-cyan text-brand-bg font-bold text-xs hover:bg-brand-cyan/90 transition shadow-lg shadow-brand-cyan/20 disabled:opacity-40 flex items-center gap-1.5 shrink-0"
>
<span className="material-symbols-outlined text-[16px]">send</span>
<span className="hidden sm:inline">Send</span>
</button>
</form>
</div>
</div>
</div>
);
}