355 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|