SkateSpots/src/components/ReportDialog.tsx
SkateSpots 09735375c2 feat(i18n): Spanish (Castellano) — infra + Map/Spots, Marketplace, Lab (batch 1)
- 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.
2026-10-01 23:57:20 +07:00

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>
);
}