- First-visit language auto-detect (Spanish devices land in es; stored choice wins). - Sasquatch AI replies in the app language (skate trick names kept English). - i18n safety net: scripts/check-i18n.mjs wired into npm test — fails on any t() key missing from en/es or any en/es key drift. - ~1,680 new keys (2,297 total) across mapUi / spotUi / marketUi / labUi and ~65 files wired to t(): Map, spots (detail/create/edit/chat), marketplace, trades, offers, escrow, shipping, The Lab, /become/* forms, coaches, skaters. - Policy: only skate TRICK names stay English; user-generated content untouched.
323 lines
10 KiB
TypeScript
323 lines
10 KiB
TypeScript
import { useState } from 'react';
|
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Label } from '@/components/ui/label';
|
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
|
import { Loader2, Flag, AlertTriangle, CheckCircle2 } from 'lucide-react';
|
|
import { useTranslation } from '@/contexts/language';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
// NIP-56 Report types with skate-specific labels. `labelKey`/`descriptionKey`
|
|
// resolve to `spotUi.report.*` translations.
|
|
const REPORT_REASONS = [
|
|
{
|
|
id: 'other',
|
|
labelKey: 'spotUi.report.notASkateSpot.label',
|
|
descriptionKey: 'spotUi.report.notASkateSpot.description',
|
|
icon: '🛹',
|
|
subtype: 'not-a-skate-spot',
|
|
},
|
|
{
|
|
id: 'spam',
|
|
labelKey: 'spotUi.report.fakeSpot.label',
|
|
descriptionKey: 'spotUi.report.fakeSpot.description',
|
|
icon: '📍',
|
|
},
|
|
{
|
|
id: 'other',
|
|
labelKey: 'spotUi.report.stolenPhotos.label',
|
|
descriptionKey: 'spotUi.report.stolenPhotos.description',
|
|
icon: '📸',
|
|
},
|
|
{
|
|
id: 'spam',
|
|
labelKey: 'spotUi.report.spamAd.label',
|
|
descriptionKey: 'spotUi.report.spamAd.description',
|
|
icon: '🗑️',
|
|
subtype: 'spam-ad',
|
|
},
|
|
{
|
|
id: 'illegal',
|
|
labelKey: 'spotUi.report.dangerousLocation.label',
|
|
descriptionKey: 'spotUi.report.dangerousLocation.description',
|
|
icon: '⚠️',
|
|
},
|
|
{
|
|
id: 'nudity',
|
|
labelKey: 'spotUi.report.inappropriateContent.label',
|
|
descriptionKey: 'spotUi.report.inappropriateContent.description',
|
|
icon: '🔞',
|
|
},
|
|
{
|
|
id: 'impersonation',
|
|
labelKey: 'spotUi.report.impersonation.label',
|
|
descriptionKey: 'spotUi.report.impersonation.description',
|
|
icon: '👤',
|
|
},
|
|
{
|
|
id: 'other',
|
|
labelKey: 'spotUi.report.other.label',
|
|
descriptionKey: 'spotUi.report.other.description',
|
|
icon: '📝',
|
|
subtype: 'other-general',
|
|
},
|
|
] as const;
|
|
|
|
interface ReportDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
// What we're reporting
|
|
targetType: 'spot' | 'event' | 'tour' | 'shop' | 'user' | 'listing' | 'coach' | 'filmer';
|
|
targetPubkey: string;
|
|
targetEventId?: string;
|
|
targetTitle?: string;
|
|
// Callback after report
|
|
onReported?: () => void;
|
|
}
|
|
|
|
export function ReportDialog({
|
|
open,
|
|
onOpenChange,
|
|
targetType,
|
|
targetPubkey,
|
|
targetEventId,
|
|
targetTitle,
|
|
onReported,
|
|
}: ReportDialogProps) {
|
|
const { t } = useTranslation();
|
|
const { user } = useCurrentUser();
|
|
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
|
|
const { toast } = useToast();
|
|
|
|
const [selectedReason, setSelectedReason] = useState<string>('');
|
|
const [additionalDetails, setAdditionalDetails] = useState('');
|
|
const [step, setStep] = useState<'form' | 'success'>('form');
|
|
|
|
const handleSubmit = async () => {
|
|
if (!user || !selectedReason) return;
|
|
|
|
const reason = REPORT_REASONS.find(
|
|
(r) => r.id === selectedReason || ('subtype' in r && r.subtype === selectedReason)
|
|
);
|
|
if (!reason) return;
|
|
|
|
try {
|
|
// Build NIP-56 report tags
|
|
const tags: string[][] = [];
|
|
|
|
// Always include the pubkey being reported
|
|
if (targetEventId) {
|
|
// Reporting an event (spot, tour, etc.)
|
|
tags.push(['e', targetEventId, reason.id]);
|
|
tags.push(['p', targetPubkey]);
|
|
} else {
|
|
// Reporting just a user/profile
|
|
tags.push(['p', targetPubkey, reason.id]);
|
|
}
|
|
|
|
// Build content with context
|
|
let content = '';
|
|
if (additionalDetails.trim()) {
|
|
content = additionalDetails.trim();
|
|
}
|
|
if (targetTitle) {
|
|
content = content
|
|
? `${content}\n\nReported: ${targetTitle} (${targetType})`
|
|
: `Reported: ${targetTitle} (${targetType})`;
|
|
}
|
|
|
|
await publishEvent({
|
|
kind: 1984, // NIP-56 Report
|
|
content,
|
|
tags,
|
|
});
|
|
|
|
setStep('success');
|
|
onReported?.();
|
|
|
|
toast({
|
|
title: t('spotUi.reportSubmitted'),
|
|
description: t('spotUi.reportSubmittedDesc'),
|
|
});
|
|
} catch (error) {
|
|
toast({
|
|
title: t('spotUi.failedToSubmitReport'),
|
|
description: error instanceof Error ? error.message : t('spotUi.pleaseTryAgain'),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleClose = () => {
|
|
onOpenChange(false);
|
|
// Reset form after animation
|
|
setTimeout(() => {
|
|
setSelectedReason('');
|
|
setAdditionalDetails('');
|
|
setStep('form');
|
|
}, 200);
|
|
};
|
|
|
|
const getReasonKey = (reason: typeof REPORT_REASONS[number]) => {
|
|
return ('subtype' in reason ? reason.subtype : undefined) || reason.id;
|
|
};
|
|
|
|
if (!user) {
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Flag className="h-5 w-5 text-red-500" />
|
|
{t('spotUi.reportContent')}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="text-center py-6">
|
|
<AlertTriangle className="h-12 w-12 mx-auto mb-4 text-yellow-500" />
|
|
<p className="text-muted-foreground">
|
|
{t('spotUi.logInToReport')}
|
|
</p>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={handleClose}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Flag className="h-5 w-5 text-red-500" />
|
|
{t('spotUi.reportContent')}
|
|
</DialogTitle>
|
|
{step === 'form' && (
|
|
<DialogDescription>
|
|
{t('spotUi.reportContentDesc')}
|
|
</DialogDescription>
|
|
)}
|
|
</DialogHeader>
|
|
|
|
{step === 'form' ? (
|
|
<div className="space-y-4">
|
|
{/* Target info */}
|
|
{targetTitle && (
|
|
<div className="p-3 rounded-lg bg-muted/50 border">
|
|
<p className="text-xs text-muted-foreground uppercase mb-1">
|
|
{t('spotUi.reportingType', { type: t(`spotUi.targetType.${targetType}`) })}
|
|
</p>
|
|
<p className="font-medium text-sm truncate">{targetTitle}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Reason selection */}
|
|
<div className="space-y-2">
|
|
<Label>{t('spotUi.whyReporting')}</Label>
|
|
<RadioGroup
|
|
value={selectedReason}
|
|
onValueChange={setSelectedReason}
|
|
className="space-y-2"
|
|
>
|
|
{REPORT_REASONS.map((reason) => {
|
|
const key = getReasonKey(reason);
|
|
return (
|
|
<div
|
|
key={key}
|
|
className={cn(
|
|
'flex items-start space-x-3 p-3 rounded-lg border transition-colors cursor-pointer',
|
|
selectedReason === key
|
|
? 'border-red-500/50 bg-red-500/10'
|
|
: 'border-border hover:bg-muted/50'
|
|
)}
|
|
onClick={() => setSelectedReason(key)}
|
|
>
|
|
<RadioGroupItem value={key} id={key} className="mt-0.5" />
|
|
<div className="flex-1 min-w-0">
|
|
<Label
|
|
htmlFor={key}
|
|
className="flex items-center gap-2 cursor-pointer font-medium"
|
|
>
|
|
<span>{reason.icon}</span>
|
|
{t(reason.labelKey)}
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
{t(reason.descriptionKey)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</RadioGroup>
|
|
</div>
|
|
|
|
{/* Additional details */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="details">{t('spotUi.additionalDetails')}</Label>
|
|
<Textarea
|
|
id="details"
|
|
placeholder={t('spotUi.additionalDetailsPlaceholder')}
|
|
value={additionalDetails}
|
|
onChange={(e) => setAdditionalDetails(e.target.value)}
|
|
rows={3}
|
|
className="resize-none"
|
|
/>
|
|
</div>
|
|
|
|
{/* Submit */}
|
|
<div className="flex gap-2 pt-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleClose}
|
|
className="flex-1"
|
|
disabled={isPending}
|
|
>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
<Button
|
|
onClick={handleSubmit}
|
|
disabled={!selectedReason || isPending}
|
|
className="flex-1 bg-red-500 hover:bg-red-600"
|
|
>
|
|
{isPending ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
{t('spotUi.submitting')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Flag className="h-4 w-4 mr-2" />
|
|
{t('spotUi.submitReport')}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
/* Success state */
|
|
<div className="text-center py-6">
|
|
<div className="w-16 h-16 rounded-full bg-green-500/20 flex items-center justify-center mx-auto mb-4">
|
|
<CheckCircle2 className="h-8 w-8 text-green-500" />
|
|
</div>
|
|
<h3 className="font-bold text-lg mb-2">{t('spotUi.reportSubmittedTitle')}</h3>
|
|
<p className="text-muted-foreground text-sm mb-6">
|
|
{t('spotUi.reportSubmittedLong')}
|
|
</p>
|
|
<Button onClick={handleClose} className="w-full">
|
|
{t('common.done')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|