fix(website): label fallback club routes and chat as examples (W-routes, W-chat)
When a club has no curated routes or a chat channel has no messages, the data layer returned fixture content with no indication it was sample data, so riders could mistake MotorSignal's "Coastal Marshes" route or canned chat messages for their club's real data. Flag fixture-sourced content and label it in the UI: - Add isExample to ClubRoute and ChatMessage; set it on every fixture fallback path (empty Firestore collection/channel and the dev demo preview). - Founder Routes: amber "Examples" count, an explanatory banner, and a per-card "Example" badge when the routes shown are samples. - Founder Chat: an "Example thread" pill (instead of "Live Connected") and a banner when the only messages are examples, so the header never claims a live conversation that does not exist. Tests: example-labelled routes render the banner/badge/count; example-labelled chat renders the banner and "Example thread" pill and does not claim "Live Connected". Existing route/chat tests (real data, no isExample) remain green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
217e5a11c0
commit
2cb3ef9694
|
|
@ -60,6 +60,7 @@ export function FounderChat({ context }: FounderChatProps) {
|
|||
|
||||
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);
|
||||
|
|
@ -67,7 +68,7 @@ export function FounderChat({ context }: FounderChatProps) {
|
|||
|
||||
if (isDemoFixture) {
|
||||
const channelMsgs = FIXTURE_CHAT_MESSAGES[activeChannelId] || [];
|
||||
setMessages(channelMsgs.map((m) => ({ ...m, clubId: club.id, channelId: activeChannelId })));
|
||||
setMessages(channelMsgs.map((m) => ({ ...m, clubId: club.id, channelId: activeChannelId, isExample: true })));
|
||||
} else {
|
||||
unsubscribe = subscribeClubChat(
|
||||
club.id,
|
||||
|
|
@ -143,10 +144,17 @@ export function FounderChat({ context }: FounderChatProps) {
|
|||
<h1 className="font-heading text-2xl font-bold tracking-wide text-white">
|
||||
Squad Live Chat
|
||||
</h1>
|
||||
<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>
|
||||
{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.
|
||||
|
|
@ -179,6 +187,16 @@ export function FounderChat({ context }: FounderChatProps) {
|
|||
</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">
|
||||
|
|
|
|||
|
|
@ -178,6 +178,8 @@ export function FounderRoutes({ context }: FounderRoutesProps) {
|
|||
|
||||
const canCurate = permissions.canPlanRides || permissions.canManageSettings;
|
||||
|
||||
const hasExampleRoutes = routes.length > 0 && routes.every((r) => r.isExample);
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
{/* Top Header Card */}
|
||||
|
|
@ -187,8 +189,14 @@ export function FounderRoutes({ context }: FounderRoutesProps) {
|
|||
<h1 className="font-heading text-2xl font-bold tracking-wide text-white">
|
||||
Curated Club Routes
|
||||
</h1>
|
||||
<span className="font-mono text-xs px-2.5 py-0.5 rounded-full bg-brand-cyan/15 text-brand-cyan border border-brand-cyan/30 font-semibold">
|
||||
{routes.length} Available
|
||||
<span
|
||||
className={`font-mono text-xs px-2.5 py-0.5 rounded-full border font-semibold ${
|
||||
hasExampleRoutes
|
||||
? 'bg-amber-500/15 text-amber-300 border-amber-500/30'
|
||||
: 'bg-brand-cyan/15 text-brand-cyan border-brand-cyan/30'
|
||||
}`}
|
||||
>
|
||||
{hasExampleRoutes ? `${routes.length} Examples` : `${routes.length} Available`}
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-body text-xs text-slate-400 mt-1 max-w-xl">
|
||||
|
|
@ -207,6 +215,16 @@ export function FounderRoutes({ context }: FounderRoutesProps) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{hasExampleRoutes && (
|
||||
<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 routes</strong> — your club
|
||||
hasn’t curated any yet. Curate a route to replace them with your own.
|
||||
</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">
|
||||
|
|
@ -314,9 +332,16 @@ export function FounderRoutes({ context }: FounderRoutesProps) {
|
|||
<div className="space-y-3">
|
||||
{/* Card Header & Badge */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="font-heading text-base font-bold text-white leading-snug">
|
||||
{route.title}
|
||||
</h3>
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-heading text-base font-bold text-white leading-snug">
|
||||
{route.title}
|
||||
</h3>
|
||||
{route.isExample && (
|
||||
<span className="inline-flex items-center gap-1 mt-1 font-mono text-[9px] px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-300 border border-amber-500/30 font-semibold uppercase tracking-wider">
|
||||
Example
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 font-mono text-[10px] px-2 py-0.5 rounded font-semibold border ${
|
||||
isHighCurvature
|
||||
|
|
|
|||
|
|
@ -198,6 +198,8 @@ export interface ClubRoute {
|
|||
curatorUid: string;
|
||||
curatorName: string;
|
||||
createdAt: string;
|
||||
/** True when this is MotorSignal sample/example content, not a real club-curated route. */
|
||||
isExample?: boolean;
|
||||
}
|
||||
|
||||
export const FIXTURE_CLUB_ROUTES: ClubRoute[] = [
|
||||
|
|
@ -256,15 +258,16 @@ export const FIXTURE_CLUB_ROUTES: ClubRoute[] = [
|
|||
|
||||
export async function fetchClubRoutes(clubId: string, options?: { allowDemoPreview?: boolean }): Promise<ClubRoute[]> {
|
||||
if (options?.allowDemoPreview) {
|
||||
return FIXTURE_CLUB_ROUTES.map((r) => ({ ...r, clubId }));
|
||||
return FIXTURE_CLUB_ROUTES.map((r) => ({ ...r, clubId, isExample: true }));
|
||||
}
|
||||
callerUid();
|
||||
const routesRef = collection(firestore, 'clubs', clubId, 'routes');
|
||||
const q = query(routesRef, orderBy('createdAt', 'desc'), limit(50));
|
||||
const snap = await getDocs(q);
|
||||
if (snap.empty) {
|
||||
// Return sample starter routes if no custom routes have been uploaded
|
||||
return FIXTURE_CLUB_ROUTES.map((r) => ({ ...r, clubId }));
|
||||
// No custom routes uploaded yet: surface sample starter routes, explicitly flagged as
|
||||
// examples so the UI can label them and they are never mistaken for real club routes.
|
||||
return FIXTURE_CLUB_ROUTES.map((r) => ({ ...r, clubId, isExample: true }));
|
||||
}
|
||||
return snap.docs.map((d) => {
|
||||
const data = d.data();
|
||||
|
|
@ -358,6 +361,8 @@ export interface ChatMessage {
|
|||
authorRole: ClubRole;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
/** True when this is MotorSignal sample/example content, not a real posted message. */
|
||||
isExample?: boolean;
|
||||
}
|
||||
|
||||
export const FIXTURE_CHAT_MESSAGES: Record<string, ChatMessage[]> = {
|
||||
|
|
@ -441,9 +446,10 @@ export function subscribeClubChat(
|
|||
q,
|
||||
(snap) => {
|
||||
if (snap.empty) {
|
||||
// Fallback to fixture messages if channel has no messages yet
|
||||
// No messages in this channel yet: surface sample messages, explicitly flagged as
|
||||
// examples so the UI can label them and they are never mistaken for real posts.
|
||||
const initial = FIXTURE_CHAT_MESSAGES[channelId] || [];
|
||||
onMessages(initial.map((m) => ({ ...m, clubId, channelId })));
|
||||
onMessages(initial.map((m) => ({ ...m, clubId, channelId, isExample: true })));
|
||||
return;
|
||||
}
|
||||
const msgs: ChatMessage[] = snap.docs.map((d) => {
|
||||
|
|
|
|||
|
|
@ -250,6 +250,18 @@ describe('Founder Routes Management', () => {
|
|||
expect(mocks.deleteRoute).toHaveBeenCalledWith('ClubID', 'r1');
|
||||
});
|
||||
});
|
||||
|
||||
// WEB-W-routes regression: fixture fallback routes must be labelled as examples so a
|
||||
// rider can never mistake sample content for the club's real curated routes.
|
||||
it('labels example routes when the club has none of its own', async () => {
|
||||
mocks.fetchRoutes.mockResolvedValue(sampleRoutes.map((r) => ({ ...r, isExample: true })));
|
||||
render(<FounderRoutes context={mockContext} />);
|
||||
await screen.findByText('Blood Mountain Gap Loop');
|
||||
|
||||
expect(screen.getByText(/example routes/i)).toBeDefined();
|
||||
expect(screen.getByText(/Examples$/)).toBeDefined();
|
||||
expect(screen.getAllByText('Example').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Founder Chat Management', () => {
|
||||
|
|
@ -296,6 +308,36 @@ describe('Founder Chat Management', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// WEB-W-chat regression: fixture fallback messages must be labelled as examples and the
|
||||
// header must not claim a live connection when only sample content is shown.
|
||||
it('labels example messages when the channel has no real posts', async () => {
|
||||
mocks.subscribeChat.mockImplementation((_clubId, _channelId, onMessages) => {
|
||||
onMessages([
|
||||
{
|
||||
id: 'example-msg-1',
|
||||
clubId: 'ClubID',
|
||||
channelId: 'general',
|
||||
authorUid: 'owner',
|
||||
authorDisplayName: 'Morgan Vance',
|
||||
authorHandle: '@morgan_v',
|
||||
authorDisplayRank: 'President',
|
||||
authorRole: 'owner',
|
||||
content: 'Welcome to squad chat!',
|
||||
createdAt: '2026-06-01T12:00:00Z',
|
||||
isExample: true,
|
||||
},
|
||||
]);
|
||||
return vi.fn();
|
||||
});
|
||||
|
||||
render(<FounderChat context={mockContext} />);
|
||||
await screen.findByText('Welcome to squad chat!');
|
||||
|
||||
expect(screen.getByText('Example thread')).toBeDefined();
|
||||
expect(screen.getByText(/example messages/i)).toBeDefined();
|
||||
expect(screen.queryByText('Live Connected')).toBeNull();
|
||||
});
|
||||
|
||||
it('restricts Officers Lounge channel for non-officers', async () => {
|
||||
const memberContext: FounderClubContext = {
|
||||
...mockContext,
|
||||
|
|
|
|||
Loading…
Reference in New Issue