SkateSpots/src/components/CustomizeNavSheet.tsx
SkateSpots b2a6108eeb refactor: split non-component exports (react-refresh) into their own modules
Removes the last react-refresh disables by splitting exports instead:
- contexts/language.ts: Language/Translations types + LanguageContext +
  useTranslation/useT (LanguageProvider stays a component-only file); 12
  importers repointed.
- lib/sortOptions.ts: calculatePopularity + calculateDistance (out of SortDropdown)
- lib/trustFilter.ts: TrustFilterLevel + filterByTrustLevel (out of TrustFilter)
- EventCountdown: deleted the unused useEventStatus hook
- NewsletterPage: dropped the NEWSLETTER_ISSUES re-export shim
- AGENTS.md updated (lint stays clean with no blanket disables)

Gate: 101 tests, 0 errors/warnings, boot smoke green.
2026-09-26 23:29:42 +07:00

252 lines
9.6 KiB
TypeScript

import { useEffect, useState } from 'react';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import {
useNavPreferences,
MIDDLE_SLOTS,
type NavTab,
} from '@/hooks/useNavPreferences';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
import { MapPin, User, Lock, ArrowUp, ArrowDown, RotateCcw, Check, Sparkles } from 'lucide-react';
interface CustomizeNavSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* Bottom-sheet editor for the mobile nav bar.
*
* Layout model: Map (fixed left) · 3 user-picked · Profile (fixed right).
* The user toggles up to 3 destinations from the catalog and can reorder them.
* Draft state is local until "Done", so changes feel deliberate and cancelable.
*/
export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps) {
const { middleIds, setMiddleIds, reset, catalog } = useNavPreferences();
const { user } = useCurrentUser();
const { t } = useTranslation();
// Local draft so we can cancel without persisting.
const [draft, setDraft] = useState<string[]>(middleIds);
// Re-seed the draft whenever the sheet opens.
useEffect(() => {
if (open) setDraft(middleIds);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const isLoggedIn = !!user;
const full = draft.length >= MIDDLE_SLOTS;
const label = (tab: NavTab) => (tab.labelKey ? t(tab.labelKey) : tab.label);
const toggle = (id: string) => {
setDraft((prev) => {
if (prev.includes(id)) return prev.filter((x) => x !== id);
if (prev.length >= MIDDLE_SLOTS) return prev; // full — ignore
return [...prev, id];
});
};
const move = (id: string, dir: -1 | 1) => {
setDraft((prev) => {
const idx = prev.indexOf(id);
const next = idx + dir;
if (idx < 0 || next < 0 || next >= prev.length) return prev;
const copy = [...prev];
[copy[idx], copy[next]] = [copy[next], copy[idx]];
return copy;
});
};
const save = () => {
setMiddleIds(draft);
onOpenChange(false);
};
const handleReset = () => {
reset();
// reset() writes defaults; reseed draft from the same default source.
setDraft(['lab', 'events', 'crews']);
};
// Resolve draft ids to tabs for the preview & reorder list.
const draftTabs = draft
.map((id) => catalog.find((c) => c.id === id))
.filter((c): c is NavTab => !!c);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="bottom"
className="max-h-[90vh] overflow-y-auto rounded-t-3xl border-t border-white/10 bg-black/95 backdrop-blur"
>
<SheetHeader className="text-left">
<SheetTitle className="flex items-center gap-2 text-xl font-black uppercase text-[#ff0040]">
<Sparkles className="h-5 w-5" /> Customize your bar
</SheetTitle>
<SheetDescription className="text-gray-400">
Pick the {MIDDLE_SLOTS} tabs you use most. Everything else stays in the menu.
</SheetDescription>
</SheetHeader>
{/* Live preview */}
<div className="mt-4">
<p className="mb-2 text-[10px] font-bold uppercase tracking-widest text-gray-500">Preview</p>
<div className="glass flex items-stretch justify-around gap-1 rounded-2xl border border-white/10 p-2">
{/* Fixed Map */}
<PreviewSlot icon={MapPin} label={t('nav.map')} locked accent="#ff0040" />
{/* Middle draft */}
{Array.from({ length: MIDDLE_SLOTS }).map((_, i) => {
const tab = draftTabs[i];
if (!tab) {
return (
<div
key={`empty-${i}`}
className="flex flex-1 flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-white/10 py-1.5 text-gray-700"
>
<span className="text-lg leading-none">+</span>
<span className="text-[9px] font-semibold uppercase">Empty</span>
</div>
);
}
return <PreviewSlot key={tab.id} icon={tab.icon} label={label(tab)} accent="#ff0040" />;
})}
{/* Fixed Profile */}
<PreviewSlot icon={User} label={isLoggedIn ? 'Profile' : 'Log In'} locked accent="#ff0040" />
</div>
</div>
{/* Reorder chosen tabs */}
{draftTabs.length > 0 && (
<div className="mt-5">
<p className="mb-2 text-[10px] font-bold uppercase tracking-widest text-gray-500">
Order ({draft.length}/{MIDDLE_SLOTS})
</p>
<div className="space-y-1.5">
{draftTabs.map((tab, i) => (
<div
key={tab.id}
className="flex items-center gap-3 rounded-xl border border-[#ff0040]/30 bg-[#ff0040]/[0.06] p-2.5"
>
<tab.icon className="h-4 w-4 text-[#ff0040]" />
<span className="flex-1 text-sm font-bold text-white">{label(tab)}</span>
<button
type="button"
onClick={() => move(tab.id, -1)}
disabled={i === 0}
aria-label={`Move ${label(tab)} up`}
className="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-white/5 hover:text-white disabled:opacity-30"
>
<ArrowUp className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => move(tab.id, 1)}
disabled={i === draftTabs.length - 1}
aria-label={`Move ${label(tab)} down`}
className="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-white/5 hover:text-white disabled:opacity-30"
>
<ArrowDown className="h-4 w-4" />
</button>
</div>
))}
</div>
</div>
)}
{/* Catalog picker */}
<div className="mt-5">
<div className="mb-2 flex items-center justify-between">
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-500">Available tabs</p>
{full && (
<span className="text-[10px] font-bold text-amber-400">
Bar full — remove one to swap
</span>
)}
</div>
<div className="grid grid-cols-3 gap-2">
{catalog.map((tab) => {
const selected = draft.includes(tab.id);
const gatedOut = tab.authOnly && !isLoggedIn;
const disabled = gatedOut || (!selected && full);
return (
<button
key={tab.id}
type="button"
onClick={() => !gatedOut && toggle(tab.id)}
disabled={disabled}
aria-pressed={selected}
className={cn(
'relative flex flex-col items-center justify-center gap-1.5 rounded-2xl border p-3 transition-all active:scale-95',
selected
? 'border-[#ff0040] bg-[#ff0040]/15 text-white shadow-[0_0_12px_rgba(255,0,64,0.25)]'
: 'border-white/10 bg-white/[0.03] text-gray-300',
!selected && !disabled && 'hover:border-[#ff0040]/40 hover:text-white',
disabled && 'cursor-not-allowed opacity-40',
)}
>
{selected && (
<span className="absolute right-1.5 top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-[#ff0040]">
<Check className="h-2.5 w-2.5 text-white" />
</span>
)}
<tab.icon className={cn('h-5 w-5', selected ? 'text-[#ff0040]' : '')} />
<span className="text-[11px] font-bold uppercase leading-tight">{label(tab)}</span>
{gatedOut && (
<span className="flex items-center gap-0.5 text-[8px] font-bold text-gray-500">
<Lock className="h-2 w-2" /> Log in
</span>
)}
</button>
);
})}
</div>
</div>
{/* Footer actions */}
<div className="mt-6 flex items-center gap-3 pb-2">
<Button
variant="outline"
onClick={handleReset}
className="gap-2 border-white/15 text-gray-300 hover:text-white"
>
<RotateCcw className="h-4 w-4" /> Reset
</Button>
<Button
onClick={save}
className="flex-1 gap-2 bg-[#ff0040] font-black uppercase text-white hover:bg-[#ff0040]/90"
>
<Check className="h-4 w-4" /> Done
</Button>
</div>
</SheetContent>
</Sheet>
);
}
function PreviewSlot({
icon: Icon,
label,
locked,
accent,
}: {
icon: NavTab['icon'];
label: string;
locked?: boolean;
accent: string;
}) {
return (
<div className="relative flex flex-1 flex-col items-center justify-center gap-0.5 py-1.5">
{locked && (
<span className="absolute right-0.5 top-0 text-gray-600">
<Lock className="h-2.5 w-2.5" />
</span>
)}
<Icon className="h-[22px] w-[22px]" style={{ color: locked ? accent : accent }} />
<span className="text-[9px] font-semibold uppercase leading-tight text-gray-300">{label}</span>
</div>
);
}