SkateSpots/src/components/ConfirmMembershipCard.tsx
SkateSpots e357833557 i18n(es): batch 3 part 3 — shared label maps + leftover routing/Sasquatch strings
- 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).
2026-10-02 15:31:27 +07:00

168 lines
5.7 KiB
TypeScript

import { useState } from 'react';
import { Users, Check, Loader2, Globe, EyeOff } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
import { useConfirmMembership } from '@/hooks/useMembershipAttestation';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
import {
MEMBER_VISIBILITY_DESCRIPTIONS,
type MemberVisibility,
} from '@/lib/types';
import { MEMBER_VISIBILITY_DESC_KEYS } from '@/lib/labelKeys';
interface ConfirmMembershipCardProps {
kind: number;
founderPubkey: string;
identifier: string;
collectiveName: string;
claimedMembers: string[];
accentBg?: string;
}
/** Two-option visibility picker used both when confirming and when editing. */
export function VisibilityPicker({
value,
onChange,
accentBg = 'bg-indigo-600',
}: {
value: MemberVisibility;
onChange: (v: MemberVisibility) => void;
accentBg?: string;
}) {
const { t } = useTranslation();
const options: { key: MemberVisibility; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
{ key: 'public', label: t('socialUi.confirmMembership.public'), icon: Globe },
{ key: 'crew', label: t('socialUi.confirmMembership.crewOnly'), icon: EyeOff },
];
return (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
{options.map(({ key, label, icon: Icon }) => {
const active = value === key;
return (
<button
key={key}
type="button"
onClick={() => onChange(key)}
className={cn(
'flex items-center justify-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-bold transition-all active:scale-95',
active
? cn('border-transparent text-white', accentBg)
: 'border-white/10 bg-white/5 text-gray-400 hover:bg-white/10 hover:text-white'
)}
>
<Icon className="h-4 w-4" />
{label}
</button>
);
})}
</div>
<p className="text-xs leading-relaxed text-gray-500">
{MEMBER_VISIBILITY_DESC_KEYS[value]
? t(MEMBER_VISIBILITY_DESC_KEYS[value])
: MEMBER_VISIBILITY_DESCRIPTIONS[value]}
</p>
</div>
);
}
/**
* Shown to a logged-in user who has been LISTED as a member of a collective but
* has not yet confirmed. Lets them pick their visibility and publishes a NIP-32
* attestation on click. Hidden for everyone else (non-members, already-confirmed
* members, the founder).
*/
export function ConfirmMembershipCard({
kind,
founderPubkey,
identifier,
collectiveName,
claimedMembers,
accentBg = 'bg-indigo-600 hover:bg-indigo-700',
}: ConfirmMembershipCardProps) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { toast } = useToast();
const { mutateAsync: confirm, isPending } = useConfirmMembership();
const [visibility, setVisibility] = useState<MemberVisibility>('public');
const { data: members } = useCollectiveMembers({
kind,
founderPubkey,
identifier,
claimedMembers,
enabled: claimedMembers.length > 0,
});
if (!user) return null;
const me = user.pubkey.toLowerCase();
// Only relevant if I'm actually listed as a member and I'm not the founder.
if (me === founderPubkey.toLowerCase()) return null;
if (!claimedMembers.includes(me)) return null;
const myStatus = members?.find((m) => m.pubkey === me)?.status;
if (myStatus === 'confirmed') return null;
const handleConfirm = async () => {
try {
await confirm({ kind, founderPubkey, identifier, collectiveName, visibility });
toast({
title: t('socialUi.confirmMembership.confirmedTitle'),
description:
visibility === 'crew'
? t('socialUi.confirmMembership.confirmedCrew', { name: collectiveName })
: t('socialUi.confirmMembership.confirmedPublic', { name: collectiveName }),
});
} catch (error) {
toast({
title: t('socialUi.confirmMembership.failed'),
description: error instanceof Error ? error.message : t('common.tryAgain'),
variant: 'destructive',
});
}
};
return (
<Card className="border-2 border-amber-500/50 bg-amber-950/20">
<CardContent className="space-y-4 p-4">
<div className="flex items-start gap-3">
<Users className="mt-0.5 h-6 w-6 shrink-0 text-amber-400" />
<div>
<h3 className="font-bold text-amber-300">{t('socialUi.confirmMembership.addedTo', { name: collectiveName })}</h3>
<p className="text-sm text-gray-300">
{t('socialUi.confirmMembership.addedDesc')}
</p>
</div>
</div>
<div>
<p className="mb-2 text-xs font-black uppercase tracking-widest text-gray-400">
{t('socialUi.confirmMembership.yourVisibility')}
</p>
<VisibilityPicker value={visibility} onChange={setVisibility} accentBg={accentBg.split(' ')[0]} />
</div>
<Button
type="button"
onClick={handleConfirm}
disabled={isPending}
className={cn('w-full gap-2 font-bold', accentBg)}
>
{isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin" /> {t('socialUi.confirmMembership.confirming')}
</>
) : (
<>
<Check className="h-4 w-4" /> {t('socialUi.confirmMembership.confirmButton')}
</>
)}
</Button>
</CardContent>
</Card>
);
}