- labelsUi.* (221+ keys): spot categories/features/types, access, shelter, event types, gear categories/conditions, price/listings, tours, crews, member-visibility, tag categories, spot status. New src/lib/labelKeys.ts holds the shared value->key maps (one source; the five duplicated EVENT_TYPE_KEYS copies now import it). spotStatus.statusTimeAgo() uses the locale date shim. - formatPrice() now takes an optional t so FREE/Trade/Make Offer translate. - Route helpers: skateTime/distanceReadout/maneuver descriptors/formatAge translated (routeUi.*), consumers updated. - Sasquatch: PRO_TIERS labels/badges + summarizeDraft titles/lines/ctas translated (sasquatchUi.*). - Lab *_LABELS/_DESCRIPTIONS maps remain English (documented out of scope).
215 lines
7.4 KiB
TypeScript
215 lines
7.4 KiB
TypeScript
import { useState } from 'react';
|
|
import { Loader2, Flag, Check } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from '@/components/ui/dialog';
|
|
import { LoginArea } from '@/components/auth/LoginArea';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { usePublishSpotStatus, useSpotStatus } from '@/hooks/useSpotStatus';
|
|
import { SpotStatusBadge } from '@/components/SpotStatusBadge';
|
|
import { SPOT_STATUS_META, SPOT_STATUS_ORDER, statusTimeAgo } from '@/lib/spotStatus';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import { useTranslation } from '@/contexts/language';
|
|
import type { SpotStatusValue, SpotStatusReport } from '@/lib/types';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface ReportSpotStatusDialogProps {
|
|
spotPubkey: string;
|
|
spotIdentifier: string;
|
|
spotTitle: string;
|
|
trigger?: React.ReactNode;
|
|
}
|
|
|
|
function ReportRow({ report }: { report: SpotStatusReport }) {
|
|
const { t } = useTranslation();
|
|
const author = useAuthor(report.pubkey);
|
|
const name = author.data?.metadata?.name ?? genUserName(report.pubkey);
|
|
const meta = SPOT_STATUS_META[report.status];
|
|
|
|
return (
|
|
<div className="flex items-start gap-2 py-2 border-b border-border/50 last:border-0">
|
|
<span
|
|
className="mt-1.5 inline-block rounded-full flex-shrink-0"
|
|
style={{ backgroundColor: meta.dot, width: 8, height: 8 }}
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<span className="font-semibold">{t(meta.labelKey)}</span>
|
|
<span className="text-muted-foreground truncate">{t('spotUi.byAuthor', { name })}</span>
|
|
<span className="text-muted-foreground ml-auto flex-shrink-0">
|
|
{statusTimeAgo(report.createdAt)}
|
|
</span>
|
|
</div>
|
|
{report.note && (
|
|
<p className="text-xs text-muted-foreground mt-0.5 break-words">{report.note}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ReportSpotStatusDialog({
|
|
spotPubkey,
|
|
spotIdentifier,
|
|
spotTitle,
|
|
trigger,
|
|
}: ReportSpotStatusDialogProps) {
|
|
const { t } = useTranslation();
|
|
const { user } = useCurrentUser();
|
|
const { toast } = useToast();
|
|
const [open, setOpen] = useState(false);
|
|
const [selected, setSelected] = useState<SpotStatusValue | null>(null);
|
|
const [note, setNote] = useState('');
|
|
|
|
const { aggregated, reports } = useSpotStatus(spotPubkey, spotIdentifier);
|
|
const { mutateAsync: publishStatus, isPending } = usePublishSpotStatus();
|
|
|
|
const handleSubmit = async () => {
|
|
if (!selected) return;
|
|
try {
|
|
await publishStatus({
|
|
spotPubkey,
|
|
spotIdentifier,
|
|
spotTitle,
|
|
status: selected,
|
|
note: note.trim() || undefined,
|
|
});
|
|
toast({
|
|
title: t('spotUi.statusReported'),
|
|
description: t('spotUi.statusReportedDesc'),
|
|
});
|
|
setSelected(null);
|
|
setNote('');
|
|
setOpen(false);
|
|
} catch (error) {
|
|
toast({
|
|
title: t('spotUi.couldNotSubmit'),
|
|
description: error instanceof Error ? error.message : t('spotUi.somethingWentWrong'),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogTrigger asChild>
|
|
{trigger ?? (
|
|
<Button variant="outline" size="sm" className="gap-2">
|
|
<Flag className="h-4 w-4" />
|
|
{t('spotUi.reportStatus')}
|
|
</Button>
|
|
)}
|
|
</DialogTrigger>
|
|
<DialogContent className="sm:max-w-md max-h-[85vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Flag className="h-5 w-5 text-[#ff0040]" />
|
|
{t('spotUi.spotStatus')}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t('spotUi.spotStatusDesc', { title: spotTitle })}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{/* Current consensus */}
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<span className="text-muted-foreground">{t('spotUi.current')}</span>
|
|
{aggregated.status ? (
|
|
<SpotStatusBadge aggregated={aggregated} />
|
|
) : (
|
|
<span className="text-muted-foreground italic">{t('spotUi.noReportsYet')}</span>
|
|
)}
|
|
</div>
|
|
|
|
{user ? (
|
|
<div className="space-y-4">
|
|
{/* Status picker */}
|
|
<div className="grid grid-cols-1 gap-2">
|
|
{SPOT_STATUS_ORDER.map((value) => {
|
|
const meta = SPOT_STATUS_META[value];
|
|
const isSel = selected === value;
|
|
return (
|
|
<button
|
|
key={value}
|
|
type="button"
|
|
onClick={() => setSelected(value)}
|
|
className={cn(
|
|
'flex items-center gap-3 rounded-lg border p-3 text-left transition-colors',
|
|
isSel
|
|
? 'border-[#ff0040] bg-[#ff0040]/10'
|
|
: 'border-border hover:border-border/80 hover:bg-muted/50',
|
|
)}
|
|
>
|
|
<span
|
|
className="inline-block rounded-full flex-shrink-0"
|
|
style={{ backgroundColor: meta.dot, width: 12, height: 12 }}
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-semibold text-sm">{t(meta.labelKey)}</p>
|
|
<p className="text-xs text-muted-foreground">{t(meta.descriptionKey)}</p>
|
|
</div>
|
|
{isSel && <Check className="h-4 w-4 text-[#ff0040] flex-shrink-0" />}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Optional note */}
|
|
<Textarea
|
|
placeholder={t('spotUi.statusNotePlaceholder')}
|
|
value={note}
|
|
onChange={(e) => setNote(e.target.value.slice(0, 500))}
|
|
rows={2}
|
|
className="resize-none"
|
|
/>
|
|
|
|
<Button
|
|
onClick={handleSubmit}
|
|
disabled={!selected || isPending}
|
|
className="w-full bg-[#ff0040] hover:bg-[#ff0040]/90"
|
|
>
|
|
{isPending ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
{t('spotUi.submitting')}
|
|
</>
|
|
) : (
|
|
t('spotUi.submitReport')
|
|
)}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('spotUi.logInToReportStatus')}
|
|
</p>
|
|
<LoginArea className="w-full" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Recent reports */}
|
|
{reports.length > 0 && (
|
|
<div className="mt-2">
|
|
<h4 className="text-xs font-semibold uppercase text-muted-foreground mb-1">
|
|
{t('spotUi.recentReports')}
|
|
</h4>
|
|
<div className="max-h-48 overflow-y-auto">
|
|
{reports.slice(0, 20).map((r) => (
|
|
<ReportRow key={r.id} report={r} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|