~3,470 new keys (5,770 total, EN=ES), ~115 more files wired. - nav + chromeUi: header, More menu, burger menu, footer, map toolbar widgets (sort/trust/weather), spot-type label helper. - placesUi: events, shops, tours, food. socialUi: crews, DMs, notifications, collectives, crew chat/coordinator. settingsUi: settings, auth/signup, onboarding, profile edit, legal. walletUi: wallet/Cashu, stash, mints, app shop, newsletter, community stats, leaderboards, profile. - Fixed 738 usages where an agent dropped the socialUi. prefix (caught by check:i18n).
140 lines
4.7 KiB
TypeScript
140 lines
4.7 KiB
TypeScript
import { Check, HelpCircle, X, Loader2 } from 'lucide-react';
|
|
import { LoginArea } from '@/components/auth/LoginArea';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { useEventRsvps, useRsvp } from '@/hooks/useEventRsvps';
|
|
import { eventCoord } from '@/hooks/useEventRsvps';
|
|
import { cn } from '@/lib/utils';
|
|
import { useTranslation } from '@/contexts/language';
|
|
import type { RsvpStatus, SkateEvent } from '@/lib/types';
|
|
|
|
const OPTIONS: {
|
|
status: RsvpStatus;
|
|
label: string;
|
|
shortLabel: string;
|
|
icon: typeof Check;
|
|
activeClass: string;
|
|
}[] = [
|
|
{
|
|
status: 'accepted',
|
|
label: 'placesUi.rsvp.going',
|
|
shortLabel: 'placesUi.rsvp.goingShort',
|
|
icon: Check,
|
|
activeClass: 'bg-green-500 border-green-500 text-black',
|
|
},
|
|
{
|
|
status: 'tentative',
|
|
label: 'placesUi.rsvp.interested',
|
|
shortLabel: 'placesUi.rsvp.maybe',
|
|
icon: HelpCircle,
|
|
activeClass: 'bg-[#ccff00] border-[#ccff00] text-black',
|
|
},
|
|
{
|
|
status: 'declined',
|
|
label: 'placesUi.rsvp.cantMakeIt',
|
|
shortLabel: 'placesUi.rsvp.cant',
|
|
icon: X,
|
|
activeClass: 'bg-red-500/80 border-red-500 text-white',
|
|
},
|
|
];
|
|
|
|
/**
|
|
* NIP-52 RSVP control. A segmented Going / Interested / Can't-make-it toggle
|
|
* that publishes (or replaces) the user's RSVP. Tapping the active option again
|
|
* is a no-op. Logged-out users see a login prompt instead.
|
|
*/
|
|
export function RsvpButtons({ event, className }: { event: SkateEvent; className?: string }) {
|
|
const { t } = useTranslation();
|
|
const { user } = useCurrentUser();
|
|
const { toast } = useToast();
|
|
const coord = eventCoord(event);
|
|
const { myRsvp, myStatus, goingCount, tentativeCount } = useEventRsvps(coord);
|
|
const { mutateAsync: rsvp, isPending } = useRsvp();
|
|
|
|
const isHost = user?.pubkey === event.pubkey;
|
|
|
|
if (!user) {
|
|
return (
|
|
<div className={cn('rounded-xl border border-white/10 bg-white/[0.03] p-4 text-center', className)}>
|
|
<p className="mb-3 text-sm font-bold text-gray-300">
|
|
{t('placesUi.rsvp.areYouGoing')}{' '}
|
|
<span className="text-muted-foreground font-normal">{t('placesUi.rsvp.loginToRsvp')}</span>
|
|
</p>
|
|
<LoginArea className="w-full justify-center" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const handleClick = async (status: RsvpStatus) => {
|
|
if (status === myStatus || isPending) return;
|
|
try {
|
|
await rsvp({
|
|
event,
|
|
status,
|
|
existingIdentifier: myRsvp?.identifier,
|
|
});
|
|
const msg =
|
|
status === 'accepted'
|
|
? t('placesUi.rsvp.toastGoing')
|
|
: status === 'tentative'
|
|
? t('placesUi.rsvp.toastInterested')
|
|
: t('placesUi.rsvp.toastNotGoing');
|
|
toast({ title: msg });
|
|
} catch (err) {
|
|
toast({
|
|
title: t('placesUi.rsvp.toastError'),
|
|
description: err instanceof Error ? err.message : t('placesUi.rsvp.tryAgain'),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={cn('space-y-2', className)}>
|
|
{isHost && (
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('placesUi.rsvp.hostNote')}
|
|
</p>
|
|
)}
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{OPTIONS.map(({ status, label, shortLabel, icon: Icon, activeClass }) => {
|
|
const active = myStatus === status;
|
|
return (
|
|
<button
|
|
key={status}
|
|
type="button"
|
|
disabled={isPending}
|
|
onClick={() => handleClick(status)}
|
|
aria-pressed={active}
|
|
className={cn(
|
|
'flex flex-col items-center justify-center gap-1 rounded-xl border-2 py-2.5 text-xs font-black uppercase tracking-wide transition-all active:scale-95 disabled:opacity-60',
|
|
active
|
|
? activeClass
|
|
: 'border-white/10 bg-white/5 text-gray-400 hover:border-white/20 hover:text-white',
|
|
)}
|
|
>
|
|
{isPending && active ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Icon className="h-4 w-4" />
|
|
)}
|
|
<span className="hidden sm:inline">{t(label)}</span>
|
|
<span className="sm:hidden">{t(shortLabel)}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
{(goingCount > 0 || tentativeCount > 0) && (
|
|
<p className="text-center text-xs text-muted-foreground">
|
|
<span className="font-bold text-green-400">{goingCount}</span> {t('placesUi.rsvp.goingCount')}
|
|
{tentativeCount > 0 && (
|
|
<>
|
|
{' · '}
|
|
<span className="font-bold text-[#ccff00]">{tentativeCount}</span> {t('placesUi.rsvp.interestedCount')}
|
|
</>
|
|
)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|