feat(i18n): Spanish batch 2 — nav/chrome, events/shops/tours, crews/DMs, settings/auth, wallet/stash/newsletter/stats
~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).
This commit is contained in:
parent
c205ad34f0
commit
0b796440f1
136 changed files with 11615 additions and 3814 deletions
25
AGENTS.md
25
AGENTS.md
|
|
@ -1642,14 +1642,23 @@ similar are internal endpoints/hook names, not credits — leave them.)
|
|||
chain) fails the build if any literal `t('…')` key is missing from `en` OR `es`,
|
||||
or if `en`/`es` drift. Dynamic template keys (`t(\`ns.${x}\`)`) are NOT checked —
|
||||
keep those few in sync by hand.
|
||||
- **Coverage (2026-10-01, batch 1 done):** ~1,680 new keys (2,297 total) and ~65
|
||||
files wired — **Map (`mapUi`)**, **spots (`spotUi`)** incl. detail/create/edit/
|
||||
chat, **Marketplace/trades/offers/escrow/shipping (`marketUi`)**, and **Lab/
|
||||
`/become/*`/coaches/skaters (`labUi`)**. **Still to do (batch 2+):** Settings,
|
||||
auth (LoginArea/dialogs), Onboarding, Events, Shops, Tours, Crews, DMs/group,
|
||||
Stash, Newsletter, Community Stats, legal (About/FAQ/Privacy/License/Glossary),
|
||||
Wallet/Mints, App Shop, Food, Bounties/leaderboards, Admin review. Untranslated
|
||||
text falls back to English, so partial coverage degrades gracefully.
|
||||
- **Coverage (2026-10-02, batches 1–2 done):** **~5,770 keys** (EN=ES) and ~180
|
||||
files wired. Namespaces: batch 1 — `mapUi`, `spotUi`, `marketUi`, `labUi`;
|
||||
batch 2 — `nav` (header/More/burger labels) + `chromeUi` (footer, toolbar
|
||||
widgets, spot-type labels), `placesUi` (events/shops/tours/food), `socialUi`
|
||||
(crews/DMs/notifications/collectives), `settingsUi` (settings/auth/signup/
|
||||
onboarding/profile/legal), `walletUi` (wallet/Cashu, stash, mints, app shop,
|
||||
newsletter, community stats, leaderboards, profile page).
|
||||
- **Still to translate (batch 3):** `RouteDialog`/`StopDepartures`/`TripItineraryCard`
|
||||
(routing UI), the Sasquatch panel/dialogs (`SasquatchGuide`, `SasquatchProDialog`,
|
||||
`SasquatchActionCard`, `SasquatchAiStatusCard`), `AddToCollectionDialog` edge
|
||||
copy, `SplashScreen`/`LazyMount`, and **shared data-module labels** that live
|
||||
outside components (`SPOT_TYPE_LABELS`/`SPOT_CATEGORIES` in `src/lib/types.ts`,
|
||||
`src/lib/skateTags.ts`, `src/lib/spotStatus.ts`, `src/lib/sortOptions.ts`).
|
||||
**Dates & relative times** are still English (no localized date layer yet).
|
||||
- ⚠️ A batch-2 agent dropped the `socialUi.` namespace prefix in ~36 files — the
|
||||
`check:i18n` guard caught all 738 usages. **Always run `node scripts/check-i18n.mjs`
|
||||
(it's in `npm test`) after wiring a file.**
|
||||
- ⚠️ **When wiring a file, keep `en.ts`/`es.ts` key-for-key in sync** and run
|
||||
`node scripts/check-i18n.mjs` before committing.
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
type CalendarEventInput,
|
||||
} from '@/lib/calendar';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
interface AddToCalendarProps {
|
||||
event: CalendarEventInput;
|
||||
|
|
@ -25,6 +26,7 @@ interface AddToCalendarProps {
|
|||
* Offers a native .ics download (Apple/Outlook) and a Google Calendar link.
|
||||
*/
|
||||
export function AddToCalendar({ event, color = '#06b6d4', className }: AddToCalendarProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
|
@ -35,7 +37,7 @@ export function AddToCalendar({ event, color = '#06b6d4', className }: AddToCale
|
|||
style={{ borderColor: `${color}55`, color }}
|
||||
>
|
||||
<CalendarPlus className="h-4 w-4" />
|
||||
Add to Calendar
|
||||
{t('placesUi.addToCalendar.add')}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
|
|
@ -44,7 +46,7 @@ export function AddToCalendar({ event, color = '#06b6d4', className }: AddToCale
|
|||
className="cursor-pointer"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Apple / Outlook (.ics)
|
||||
{t('placesUi.addToCalendar.appleOutlook')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<a
|
||||
|
|
@ -53,7 +55,7 @@ export function AddToCalendar({ event, color = '#06b6d4', className }: AddToCale
|
|||
rel="noopener noreferrer"
|
||||
>
|
||||
<CalendarClock className="h-4 w-4 mr-2" />
|
||||
Google Calendar
|
||||
{t('placesUi.addToCalendar.google')}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { Label } from '@/components/ui/label';
|
|||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import {
|
||||
useMySpotCollections,
|
||||
|
|
@ -29,6 +30,7 @@ interface AddToCollectionDialogProps {
|
|||
}
|
||||
|
||||
export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollectionDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: collections, isLoading } = useMySpotCollections();
|
||||
const { createCollection, isPending: isCreating } = useCreateSpotCollection();
|
||||
|
|
@ -45,8 +47,8 @@ export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollect
|
|||
const handleAddToCollection = async (collection: SpotCollection) => {
|
||||
if (collection.spotRefs.includes(spotRef)) {
|
||||
toast({
|
||||
title: 'Already saved',
|
||||
description: `"${spot.title}" is already in "${collection.title}"`,
|
||||
title: t('walletUi.addToCollection.alreadySaved'),
|
||||
description: t('walletUi.addToCollection.alreadySavedDesc', { spot: spot.title, collection: collection.title }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -59,14 +61,14 @@ export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollect
|
|||
spotIdentifier: spot.identifier,
|
||||
});
|
||||
toast({
|
||||
title: 'Spot saved!',
|
||||
description: `Added "${spot.title}" to "${collection.title}"`,
|
||||
title: t('walletUi.addToCollection.saved'),
|
||||
description: t('walletUi.addToCollection.savedDesc', { spot: spot.title, collection: collection.title }),
|
||||
});
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to save',
|
||||
description: error instanceof Error ? error.message : 'Something went wrong',
|
||||
title: t('walletUi.addToCollection.saveFailed'),
|
||||
description: error instanceof Error ? error.message : t('walletUi.addToCollection.somethingWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -85,8 +87,8 @@ export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollect
|
|||
spotRefs: [spotRef], // Add the current spot to the new collection
|
||||
});
|
||||
toast({
|
||||
title: 'Collection created!',
|
||||
description: `"${spot.title}" added to your new collection "${newTitle}"`,
|
||||
title: t('walletUi.addToCollection.created'),
|
||||
description: t('walletUi.addToCollection.createdDesc', { spot: spot.title, collection: newTitle }),
|
||||
});
|
||||
setNewTitle('');
|
||||
setNewDescription('');
|
||||
|
|
@ -94,8 +96,8 @@ export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollect
|
|||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to create collection',
|
||||
description: error instanceof Error ? error.message : 'Something went wrong',
|
||||
title: t('walletUi.addToCollection.createFailed'),
|
||||
description: error instanceof Error ? error.message : t('walletUi.addToCollection.somethingWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -108,11 +110,11 @@ export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollect
|
|||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-white">
|
||||
<FolderPlus className="h-5 w-5 text-[#ccff00]" />
|
||||
Save to Collection
|
||||
{t('walletUi.addToCollection.title')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-gray-400">Please log in to save spots to collections</p>
|
||||
<p className="text-gray-400">{t('walletUi.addToCollection.loginRequired')}</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
@ -125,10 +127,10 @@ export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollect
|
|||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-white">
|
||||
<FolderPlus className="h-5 w-5 text-[#ccff00]" />
|
||||
Save to Collection
|
||||
{t('walletUi.addToCollection.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-gray-400 text-sm">
|
||||
Save "{spot.title}" to one of your collections
|
||||
{t('walletUi.addToCollection.desc', { spot: spot.title })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -179,8 +181,8 @@ export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollect
|
|||
</p>
|
||||
<p className="text-[10px] sm:text-xs text-gray-500 flex items-center gap-1">
|
||||
<MapPin className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
||||
{collection.spotRefs.length} spot{collection.spotRefs.length !== 1 ? 's' : ''}
|
||||
{isInCollection && ' · Already saved'}
|
||||
{t('walletUi.addToCollection.spotCount', { count: collection.spotRefs.length })}
|
||||
{isInCollection && ` · ${t('walletUi.addToCollection.alreadySavedTag')}`}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
|
@ -190,8 +192,8 @@ export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollect
|
|||
) : !showNewForm ? (
|
||||
<div className="text-center py-4 sm:py-6 text-gray-400">
|
||||
<FolderOpen className="h-10 w-10 sm:h-12 sm:w-12 mx-auto mb-2 sm:mb-3 opacity-50" />
|
||||
<p className="text-sm sm:text-base">You don't have any collections yet</p>
|
||||
<p className="text-xs sm:text-sm">Create your first one below!</p>
|
||||
<p className="text-sm sm:text-base">{t('walletUi.addToCollection.noCollections')}</p>
|
||||
<p className="text-xs sm:text-sm">{t('walletUi.addToCollection.createFirst')}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
@ -199,7 +201,7 @@ export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollect
|
|||
{showNewForm ? (
|
||||
<form onSubmit={handleCreateCollection} className="space-y-3 sm:space-y-4 pt-3 sm:pt-4 border-t border-gray-800">
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<Label htmlFor="title" className="text-white text-sm">Collection Name</Label>
|
||||
<Label htmlFor="title" className="text-white text-sm">{t('walletUi.addToCollection.name')}</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={newTitle}
|
||||
|
|
@ -210,7 +212,7 @@ export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollect
|
|||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<Label htmlFor="description" className="text-white text-sm">Description (optional)</Label>
|
||||
<Label htmlFor="description" className="text-white text-sm">{t('walletUi.addToCollection.descriptionOptional')}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={newDescription}
|
||||
|
|
@ -227,7 +229,7 @@ export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollect
|
|||
onClick={() => setShowNewForm(false)}
|
||||
className="flex-1 border-gray-700 h-11"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
|
|
@ -237,14 +239,14 @@ export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollect
|
|||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
<span className="hidden sm:inline">Creating...</span>
|
||||
<span className="hidden sm:inline">{t('walletUi.addToCollection.creating')}</span>
|
||||
<span className="sm:hidden">...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Create & Save</span>
|
||||
<span className="sm:hidden">Create</span>
|
||||
<span className="hidden sm:inline">{t('walletUi.addToCollection.createAndSave')}</span>
|
||||
<span className="sm:hidden">{t('common.create')}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
|
@ -257,7 +259,7 @@ export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollect
|
|||
className="w-full border-dashed border-gray-600 hover:border-[#ccff00]/50 hover:bg-[#ccff00]/5 h-11"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create New Collection
|
||||
{t('walletUi.addToCollection.createNew')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||
import { isAdminPubkey } from '@/lib/admins';
|
||||
import { isFlagged } from '@/lib/spotQuality';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
/**
|
||||
* Admin-only entry point for the review queue. Renders nothing for everyone
|
||||
|
|
@ -14,6 +15,7 @@ export function AdminReviewCard() {
|
|||
const { user } = useCurrentUser();
|
||||
const isAdmin = isAdminPubkey(user?.pubkey);
|
||||
const { data: spots } = useSkateSpots(300);
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Cheap signals only here (no per-spot trust/status queries just to draw a
|
||||
// badge) — the review page does the deeper pass.
|
||||
|
|
@ -29,11 +31,11 @@ export function AdminReviewCard() {
|
|||
<div className="flex items-center gap-3 px-4 py-3.5">
|
||||
<ShieldAlert className="h-5 w-5 shrink-0 text-amber-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-bold text-amber-400">Review queue</p>
|
||||
<p className="text-sm font-bold text-amber-400">{t('settingsUi.adminReview.title')}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{flaggedCount > 0
|
||||
? `${flaggedCount} spot${flaggedCount === 1 ? '' : 's'} trip the junk signals — hide or dismiss.`
|
||||
: 'Spots that look like test entries or off-topic pins.'}
|
||||
? t('settingsUi.adminReview.flagged', { count: flaggedCount, s: flaggedCount === 1 ? '' : 's' })
|
||||
: t('settingsUi.adminReview.clean')}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 shrink-0 text-gray-500" />
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|||
import { useNostrLogin } from '@nostrify/react/login';
|
||||
import { isInteractiveSigner } from '@/lib/signerCapabilities';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
interface AmberApprovalHintProps {
|
||||
/**
|
||||
|
|
@ -32,13 +33,15 @@ interface AmberApprovalHintProps {
|
|||
* We DON'T try to reduce the count (that would weaken the encryption/authorship
|
||||
* model) — we just tell the truth up front. Renders nothing for silent signers.
|
||||
*/
|
||||
export function AmberApprovalHint({ approvals, action = 'send', className }: AmberApprovalHintProps) {
|
||||
export function AmberApprovalHint({ approvals, action, className }: AmberApprovalHintProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { logins } = useNostrLogin();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isInteractiveSigner(user, logins)) return null;
|
||||
|
||||
const multi = typeof approvals === 'number' && approvals > 1;
|
||||
const actionText = action ?? t('settingsUi.amberHint.defaultAction');
|
||||
|
||||
return (
|
||||
<p
|
||||
|
|
@ -51,12 +54,14 @@ export function AmberApprovalHint({ approvals, action = 'send', className }: Amb
|
|||
<span>
|
||||
{multi ? (
|
||||
<>
|
||||
Your signer will ask you to approve{' '}
|
||||
<strong className="font-semibold text-amber-300">{approvals} times</strong> to {action} —
|
||||
that's normal for encrypted messages (one approval per recipient). Approve each.
|
||||
{t('settingsUi.amberHint.multiBefore')}{' '}
|
||||
<strong className="font-semibold text-amber-300">
|
||||
{t('settingsUi.amberHint.multiTimes', { approvals })}
|
||||
</strong>{' '}
|
||||
{t('settingsUi.amberHint.multiAfter', { action: actionText })}
|
||||
</>
|
||||
) : (
|
||||
<>Your signer will ask you to approve to {action}.</>
|
||||
<>{t('settingsUi.amberHint.single', { action: actionText })}</>
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -7,10 +7,12 @@ import { useAuthor } from '@/hooks/useAuthor';
|
|||
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
const TRUSTED_THRESHOLD = 60;
|
||||
|
||||
function AttendeeRow({ pubkey, trusted }: { pubkey: string; trusted: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const author = useAuthor(pubkey);
|
||||
const name = author.data?.metadata?.name ?? genUserName(pubkey);
|
||||
const pic = author.data?.metadata?.picture;
|
||||
|
|
@ -35,10 +37,10 @@ function AttendeeRow({ pubkey, trusted }: { pubkey: string; trusted: boolean })
|
|||
{trusted && (
|
||||
<span
|
||||
className="flex items-center gap-1 rounded-full bg-[#ccff00]/15 px-2 py-0.5 text-[10px] font-black uppercase tracking-wide text-[#ccff00]"
|
||||
title="Someone you follow or a trusted mapper"
|
||||
title={t('placesUi.attendees.trustedTitle')}
|
||||
>
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
Trusted
|
||||
{t('placesUi.attendees.trusted')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -63,6 +65,7 @@ export function AttendeeList({
|
|||
tentative: string[];
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<'going' | 'tentative'>('going');
|
||||
|
||||
const allPubkeys = useMemo(() => [...new Set([...going, ...tentative])], [going, tentative]);
|
||||
|
|
@ -76,7 +79,7 @@ export function AttendeeList({
|
|||
if (going.length === 0 && tentative.length === 0) {
|
||||
return (
|
||||
<p className={cn('text-sm text-muted-foreground', className)}>
|
||||
No RSVPs yet — be the first to say you're going.
|
||||
{t('placesUi.attendees.noRsvps')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
|
@ -90,11 +93,11 @@ export function AttendeeList({
|
|||
<div className="flex items-center gap-2 rounded-lg border border-[#ccff00]/20 bg-[#ccff00]/5 px-3 py-2">
|
||||
<Users className="h-4 w-4 flex-shrink-0 text-[#ccff00]" />
|
||||
<p className="text-xs text-gray-300">
|
||||
<span className="font-black text-white">{going.length}</span> going
|
||||
<span className="font-black text-white">{going.length}</span> {t('placesUi.attendees.goingCount')}
|
||||
{trustedGoing > 0 && (
|
||||
<>
|
||||
{' · '}
|
||||
<span className="font-black text-[#ccff00]">{trustedGoing}</span> you trust
|
||||
<span className="font-black text-[#ccff00]">{trustedGoing}</span> {t('placesUi.attendees.youTrust')}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
|
@ -113,7 +116,7 @@ export function AttendeeList({
|
|||
: 'bg-white/5 text-gray-400 hover:text-white',
|
||||
)}
|
||||
>
|
||||
Going ({going.length})
|
||||
{t('placesUi.attendees.goingTab', { count: going.length })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -125,7 +128,7 @@ export function AttendeeList({
|
|||
: 'bg-white/5 text-gray-400 hover:text-white',
|
||||
)}
|
||||
>
|
||||
Interested ({tentative.length})
|
||||
{t('placesUi.attendees.interestedTab', { count: tentative.length })}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -142,7 +145,7 @@ export function AttendeeList({
|
|||
</div>
|
||||
) : (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
{tab === 'going' ? 'Nobody has confirmed yet.' : 'Nobody marked interested yet.'}
|
||||
{tab === 'going' ? t('placesUi.attendees.nobodyGoing') : t('placesUi.attendees.nobodyInterested')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
import { AlertTriangle, Code2, FlaskConical, ShieldOff, Wallet, ArrowRight } from 'lucide-react';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
/**
|
||||
* Bump this version to re-show the disclaimer to everyone
|
||||
|
|
@ -24,6 +25,7 @@ const REPO_URL =
|
|||
export function BetaDisclaimerDialog() {
|
||||
const [accepted, setAccepted] = useLocalStorage<boolean>(STORAGE_KEY, false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Open on first launch (after mount so SSR/hydration is clean)
|
||||
useEffect(() => {
|
||||
|
|
@ -69,11 +71,11 @@ export function BetaDisclaimerDialog() {
|
|||
</div>
|
||||
|
||||
<DialogTitle className="text-center text-xl font-black uppercase tracking-wide text-white">
|
||||
Heads up — this is beta
|
||||
{t('settingsUi.betaDisclaimer.title')}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="text-center text-sm text-gray-400">
|
||||
A quick word before you drop in.
|
||||
{t('settingsUi.betaDisclaimer.subtitle')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -81,18 +83,18 @@ export function BetaDisclaimerDialog() {
|
|||
<div className="mt-5 space-y-3">
|
||||
<DisclaimerRow
|
||||
icon={<Code2 className="h-4 w-4 text-[#ccff00]" />}
|
||||
title="Open-source & experimental"
|
||||
body="Built in the open by the community. Poke around, fork it, break it."
|
||||
title={t('settingsUi.betaDisclaimer.ossTitle')}
|
||||
body={t('settingsUi.betaDisclaimer.ossBody')}
|
||||
/>
|
||||
<DisclaimerRow
|
||||
icon={<FlaskConical className="h-4 w-4 text-[#ff6600]" />}
|
||||
title="In beta — not audited"
|
||||
body="The code has not undergone a formal security audit yet. Bugs happen."
|
||||
title={t('settingsUi.betaDisclaimer.betaTitle')}
|
||||
body={t('settingsUi.betaDisclaimer.betaBody')}
|
||||
/>
|
||||
<DisclaimerRow
|
||||
icon={<ShieldOff className="h-4 w-4 text-[#ff0040]" />}
|
||||
title="Handles real Bitcoin & Lightning"
|
||||
body="Use money on it at your own risk. Only load what you can afford to lose."
|
||||
title={t('settingsUi.betaDisclaimer.moneyTitle')}
|
||||
body={t('settingsUi.betaDisclaimer.moneyBody')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -100,15 +102,14 @@ export function BetaDisclaimerDialog() {
|
|||
<p className="mt-4 flex items-start gap-2 rounded-lg border border-gray-800 bg-gray-900/50 px-3 py-2.5 text-[11px] leading-relaxed text-gray-500">
|
||||
<Wallet className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-gray-600" />
|
||||
<span>
|
||||
No warranty of any kind. You are solely responsible for your own
|
||||
funds and keys.{' '}
|
||||
{t('settingsUi.betaDisclaimer.finePrint')}{' '}
|
||||
<a
|
||||
href={REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-[#ccff00] underline decoration-dotted underline-offset-2 hover:text-[#ccff00]/80"
|
||||
>
|
||||
View the code
|
||||
{t('settingsUi.betaDisclaimer.viewCode')}
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
|
|
@ -120,7 +121,7 @@ export function BetaDisclaimerDialog() {
|
|||
onClick={handleAccept}
|
||||
className="w-full bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-black uppercase tracking-wide h-12 shadow-[0_0_20px_rgba(255,0,64,0.3)] transition-all hover:shadow-[0_0_28px_rgba(255,0,64,0.45)]"
|
||||
>
|
||||
Got it, let me in
|
||||
{t('settingsUi.betaDisclaimer.accept')}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { useLabProfile } from '@/hooks/useLabProfiles';
|
|||
import { useApproveCollectiveJoinRequest } from '@/hooks/useCollectiveJoinRequests';
|
||||
import { useDismissCrewNotification } from '@/hooks/useCrewDismissals';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { parseAddrCoord, type CollectiveJoinRequest } from '@/lib/types';
|
||||
import { UserPlus, X, Loader2, Hand, Check } from 'lucide-react';
|
||||
|
|
@ -39,6 +40,7 @@ export function CollectiveJoinRequestCard({
|
|||
onResolved?: () => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const requester = useAuthor(request.requester);
|
||||
const requesterName = requester.data?.metadata?.name ?? genUserName(request.requester);
|
||||
const requesterPic = requester.data?.metadata?.picture;
|
||||
|
|
@ -66,14 +68,14 @@ export function CollectiveJoinRequestCard({
|
|||
await approve({ request, profile });
|
||||
setDone('added');
|
||||
toast({
|
||||
title: `Added ${requesterName} to ${request.collectiveName} 🛹`,
|
||||
description: "They'll confirm from their side to appear as a verified member.",
|
||||
title: t('socialUi.collectiveJoinRequest.addedTitle', { name: requesterName, collective: request.collectiveName }),
|
||||
description: t('socialUi.collectiveJoinRequest.addedDesc'),
|
||||
});
|
||||
onResolved?.();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Could not add member',
|
||||
description: err instanceof Error ? err.message : 'Please try again.',
|
||||
title: t('socialUi.collectiveJoinRequest.couldNotAdd'),
|
||||
description: err instanceof Error ? err.message : t('common.tryAgain'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -85,7 +87,7 @@ export function CollectiveJoinRequestCard({
|
|||
setDone('dismissed');
|
||||
onResolved?.();
|
||||
} catch {
|
||||
toast({ title: 'Could not dismiss', variant: 'destructive' });
|
||||
toast({ title: t('socialUi.collectiveJoinRequest.couldNotDismiss'), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -95,13 +97,13 @@ export function CollectiveJoinRequestCard({
|
|||
{done === 'added' ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 text-sky-400" />
|
||||
Added <span className="font-bold text-white">{requesterName}</span> to{' '}
|
||||
{t('socialUi.collectiveJoinRequest.addedDone')} <span className="font-bold text-white">{requesterName}</span> {t('socialUi.collectiveJoinRequest.addedTo')}{' '}
|
||||
{request.collectiveName}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<X className="h-4 w-4 text-gray-500" />
|
||||
Dismissed request from {requesterName}
|
||||
{t('socialUi.collectiveJoinRequest.dismissedDone', { name: requesterName })}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -133,7 +135,7 @@ export function CollectiveJoinRequestCard({
|
|||
</Avatar>
|
||||
{requesterName}
|
||||
</Link>{' '}
|
||||
wants to join{' '}
|
||||
{t('socialUi.collectiveJoinRequest.wantsToJoin')}{' '}
|
||||
<Link to={collectiveHref} className="font-black text-sky-300 hover:underline">
|
||||
{request.collectiveName}
|
||||
</Link>
|
||||
|
|
@ -146,14 +148,14 @@ export function CollectiveJoinRequestCard({
|
|||
|
||||
{alreadyMember ? (
|
||||
<p className="mt-2 flex items-center gap-1.5 text-xs font-bold text-sky-400/90">
|
||||
<Check className="h-3.5 w-3.5" /> Already on the roster
|
||||
<Check className="h-3.5 w-3.5" /> {t('socialUi.collectiveJoinRequest.alreadyOnRoster')}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
disabled={busy}
|
||||
className="ml-auto text-gray-500 underline-offset-2 hover:text-white hover:underline"
|
||||
>
|
||||
Clear
|
||||
{t('common.clear')}
|
||||
</button>
|
||||
</p>
|
||||
) : !profileLoading && !profile ? (
|
||||
|
|
@ -167,7 +169,7 @@ export function CollectiveJoinRequestCard({
|
|||
>
|
||||
<Link to={collectiveHref}>
|
||||
<UserPlus className="h-3.5 w-3.5" />
|
||||
Open collective
|
||||
{t('socialUi.collectiveJoinRequest.openCollective')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
|
|
@ -178,7 +180,7 @@ export function CollectiveJoinRequestCard({
|
|||
className="h-8 gap-1.5 border-white/15 text-gray-400 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{dismissing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
|
||||
Dismiss
|
||||
{t('socialUi.collectiveJoinRequest.dismiss')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -194,7 +196,7 @@ export function CollectiveJoinRequestCard({
|
|||
) : (
|
||||
<UserPlus className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{profileLoading ? 'Loading…' : 'Add to collective'}
|
||||
{profileLoading ? t('common.loading') : t('socialUi.collectiveJoinRequest.addToCollective')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -204,7 +206,7 @@ export function CollectiveJoinRequestCard({
|
|||
className="h-8 gap-1.5 border-white/15 text-gray-400 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{dismissing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
|
||||
Dismiss
|
||||
{t('socialUi.collectiveJoinRequest.dismiss')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useCollectiveMembers, type CollectiveMember } from '@/hooks/useCollectiveMembers';
|
||||
|
|
@ -41,10 +42,11 @@ function RoleChip({ role }: { role: CrewRole }) {
|
|||
}
|
||||
|
||||
function StatusChip({ status }: { status: CollectiveMember['status'] }) {
|
||||
const { t } = useTranslation();
|
||||
if (status === 'confirmed') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-green-500/15 px-2 py-0.5 text-xs font-bold text-green-500">
|
||||
<Check className="h-3 w-3" /> Confirmed
|
||||
<Check className="h-3 w-3" /> {t('socialUi.collectiveMembers.confirmed')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -53,11 +55,11 @@ function StatusChip({ status }: { status: CollectiveMember['status'] }) {
|
|||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 text-xs font-bold text-amber-500">
|
||||
<Clock className="h-3 w-3" /> Pending
|
||||
<Clock className="h-3 w-3" /> {t('socialUi.collectiveMembers.pending')}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Listed by the collective — awaiting this member’s confirmation.
|
||||
{t('socialUi.collectiveMembers.pendingTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
|
@ -65,25 +67,26 @@ function StatusChip({ status }: { status: CollectiveMember['status'] }) {
|
|||
}
|
||||
|
||||
function AdminChip() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-teal-500/15 px-2 py-0.5 text-xs font-bold text-teal-300">
|
||||
<ShieldCheck className="h-3 w-3" /> Admin
|
||||
<ShieldCheck className="h-3 w-3" /> {t('socialUi.inviteCard.admin')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CrewOnlyChip() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs font-bold text-gray-300">
|
||||
<EyeOff className="h-3 w-3" /> Crew only
|
||||
<EyeOff className="h-3 w-3" /> {t('socialUi.collectiveMembers.crewOnly')}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
This member chose to be visible only to crew-mates. You can see them
|
||||
because you're in the crew.
|
||||
{t('socialUi.collectiveMembers.crewOnlyTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
|
@ -124,6 +127,7 @@ function MemberRow({
|
|||
|
||||
/** Anonymous placeholder shown to NON-members for a `crew`-only member. */
|
||||
function HiddenMemberRow() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg bg-black/40 p-2">
|
||||
<Avatar className="h-9 w-9">
|
||||
|
|
@ -132,10 +136,10 @@ function HiddenMemberRow() {
|
|||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-gray-500 italic">
|
||||
Hidden member
|
||||
{t('socialUi.collectiveMembers.hiddenMember')}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-white/5 px-2 py-0.5 text-xs font-bold text-gray-500">
|
||||
<EyeOff className="h-3 w-3" /> Crew only
|
||||
<EyeOff className="h-3 w-3" /> {t('socialUi.collectiveMembers.crewOnly')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -155,6 +159,7 @@ export function CollectiveMembers({
|
|||
accentText = 'text-indigo-400',
|
||||
accentBorder = 'border-gray-700',
|
||||
}: CollectiveMembersProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: members, isLoading } = useCollectiveMembers({
|
||||
kind,
|
||||
|
|
@ -196,7 +201,7 @@ export function CollectiveMembers({
|
|||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
|
||||
<Users className={cn('h-5 w-5', accentText)} />
|
||||
Members · {claimedMembers.length}
|
||||
{t('socialUi.collectiveMembers.title')} · {claimedMembers.length}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
|
|
@ -204,9 +209,12 @@ export function CollectiveMembers({
|
|||
<ShieldCheck className={cn('mt-0.5 h-3.5 w-3.5 shrink-0', accentText)} />
|
||||
<span>
|
||||
{confirmedCount > 0
|
||||
? `${confirmedCount} of ${claimedMembers.length} verified — each confirmed member signed with their own Nostr key.`
|
||||
: 'Membership is verified per member. Each member confirms with their own Nostr key.'}
|
||||
{admins.length > 0 && ` ${admins.length} admin${admins.length !== 1 ? 's' : ''} help run the crew.`}
|
||||
? t('socialUi.collectiveMembers.verifiedOf', { confirmed: confirmedCount, total: claimedMembers.length })
|
||||
: t('socialUi.collectiveMembers.verifiedPerMember')}
|
||||
{admins.length > 0 &&
|
||||
(admins.length === 1
|
||||
? ` ${t('socialUi.collectiveMembers.adminsOne', { count: admins.length })}`
|
||||
: ` ${t('socialUi.collectiveMembers.adminsMany', { count: admins.length })}`)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ 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,
|
||||
|
|
@ -31,9 +32,10 @@ export function VisibilityPicker({
|
|||
onChange: (v: MemberVisibility) => void;
|
||||
accentBg?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const options: { key: MemberVisibility; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
|
||||
{ key: 'public', label: 'Public', icon: Globe },
|
||||
{ key: 'crew', label: 'Crew only', icon: EyeOff },
|
||||
{ key: 'public', label: t('socialUi.confirmMembership.public'), icon: Globe },
|
||||
{ key: 'crew', label: t('socialUi.confirmMembership.crewOnly'), icon: EyeOff },
|
||||
];
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
|
|
@ -79,6 +81,7 @@ export function ConfirmMembershipCard({
|
|||
claimedMembers,
|
||||
accentBg = 'bg-indigo-600 hover:bg-indigo-700',
|
||||
}: ConfirmMembershipCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { toast } = useToast();
|
||||
const { mutateAsync: confirm, isPending } = useConfirmMembership();
|
||||
|
|
@ -105,16 +108,16 @@ export function ConfirmMembershipCard({
|
|||
try {
|
||||
await confirm({ kind, founderPubkey, identifier, collectiveName, visibility });
|
||||
toast({
|
||||
title: 'Membership confirmed!',
|
||||
title: t('socialUi.confirmMembership.confirmedTitle'),
|
||||
description:
|
||||
visibility === 'crew'
|
||||
? `You're verified in ${collectiveName} — visible to crew-mates only.`
|
||||
: `You now show as a verified member of ${collectiveName}.`,
|
||||
? t('socialUi.confirmMembership.confirmedCrew', { name: collectiveName })
|
||||
: t('socialUi.confirmMembership.confirmedPublic', { name: collectiveName }),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to confirm',
|
||||
description: error instanceof Error ? error.message : 'Please try again.',
|
||||
title: t('socialUi.confirmMembership.failed'),
|
||||
description: error instanceof Error ? error.message : t('common.tryAgain'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -126,16 +129,16 @@ export function ConfirmMembershipCard({
|
|||
<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">You've been added to {collectiveName}</h3>
|
||||
<h3 className="font-bold text-amber-300">{t('socialUi.confirmMembership.addedTo', { name: collectiveName })}</h3>
|
||||
<p className="text-sm text-gray-300">
|
||||
Confirm your membership so it shows as verified on your profile and theirs.
|
||||
{t('socialUi.confirmMembership.addedDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-black uppercase tracking-widest text-gray-400">
|
||||
Your visibility
|
||||
{t('socialUi.confirmMembership.yourVisibility')}
|
||||
</p>
|
||||
<VisibilityPicker value={visibility} onChange={setVisibility} accentBg={accentBg.split(' ')[0]} />
|
||||
</div>
|
||||
|
|
@ -148,11 +151,11 @@ export function ConfirmMembershipCard({
|
|||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Confirming…
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> {t('socialUi.confirmMembership.confirming')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="h-4 w-4" /> Confirm Membership
|
||||
<Check className="h-4 w-4" /> {t('socialUi.confirmMembership.confirmButton')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { Input } from '@/components/ui/input';
|
|||
import { QRCodeCanvas } from '@/components/ui/qrcode';
|
||||
import { QRScannerModal } from '@/components/wallet/QRScannerModal';
|
||||
import { useCoordinator, useCoordinatorHealth, type CoordinatorHealthStatus } from '@/hooks/useCoordinator';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import {
|
||||
CORDN_APP_URL,
|
||||
|
|
@ -26,34 +27,34 @@ import {
|
|||
} from '@/lib/coordinator';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function timeAgo(unixSeconds: number): string {
|
||||
function timeAgo(unixSeconds: number, t: (key: string, params?: Record<string, string | number>) => string): string {
|
||||
const s = Math.floor(Date.now() / 1000 - unixSeconds);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
if (s < 60) return t('socialUi.coordinator.secondsAgo', { count: s });
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
if (m < 60) return t('socialUi.coordinator.minutesAgo', { count: m });
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
if (h < 24) return t('socialUi.coordinator.hoursAgo', { count: h });
|
||||
return t('socialUi.coordinator.daysAgo', { count: Math.floor(h / 24) });
|
||||
}
|
||||
|
||||
const STATUS_META: Record<
|
||||
CoordinatorHealthStatus,
|
||||
{ label: string; color: string; ring: string; dot: string }
|
||||
> = {
|
||||
idle: { label: 'Not checked', color: 'text-gray-400', ring: 'border-white/10', dot: 'bg-gray-500' },
|
||||
checking: { label: 'Checking…', color: 'text-blue-300', ring: 'border-blue-500/30', dot: 'bg-blue-400' },
|
||||
online: { label: 'Connected', color: 'text-green-400', ring: 'border-green-500/30', dot: 'bg-green-400' },
|
||||
stale: { label: 'Quiet', color: 'text-amber-400', ring: 'border-amber-500/30', dot: 'bg-amber-400' },
|
||||
offline: { label: 'Not seen', color: 'text-red-400', ring: 'border-red-500/30', dot: 'bg-red-500' },
|
||||
error: { label: 'Unreachable', color: 'text-red-400', ring: 'border-red-500/30', dot: 'bg-red-500' },
|
||||
idle: { label: 'coordinator.statusIdle', color: 'text-gray-400', ring: 'border-white/10', dot: 'bg-gray-500' },
|
||||
checking: { label: 'coordinator.statusChecking', color: 'text-blue-300', ring: 'border-blue-500/30', dot: 'bg-blue-400' },
|
||||
online: { label: 'coordinator.statusOnline', color: 'text-green-400', ring: 'border-green-500/30', dot: 'bg-green-400' },
|
||||
stale: { label: 'coordinator.statusStale', color: 'text-amber-400', ring: 'border-amber-500/30', dot: 'bg-amber-400' },
|
||||
offline: { label: 'coordinator.statusOffline', color: 'text-red-400', ring: 'border-red-500/30', dot: 'bg-red-500' },
|
||||
error: { label: 'coordinator.statusError', color: 'text-red-400', ring: 'border-red-500/30', dot: 'bg-red-500' },
|
||||
};
|
||||
|
||||
async function copyText(text: string, label: string) {
|
||||
async function copyText(text: string, copiedText: string, failedTitle: string, failedDesc: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast({ title: `${label} copied` });
|
||||
toast({ title: copiedText });
|
||||
} catch {
|
||||
toast({ title: 'Copy failed', description: 'Copy it manually.', variant: 'destructive' });
|
||||
toast({ title: failedTitle, description: failedDesc, variant: 'destructive' });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -66,6 +67,7 @@ async function copyText(text: string, label: string) {
|
|||
* probe: a ContextVM server is visible only if it announces.
|
||||
*/
|
||||
export function CoordinatorSettingsCard() {
|
||||
const { t } = useTranslation();
|
||||
const { coordinator, setCoordinator, removeCoordinator } = useCoordinator();
|
||||
const { status, lastSeen, eventCount, name, error, check } = useCoordinatorHealth(coordinator);
|
||||
|
||||
|
|
@ -84,8 +86,8 @@ export function CoordinatorSettingsCard() {
|
|||
if (!parsed) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: "That's not a coordinator",
|
||||
description: 'Paste an nprofile, an npub, or a cordn.net link.',
|
||||
title: t('socialUi.coordinator.notACoordinator'),
|
||||
description: t('socialUi.coordinator.notACoordinatorDesc'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -94,7 +96,7 @@ export function CoordinatorSettingsCard() {
|
|||
relays: parsed.relays.length ? parsed.relays : [CORDN_RELAY_URL],
|
||||
});
|
||||
setInput('');
|
||||
toast({ title: 'Coordinator added', description: shortNpub(parsed.pubkey) });
|
||||
toast({ title: t('socialUi.coordinator.added'), description: shortNpub(parsed.pubkey) });
|
||||
};
|
||||
|
||||
const meta = STATUS_META[status];
|
||||
|
|
@ -111,8 +113,10 @@ export function CoordinatorSettingsCard() {
|
|||
<span className="truncate">{name ?? shortNpub(coordinator.pubkey)}</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{coordinator.relays.length} relay{coordinator.relays.length === 1 ? '' : 's'}
|
||||
{lastSeen ? ` · last seen ${timeAgo(lastSeen)}` : ''}
|
||||
{coordinator.relays.length === 1
|
||||
? t('socialUi.coordinator.relayOne', { count: coordinator.relays.length })
|
||||
: t('socialUi.coordinator.relayMany', { count: coordinator.relays.length })}
|
||||
{lastSeen ? ` · ${t('socialUi.coordinator.lastSeen', { time: timeAgo(lastSeen, t) })}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
|
|
@ -123,30 +127,32 @@ export function CoordinatorSettingsCard() {
|
|||
)}
|
||||
>
|
||||
<span className={cn('h-1.5 w-1.5 rounded-full', meta.dot)} />
|
||||
{meta.label}
|
||||
{t(meta.label)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3.5 space-y-3">
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{status === 'online' && 'Announcing. Groups that use it can deliver messages now.'}
|
||||
{status === 'stale' && 'Found, but it has not announced recently — it may be asleep.'}
|
||||
{status === 'offline' &&
|
||||
'No announcement seen. It may be private (that is fine), or simply offline.'}
|
||||
{status === 'error' && (error ?? 'Could not reach the relays.')}
|
||||
{status === 'idle' && 'Checking…'}
|
||||
{status === 'checking' && 'Looking for its announcement…'}
|
||||
{eventCount > 0 && ` · ${eventCount} event${eventCount === 1 ? '' : 's'} found.`}
|
||||
{status === 'online' && t('socialUi.coordinator.onlineDesc')}
|
||||
{status === 'stale' && t('socialUi.coordinator.staleDesc')}
|
||||
{status === 'offline' && t('socialUi.coordinator.offlineDesc')}
|
||||
{status === 'error' && (error ?? t('socialUi.coordinator.errorDesc'))}
|
||||
{status === 'idle' && t('socialUi.coordinator.idleDesc')}
|
||||
{status === 'checking' && t('socialUi.coordinator.checkingDesc')}
|
||||
{eventCount > 0 &&
|
||||
(eventCount === 1
|
||||
? ` · ${t('socialUi.coordinator.eventFoundOne', { count: eventCount })}`
|
||||
: ` · ${t('socialUi.coordinator.eventFoundMany', { count: eventCount })}`)}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => void check()} disabled={status === 'checking'}>
|
||||
<RefreshCw className={cn('mr-1.5 h-3.5 w-3.5', status === 'checking' && 'animate-spin')} />
|
||||
Check
|
||||
{t('socialUi.coordinator.check')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => void copyText(coordinatorAddUrl(coordinator), 'Add link')}>
|
||||
<Button size="sm" variant="outline" onClick={() => void copyText(coordinatorAddUrl(coordinator), t('socialUi.coordinator.addLinkCopied'), t('socialUi.coordinator.copyFailed'), t('socialUi.coordinator.copyManually'))}>
|
||||
<Copy className="mr-1.5 h-3.5 w-3.5" />
|
||||
Add link
|
||||
{t('socialUi.coordinator.addLink')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setShowQr((v) => !v)}>
|
||||
<QrCode className="mr-1.5 h-3.5 w-3.5" />
|
||||
|
|
@ -157,7 +163,7 @@ export function CoordinatorSettingsCard() {
|
|||
variant="ghost"
|
||||
className="ml-auto text-muted-foreground hover:text-red-400"
|
||||
onClick={() => {
|
||||
if (window.confirm('Remove this coordinator from this device?')) removeCoordinator();
|
||||
if (window.confirm(t('socialUi.coordinator.removeConfirm'))) removeCoordinator();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
|
|
@ -168,7 +174,7 @@ export function CoordinatorSettingsCard() {
|
|||
<div className="flex flex-col items-center gap-2 rounded-xl bg-black/30 border border-white/5 p-4">
|
||||
<QRCodeCanvas value={coordinatorAddUrl(coordinator)} size={180} level="M" />
|
||||
<p className="text-[11px] text-muted-foreground text-center">
|
||||
Scan in Cordn to join this coordinator.
|
||||
{t('socialUi.coordinator.scanToJoin')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -182,67 +188,66 @@ export function CoordinatorSettingsCard() {
|
|||
<div className="rounded-2xl border border-cyan-500/25 bg-cyan-500/[0.06] p-4 space-y-3">
|
||||
<p className="text-sm font-bold text-white flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-cyan-400" />
|
||||
No coordinator yet
|
||||
{t('socialUi.coordinator.noCoordinator')}
|
||||
</p>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
A coordinator delivers your crew's <strong className="text-white">encrypted</strong> group
|
||||
messages in order. It sees <em>who, when and which group</em> — never the contents. Pick one,
|
||||
or run your own.
|
||||
{t('socialUi.coordinator.noCoordinatorDescA')}{' '}
|
||||
<strong className="text-white">{t('socialUi.coordinator.encryptedWord')}</strong>{' '}
|
||||
{t('socialUi.coordinator.noCoordinatorDescB')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Add / change ────────────────────────────────────────────────── */}
|
||||
<div className="rounded-2xl border border-white/8 bg-white/2 p-4 space-y-3">
|
||||
<p className="text-sm font-bold text-white">{coordinator ? 'Change coordinator' : 'Add a coordinator'}</p>
|
||||
<p className="text-sm font-bold text-white">{coordinator ? t('socialUi.coordinator.change') : t('socialUi.coordinator.addTitle')}</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="nprofile… or a cordn.net link"
|
||||
placeholder={t('socialUi.coordinator.inputPlaceholder')}
|
||||
spellCheck={false}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && input.trim()) handleAdd(input);
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" onClick={() => handleAdd(input)} disabled={!input.trim()} className="shrink-0">
|
||||
Add
|
||||
{t('socialUi.coordinator.add')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => setScanOpen(true)}>
|
||||
<ScanLine className="mr-1.5 h-3.5 w-3.5" />
|
||||
Scan a coordinator QR
|
||||
{t('socialUi.coordinator.scanQR')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── Run your own (the hand-off) ─────────────────────────────────── */}
|
||||
<div className="rounded-2xl border border-white/8 bg-white/2 overflow-hidden">
|
||||
<div className="px-4 py-3.5 border-b border-white/5">
|
||||
<p className="text-sm font-bold text-white">Run your own</p>
|
||||
<p className="text-sm font-bold text-white">{t('socialUi.coordinator.runOwn')}</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
It can live on an old phone, a Raspberry Pi, or any small box you leave on. Your group keeps
|
||||
working while it's up — and you can move the group whenever you like.
|
||||
{t('socialUi.coordinator.runOwnDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3.5 space-y-3 border-b border-white/5">
|
||||
<p className="flex items-center gap-2 text-xs font-bold text-white">
|
||||
<Smartphone className="h-3.5 w-3.5 text-cyan-400" />
|
||||
On a phone
|
||||
{t('socialUi.coordinator.onPhone')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use the Cordn Coordinator app, then scan its QR here to add it.
|
||||
{t('socialUi.coordinator.onPhoneDesc')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a href={CORDN_APP_URL} target="_blank" rel="noopener noreferrer">
|
||||
<Button size="sm" variant="outline">
|
||||
Open Cordn
|
||||
{t('socialUi.coordinator.openCordn')}
|
||||
<ExternalLink className="ml-1.5 h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</a>
|
||||
<Button size="sm" variant="outline" onClick={() => setScanOpen(true)}>
|
||||
<ScanLine className="mr-1.5 h-3.5 w-3.5" />
|
||||
Scan its QR
|
||||
{t('socialUi.coordinator.scanItsQR')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -250,7 +255,7 @@ export function CoordinatorSettingsCard() {
|
|||
<div className="px-4 py-3.5 space-y-3">
|
||||
<p className="flex items-center gap-2 text-xs font-bold text-white">
|
||||
<Server className="h-3.5 w-3.5 text-cyan-400" />
|
||||
On a server
|
||||
{t('socialUi.coordinator.onServer')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 rounded-xl bg-black/40 border border-white/5 px-3 py-2">
|
||||
<code className="flex-1 min-w-0 truncate text-[11px] font-mono text-gray-300">
|
||||
|
|
@ -258,23 +263,22 @@ export function CoordinatorSettingsCard() {
|
|||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText(CORDN_DOCKER_COMMAND, 'Command')}
|
||||
onClick={() => void copyText(CORDN_DOCKER_COMMAND, t('socialUi.coordinator.commandCopied'), t('socialUi.coordinator.copyFailed'), t('socialUi.coordinator.copyManually'))}
|
||||
className="shrink-0 text-muted-foreground hover:text-white transition-colors"
|
||||
aria-label="Copy command"
|
||||
aria-label={t('socialUi.coordinator.copyCommand')}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="flex items-start gap-2 text-[11px] text-amber-400/90 leading-relaxed">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
||||
Android may sleep a phone coordinator. Expect to move a group now and then.
|
||||
{t('socialUi.coordinator.androidSleep')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="px-1 text-[11px] leading-relaxed text-muted-foreground/70">
|
||||
🔒 A coordinator sees ciphertext and metadata only — never message contents. Run your own to keep
|
||||
even the metadata yours.
|
||||
{t('socialUi.coordinator.privacyNote')}
|
||||
</p>
|
||||
|
||||
<QRScannerModal
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { Input } from '@/components/ui/input';
|
|||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Send, Loader2, MapPin, ChevronRight, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { CREW_AGENT_AVATAR } from '@/lib/crewAgent';
|
||||
import { useCrewAgentChat, type CrewContext, type CrewAgentMessage } from '@/hooks/useCrewAgentChat';
|
||||
|
||||
|
|
@ -40,14 +41,14 @@ interface ReceiptItem {
|
|||
}
|
||||
|
||||
/** Normalise a CVM tool result into flat receipt cards. */
|
||||
function toReceiptItems(tool: string, data: unknown): ReceiptItem[] {
|
||||
function toReceiptItems(t: (key: string) => string, tool: string, data: unknown): ReceiptItem[] {
|
||||
const d = data as Record<string, unknown>;
|
||||
const take = <T,>(arr: unknown): T[] => (Array.isArray(arr) ? (arr as T[]).slice(0, 6) : []);
|
||||
|
||||
if (tool === 'find_spots') {
|
||||
return take<Record<string, string>>(d.spots).map((s, i) => ({
|
||||
key: `${s.url}-${i}`,
|
||||
title: s.name || 'Spot',
|
||||
title: s.name || t('socialUi.agentPanel.spot'),
|
||||
subtitle: [s.difficulty, s.location].filter(Boolean).join(' · ') || undefined,
|
||||
image: s.image,
|
||||
to: toAppPath(s.url),
|
||||
|
|
@ -56,7 +57,7 @@ function toReceiptItems(tool: string, data: unknown): ReceiptItem[] {
|
|||
if (tool === 'find_events') {
|
||||
return take<Record<string, string>>(d.events).map((e, i) => ({
|
||||
key: `${e.url}-${i}`,
|
||||
title: e.title || 'Event',
|
||||
title: e.title || t('socialUi.agentPanel.event'),
|
||||
subtitle: e.location,
|
||||
image: e.image,
|
||||
to: toAppPath(e.url),
|
||||
|
|
@ -65,7 +66,7 @@ function toReceiptItems(tool: string, data: unknown): ReceiptItem[] {
|
|||
if (tool === 'find_coaches') {
|
||||
return take<Record<string, string>>(d.profiles).map((p, i) => ({
|
||||
key: `${p.url}-${i}`,
|
||||
title: p.title || p.role || 'Lab pro',
|
||||
title: p.title || p.role || t('socialUi.agentPanel.labPro'),
|
||||
subtitle: [p.role, p.location].filter(Boolean).join(' · ') || undefined,
|
||||
image: p.image,
|
||||
to: toAppPath(p.url),
|
||||
|
|
@ -74,7 +75,7 @@ function toReceiptItems(tool: string, data: unknown): ReceiptItem[] {
|
|||
if (tool === 'find_gear') {
|
||||
return take<Record<string, string>>(d.listings).map((g, i) => ({
|
||||
key: `${g.url}-${i}`,
|
||||
title: g.title || 'Gear',
|
||||
title: g.title || t('socialUi.agentPanel.gear'),
|
||||
subtitle: g.condition,
|
||||
image: g.image,
|
||||
to: toAppPath(g.url),
|
||||
|
|
@ -83,7 +84,7 @@ function toReceiptItems(tool: string, data: unknown): ReceiptItem[] {
|
|||
if (tool === 'find_crews') {
|
||||
return take<Record<string, string>>(d.crews).map((c, i) => ({
|
||||
key: `${c.url}-${i}`,
|
||||
title: c.name || 'Crew',
|
||||
title: c.name || t('socialUi.agentPanel.crew'),
|
||||
subtitle: c.location,
|
||||
image: c.image,
|
||||
to: toAppPath(c.url),
|
||||
|
|
@ -93,7 +94,8 @@ function toReceiptItems(tool: string, data: unknown): ReceiptItem[] {
|
|||
}
|
||||
|
||||
function ReceiptCards({ tool, data, onNavigate }: { tool: string; data: unknown; onNavigate: () => void }) {
|
||||
const items = toReceiptItems(tool, data);
|
||||
const { t } = useTranslation();
|
||||
const items = toReceiptItems(t, tool, data);
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
|
|
@ -150,18 +152,18 @@ function MessageRow({ m, onNavigate }: { m: CrewAgentMessage; onNavigate: () =>
|
|||
);
|
||||
}
|
||||
|
||||
const SUGGESTIONS = [
|
||||
"What's our next jam?",
|
||||
'Who runs this crew?',
|
||||
'What are we looking for?',
|
||||
'Best spots near us?',
|
||||
"What's the weather for skating?",
|
||||
];
|
||||
|
||||
export function CrewAgentPanel({ open, onOpenChange, ctx }: CrewAgentPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { messages, isThinking, send } = useCrewAgentChat(ctx);
|
||||
const [draft, setDraft] = useState('');
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const suggestions = [
|
||||
t('socialUi.agentPanel.suggestion1'),
|
||||
t('socialUi.agentPanel.suggestion2'),
|
||||
t('socialUi.agentPanel.suggestion3'),
|
||||
t('socialUi.agentPanel.suggestion4'),
|
||||
t('socialUi.agentPanel.suggestion5'),
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
|
||||
|
|
@ -183,13 +185,13 @@ export function CrewAgentPanel({ open, onOpenChange, ctx }: CrewAgentPanelProps)
|
|||
<AvatarImage src={CREW_AGENT_AVATAR} alt="Sasquatch" />
|
||||
<AvatarFallback className="bg-teal-900/40 text-teal-300 text-[9px] font-black">SQ</AvatarFallback>
|
||||
</Avatar>
|
||||
Ask Sasquatch
|
||||
{t('socialUi.crew.askSasquatch')}
|
||||
<span className="rounded-full bg-teal-500/15 px-1.5 py-0.5 text-[10px] font-bold uppercase text-teal-300">
|
||||
Crew Agent
|
||||
{t('socialUi.crew.agent')}
|
||||
</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
Knows this crew — its jams, roster & open call — plus the wider scene with live data.
|
||||
{t('socialUi.agentPanel.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -201,11 +203,10 @@ export function CrewAgentPanel({ open, onOpenChange, ctx }: CrewAgentPanelProps)
|
|||
<AvatarFallback className="bg-teal-900/40 text-teal-300 font-black">SQ</AvatarFallback>
|
||||
</Avatar>
|
||||
<p className="text-sm text-muted-foreground max-w-[16rem]">
|
||||
Yo! I'm Sasquatch, {ctx.crewName}'s guide. Ask me about the crew — our next jam,
|
||||
who runs it, what we're after — or the wider scene.
|
||||
{t('socialUi.agentPanel.greeting', { crew: ctx.crewName })}
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-1.5">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
{suggestions.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => submit(s)}
|
||||
|
|
@ -225,7 +226,7 @@ export function CrewAgentPanel({ open, onOpenChange, ctx }: CrewAgentPanelProps)
|
|||
{isThinking && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-teal-400" />
|
||||
Sasquatch is thinking…
|
||||
{t('socialUi.agentPanel.thinking')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -241,7 +242,7 @@ export function CrewAgentPanel({ open, onOpenChange, ctx }: CrewAgentPanelProps)
|
|||
<Input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder="Ask about your local scene…"
|
||||
placeholder={t('socialUi.agentPanel.placeholder')}
|
||||
maxLength={300}
|
||||
className="flex-1"
|
||||
/>
|
||||
|
|
@ -255,7 +256,7 @@ export function CrewAgentPanel({ open, onOpenChange, ctx }: CrewAgentPanelProps)
|
|||
</Button>
|
||||
</form>
|
||||
<p className="mt-1.5 flex items-center justify-center gap-1 text-[10px] text-muted-foreground">
|
||||
<Sparkles className="h-2.5 w-2.5" /> AI can be wrong — links go to real listings.
|
||||
<Sparkles className="h-2.5 w-2.5" /> {t('socialUi.agentPanel.disclaimer')}
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { crewBadge, ACTIVITY_META, type CrewActivity } from '@/lib/crewHelpers';
|
||||
import type { SkateCrew } from '@/lib/types';
|
||||
|
||||
|
|
@ -52,19 +53,28 @@ export function CrewActivityPulse({
|
|||
showDormant?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!activity) return null;
|
||||
// In dense card grids we only surface positive signals — hide dormant crews.
|
||||
if (activity.level === 'dormant' && !showDormant) return null;
|
||||
|
||||
const meta = ACTIVITY_META[activity.level];
|
||||
const isHot = activity.level === 'hot';
|
||||
const upcoming =
|
||||
activity.upcomingEvents === 1
|
||||
? t('socialUi.badge.upcomingOne', { count: activity.upcomingEvents })
|
||||
: t('socialUi.badge.upcomingMany', { count: activity.upcomingEvents });
|
||||
const coming =
|
||||
activity.upcomingEvents === 1
|
||||
? t('socialUi.badge.comingOne', { count: activity.upcomingEvents })
|
||||
: t('socialUi.badge.comingMany', { count: activity.upcomingEvents });
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn('inline-flex items-center gap-1 text-[10px] font-bold', meta.text, className)}
|
||||
title={
|
||||
activity.upcomingEvents > 0
|
||||
? `${activity.upcomingEvents} upcoming event${activity.upcomingEvents === 1 ? '' : 's'}`
|
||||
? upcoming
|
||||
: meta.label
|
||||
}
|
||||
>
|
||||
|
|
@ -75,7 +85,7 @@ export function CrewActivityPulse({
|
|||
<span className={cn('relative inline-flex h-1.5 w-1.5 rounded-full', meta.dot)} />
|
||||
</span>
|
||||
{activity.upcomingEvents > 0
|
||||
? `${activity.upcomingEvents} event${activity.upcomingEvents === 1 ? '' : 's'} coming`
|
||||
? coming
|
||||
: meta.label}
|
||||
</span>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { Badge } from '@/components/ui/badge';
|
|||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { useNostrLogin } from '@nostrify/react/login';
|
||||
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
|
||||
import { AmberApprovalHint } from '@/components/AmberApprovalHint';
|
||||
|
|
@ -77,6 +78,7 @@ function CrewMessageBubble({
|
|||
canModerate?: boolean;
|
||||
onHide?: (message: DirectMessage) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const author = useAuthor(message.pubkey);
|
||||
const meta = author.data?.metadata;
|
||||
const name = meta?.name ?? genUserName(message.pubkey);
|
||||
|
|
@ -101,7 +103,7 @@ function CrewMessageBubble({
|
|||
<span className="text-[10px] text-muted-foreground shrink-0">{timeAgo}</span>
|
||||
{isHidden && (
|
||||
<span className="flex items-center gap-0.5 text-[10px] font-bold uppercase text-red-400">
|
||||
<EyeOff className="h-2.5 w-2.5" /> Hidden
|
||||
<EyeOff className="h-2.5 w-2.5" /> {t('socialUi.chat.hidden')}
|
||||
</span>
|
||||
)}
|
||||
{canModerate && !isHidden && onHide && (
|
||||
|
|
@ -109,8 +111,8 @@ function CrewMessageBubble({
|
|||
type="button"
|
||||
onClick={() => onHide(message)}
|
||||
className="text-muted-foreground opacity-0 transition-opacity hover:text-red-400 group-hover:opacity-100"
|
||||
aria-label="Hide this message"
|
||||
title="Hide message (admin)"
|
||||
aria-label={t('socialUi.chat.hideMessageAria')}
|
||||
title={t('socialUi.chat.hideMessageTitle')}
|
||||
>
|
||||
<EyeOff className="h-3 w-3" />
|
||||
</button>
|
||||
|
|
@ -138,6 +140,7 @@ export function CrewChat({
|
|||
canModerate = false,
|
||||
crew,
|
||||
}: CrewChatProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { logins } = useNostrLogin();
|
||||
const { toast } = useToast();
|
||||
|
|
@ -167,11 +170,11 @@ export function CrewChat({
|
|||
const handleHideMessage = async (m: DirectMessage) => {
|
||||
try {
|
||||
await hideMessage({ postId: m.id, postAuthor: m.pubkey });
|
||||
toast({ title: 'Message hidden', description: 'Hidden from the crew chat for everyone.' });
|
||||
toast({ title: t('socialUi.chat.messageHidden'), description: t('socialUi.chat.messageHiddenDesc') });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Failed to hide message',
|
||||
description: err instanceof Error ? err.message : 'Please try again.',
|
||||
title: t('socialUi.chat.messageHideFailed'),
|
||||
description: err instanceof Error ? err.message : t('common.tryAgain'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -270,8 +273,8 @@ export function CrewChat({
|
|||
|
||||
if (!canSendNip44) {
|
||||
toast({
|
||||
title: 'Encryption unavailable',
|
||||
description: 'Your signer does not support NIP-44 encryption. Please upgrade your extension.',
|
||||
title: t('socialUi.chat.encryptionUnavailable'),
|
||||
description: t('socialUi.chat.encryptionUnavailableDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -284,8 +287,8 @@ export function CrewChat({
|
|||
setDraft('');
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Message failed',
|
||||
description: err instanceof Error ? err.message : 'Could not send message.',
|
||||
title: t('socialUi.chat.messageFailed'),
|
||||
description: err instanceof Error ? err.message : t('socialUi.chat.couldNotSend'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -296,8 +299,8 @@ export function CrewChat({
|
|||
const recipients = participants.filter((p) => p !== user.pubkey);
|
||||
if (recipients.length === 0) {
|
||||
toast({
|
||||
title: 'No one to message yet',
|
||||
description: 'This crew has no other members to chat with.',
|
||||
title: t('socialUi.chat.noOneToMessage'),
|
||||
description: t('socialUi.chat.noOneToMessageDesc'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -306,13 +309,13 @@ export function CrewChat({
|
|||
await sendNip17({
|
||||
recipients,
|
||||
content: text,
|
||||
subject: `${crewName} crew chat`,
|
||||
subject: t('socialUi.chat.subject', { crew: crewName }),
|
||||
});
|
||||
setDraft('');
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Message failed',
|
||||
description: err instanceof Error ? err.message : 'Could not send message.',
|
||||
title: t('socialUi.chat.messageFailed'),
|
||||
description: err instanceof Error ? err.message : t('socialUi.chat.couldNotSend'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -323,13 +326,13 @@ export function CrewChat({
|
|||
<CardHeader className="pb-3 border-b border-white/5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
|
||||
<MessageSquare className="h-5 w-5 text-teal-400" /> Crew Chat
|
||||
<MessageSquare className="h-5 w-5 text-teal-400" /> {t('socialUi.chat.title')}
|
||||
</CardTitle>
|
||||
{usingCordn ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 border-emerald-500/40 bg-emerald-500/10 text-emerald-300 text-[10px] font-bold uppercase tracking-wide"
|
||||
title="Routed through this crew's Cordn coordinator over ContextVM. End-to-end encrypted."
|
||||
title={t('socialUi.chat.cordnBadgeTitle')}
|
||||
>
|
||||
<Server className="h-3 w-3" />
|
||||
Cordn
|
||||
|
|
@ -338,19 +341,19 @@ export function CrewChat({
|
|||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 border-teal-500/40 bg-teal-500/10 text-teal-400 text-[10px] font-bold uppercase tracking-wide"
|
||||
title="End-to-end encrypted — only crew members can read these messages."
|
||||
title={t('socialUi.chat.privateBadgeTitle')}
|
||||
>
|
||||
<Lock className="h-3 w-3" />
|
||||
Private
|
||||
{t('socialUi.chat.private')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1 pt-0.5">
|
||||
<ShieldCheck className="h-3 w-3 text-teal-400 shrink-0" />
|
||||
{usingCordn
|
||||
? 'Encrypted & routed through the crew coordinator (Cordn) — no messages hit public relays.'
|
||||
: 'Encrypted group chat for the founder & all listed crew members.'}
|
||||
{canModerate && ' You can hide messages from the crew as a moderator.'}
|
||||
? t('socialUi.chat.encryptedCordn')
|
||||
: t('socialUi.chat.encryptedNip17')}
|
||||
{canModerate && t('socialUi.chat.moderatorNote')}
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
|
|
@ -358,14 +361,13 @@ export function CrewChat({
|
|||
{!user ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-10 px-6 text-center">
|
||||
<Lock className="h-8 w-8 text-teal-400/40" />
|
||||
<p className="text-sm text-muted-foreground">Log in to see if you can join the crew chat.</p>
|
||||
<p className="text-sm text-muted-foreground">{t('socialUi.chat.loginToSee')}</p>
|
||||
</div>
|
||||
) : !isMember ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-10 px-6 text-center">
|
||||
<Lock className="h-8 w-8 text-teal-400/40" />
|
||||
<p className="text-sm text-muted-foreground max-w-xs">
|
||||
This chat is private to crew members. Ask the founder to add you to the crew to join
|
||||
the conversation.
|
||||
{t('socialUi.chat.membersOnly')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -388,7 +390,7 @@ export function CrewChat({
|
|||
<div className="flex h-full flex-col items-center justify-center text-center gap-2 py-8">
|
||||
<MessageSquare className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground max-w-[16rem]">
|
||||
No messages yet. Kick off the crew chat — plan a session, share a spot, hype a clip.
|
||||
{t('socialUi.chat.noMessages')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -397,10 +399,10 @@ export function CrewChat({
|
|||
.map((m) => (
|
||||
<div key={m.id}>
|
||||
{m.id === firstUnreadId && (
|
||||
<div className="flex items-center gap-2 py-1.5" aria-label="New messages">
|
||||
<div className="flex items-center gap-2 py-1.5" aria-label={t('socialUi.chat.newMessages')}>
|
||||
<span className="h-px flex-1 bg-teal-500/30" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide text-teal-400">
|
||||
New
|
||||
{t('socialUi.chat.new')}
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-teal-500/30" />
|
||||
</div>
|
||||
|
|
@ -427,7 +429,7 @@ export function CrewChat({
|
|||
handleSend(e);
|
||||
}
|
||||
}}
|
||||
placeholder="Message your crew…"
|
||||
placeholder={t('socialUi.chat.placeholder')}
|
||||
maxLength={2000}
|
||||
rows={1}
|
||||
className="flex-1 resize-none min-h-[40px] max-h-32"
|
||||
|
|
@ -436,7 +438,7 @@ export function CrewChat({
|
|||
type="submit"
|
||||
size="icon"
|
||||
disabled={isPending || !draft.trim()}
|
||||
aria-label="Send message"
|
||||
aria-label={t('socialUi.chat.sendAria')}
|
||||
className="bg-teal-500 hover:bg-teal-600 text-black shrink-0"
|
||||
>
|
||||
{isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { Textarea } from '@/components/ui/textarea';
|
|||
import { Label } from '@/components/ui/label';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
|
@ -59,6 +60,7 @@ function MemberRow({
|
|||
onToggleRole: (role: CrewRole) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const name = metadata?.name ?? genUserName(pubkey);
|
||||
|
|
@ -75,7 +77,7 @@ function MemberRow({
|
|||
{name}
|
||||
{isAdmin && (
|
||||
<span className="inline-flex items-center gap-0.5 rounded-full bg-teal-500/20 px-1.5 py-0.5 text-[10px] font-bold uppercase text-teal-300">
|
||||
<ShieldCheck className="h-2.5 w-2.5" /> Admin
|
||||
<ShieldCheck className="h-2.5 w-2.5" /> {t('socialUi.inviteCard.admin')}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
|
@ -90,19 +92,19 @@ function MemberRow({
|
|||
isAdmin ? 'text-teal-300 hover:text-teal-200' : 'text-gray-400 hover:text-teal-300',
|
||||
)}
|
||||
onClick={onToggleAdmin}
|
||||
aria-label={isAdmin ? 'Remove admin role' : 'Make admin'}
|
||||
aria-label={isAdmin ? t('socialUi.form.removeAdminRole') : t('socialUi.form.makeAdmin')}
|
||||
>
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
{isAdmin ? 'Admin' : 'Make admin'}
|
||||
{isAdmin ? t('socialUi.inviteCard.admin') : t('socialUi.form.makeAdmin')}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-gray-400 hover:text-red-400" onClick={onRemove} aria-label="Remove member">
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-gray-400 hover:text-red-400" onClick={onRemove} aria-label={t('socialUi.form.removeMember')}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Skate roles — the *functions* on the team. Additive, no powers. */}
|
||||
<div className="flex flex-wrap items-center gap-1 pl-11">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-gray-600">Roles:</span>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-gray-600">{t('socialUi.form.roles')}</span>
|
||||
{CREW_ROLE_ORDER.map((role) => {
|
||||
const active = roles.includes(role);
|
||||
const meta = CREW_ROLE_META[role];
|
||||
|
|
@ -154,6 +156,7 @@ function decodePubkeyInput(raw: string): string | null {
|
|||
* *before* adding. Shows a neutral "resolving…" state while metadata loads.
|
||||
*/
|
||||
function MemberPreview({ pubkey, alreadyAdded }: { pubkey: string; alreadyAdded: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const name = metadata?.name ?? genUserName(pubkey);
|
||||
|
|
@ -170,11 +173,11 @@ function MemberPreview({ pubkey, alreadyAdded }: { pubkey: string; alreadyAdded:
|
|||
</div>
|
||||
{alreadyAdded ? (
|
||||
<span className="shrink-0 rounded-full bg-gray-700 px-2 py-0.5 text-[10px] font-bold uppercase text-gray-300">
|
||||
Added
|
||||
{t('socialUi.form.added')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 text-[11px] font-bold text-teal-400">
|
||||
<Check className="h-3 w-3" /> Ready
|
||||
<Check className="h-3 w-3" /> {t('socialUi.form.ready')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -187,6 +190,7 @@ interface CrewFormProps {
|
|||
}
|
||||
|
||||
export function CrewForm({ existing }: CrewFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
|
||||
|
|
@ -350,11 +354,11 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
if (!raw) return;
|
||||
const hex = decodePubkeyInput(raw);
|
||||
if (!hex) {
|
||||
setMemberError('Enter a valid npub, nprofile, or hex pubkey.');
|
||||
setMemberError(t('socialUi.form.invalidMember'));
|
||||
return;
|
||||
}
|
||||
if (members.includes(hex)) {
|
||||
setMemberError('That member is already added.');
|
||||
setMemberError(t('socialUi.form.memberAlreadyAdded'));
|
||||
return;
|
||||
}
|
||||
setMembers([...members, hex]);
|
||||
|
|
@ -364,7 +368,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
|
||||
const getCurrentLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
toast({ title: 'Geolocation not supported', variant: 'destructive' });
|
||||
toast({ title: t('socialUi.form.geolocationUnsupported'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
setIsGettingLocation(true);
|
||||
|
|
@ -373,11 +377,11 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
setLatitude(position.coords.latitude.toFixed(6));
|
||||
setLongitude(position.coords.longitude.toFixed(6));
|
||||
setIsGettingLocation(false);
|
||||
toast({ title: 'Location found' });
|
||||
toast({ title: t('socialUi.form.locationFound') });
|
||||
},
|
||||
(error) => {
|
||||
setIsGettingLocation(false);
|
||||
toast({ title: 'Location error', description: error.message, variant: 'destructive' });
|
||||
toast({ title: t('socialUi.form.locationError'), description: error.message, variant: 'destructive' });
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
@ -390,10 +394,10 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
const url = tags[0]?.[1];
|
||||
if (url) {
|
||||
setImage(url);
|
||||
toast({ title: 'Logo uploaded' });
|
||||
toast({ title: t('socialUi.form.logoUploaded') });
|
||||
}
|
||||
} catch (error) {
|
||||
toast({ title: 'Upload failed', description: error instanceof Error ? error.message : 'Failed', variant: 'destructive' });
|
||||
toast({ title: t('socialUi.form.uploadFailed'), description: error instanceof Error ? error.message : t('socialUi.form.failed'), variant: 'destructive' });
|
||||
}
|
||||
e.target.value = '';
|
||||
};
|
||||
|
|
@ -405,7 +409,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
// advance rather than publish a half-filled crew. (Edit mode has no steps.)
|
||||
if (!existing && step < WIZARD_LAST_STEP) {
|
||||
if (step === 0 && !name.trim()) {
|
||||
toast({ title: 'Name your crew', description: 'A crew needs a name before you continue.', variant: 'destructive' });
|
||||
toast({ title: t('socialUi.form.nameCrew'), description: t('socialUi.form.nameCrewDesc'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
setNavDir('forward');
|
||||
|
|
@ -415,7 +419,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
}
|
||||
|
||||
if (!name.trim()) {
|
||||
toast({ title: 'Name required', description: 'Give your crew a name.', variant: 'destructive' });
|
||||
toast({ title: t('socialUi.form.nameRequired'), description: t('socialUi.form.nameRequiredDesc'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -549,10 +553,10 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
|
||||
await createEvent({ kind: SKATE_CREW_KIND, content: bio.trim(), tags });
|
||||
|
||||
toast({ title: existing ? 'Crew updated!' : 'Crew created!', description: 'Published to the scene.' });
|
||||
toast({ title: existing ? t('socialUi.form.crewUpdated') : t('socialUi.form.crewCreated'), description: t('socialUi.form.publishedToScene') });
|
||||
navigate(`/crew/${user?.pubkey}/${identifier}`);
|
||||
} catch (error) {
|
||||
toast({ title: 'Failed to publish', description: error instanceof Error ? error.message : 'Error', variant: 'destructive' });
|
||||
toast({ title: t('socialUi.form.publishFailed'), description: error instanceof Error ? error.message : t('common.error'), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -563,10 +567,10 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
|
||||
const logoBlock = (
|
||||
<div className="space-y-2">
|
||||
<Label>Crew Logo / Photo</Label>
|
||||
<Label>{t('socialUi.form.logoLabel')}</Label>
|
||||
{image ? (
|
||||
<div className="relative w-32 h-32 rounded-2xl overflow-hidden bg-gray-800">
|
||||
<img src={image} alt="Crew" className="w-full h-full object-cover" />
|
||||
<img src={image} alt={t('socialUi.form.logoAlt')} className="w-full h-full object-cover" />
|
||||
<button type="button" onClick={() => setImage(null)} className="absolute top-2 right-2 p-1 rounded-full bg-black/80 hover:bg-red-500">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
|
@ -574,7 +578,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
) : (
|
||||
<label className="w-32 h-32 rounded-2xl border-2 border-dashed border-gray-700 flex flex-col items-center justify-center cursor-pointer hover:bg-gray-800/50 hover:border-teal-500/50 transition-colors">
|
||||
<input type="file" accept="image/*" className="hidden" onChange={handleImageUpload} disabled={isUploading} />
|
||||
{isUploading ? <Loader2 className="h-6 w-6 animate-spin" /> : <><Upload className="h-6 w-6 mb-1 text-gray-500" /><span className="text-xs text-gray-500">Upload</span></>}
|
||||
{isUploading ? <Loader2 className="h-6 w-6 animate-spin" /> : <><Upload className="h-6 w-6 mb-1 text-gray-500" /><span className="text-xs text-gray-500">{t('socialUi.form.upload')}</span></>}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -584,17 +588,17 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
const nameBlock = (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="crew-name">Crew Name <span className="text-teal-400">*</span></Label>
|
||||
<Label htmlFor="crew-name">{t('socialUi.form.crewName')} <span className="text-teal-400">*</span></Label>
|
||||
{nameValid && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-bold text-teal-400">
|
||||
<Check className="h-3 w-3" /> Looks good
|
||||
<Check className="h-3 w-3" /> {t('socialUi.form.looksGood')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="crew-name"
|
||||
placeholder="e.g. Backyard Bandits"
|
||||
placeholder={t('socialUi.form.namePlaceholder')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={100}
|
||||
|
|
@ -608,21 +612,21 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
<Check className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-teal-400" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500">The only thing you truly need. You can change it later.</p>
|
||||
<p className="text-[11px] text-gray-500">{t('socialUi.form.nameHint')}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const bioBlock = (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="crew-bio">About / Manifesto <span className="font-normal text-gray-500">· optional</span></Label>
|
||||
<Label htmlFor="crew-bio">{t('socialUi.form.bioLabel')} <span className="font-normal text-gray-500">{t('socialUi.form.optional')}</span></Label>
|
||||
{bio.length > 0 && (
|
||||
<span className={cn('text-[11px] tabular-nums', bio.length > 2800 ? 'text-amber-400' : 'text-gray-500')}>
|
||||
{bio.length}/3000
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Textarea id="crew-bio" placeholder="Who you are, what you skate, your story..." value={bio} onChange={(e) => setBio(e.target.value)} rows={5} maxLength={3000} className="bg-gray-800 border-gray-700" />
|
||||
<Textarea id="crew-bio" placeholder={t('socialUi.form.bioPlaceholder')} value={bio} onChange={(e) => setBio(e.target.value)} rows={5} maxLength={3000} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
@ -630,27 +634,27 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="crew-location">Home Turf (City / Area)</Label>
|
||||
<Input id="crew-location" placeholder="Bristol, UK" value={location} onChange={(e) => setLocation(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Label htmlFor="crew-location">{t('socialUi.form.homeTurf')}</Label>
|
||||
<Input id="crew-location" placeholder={t('socialUi.form.homeTurfPlaceholder')} value={location} onChange={(e) => setLocation(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="crew-founded">Founded (Year)</Label>
|
||||
<Label htmlFor="crew-founded">{t('socialUi.form.founded')}</Label>
|
||||
<Input id="crew-founded" type="number" placeholder="2015" value={founded} onChange={(e) => setFounded(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Map Pin <span className="font-normal text-gray-500">· optional</span></Label>
|
||||
<p className="text-xs text-gray-500">Drop your crew on the map so locals can find you. Tap the button — no typing needed.</p>
|
||||
<Label>{t('socialUi.form.mapPin')} <span className="font-normal text-gray-500">{t('socialUi.form.optional')}</span></Label>
|
||||
<p className="text-xs text-gray-500">{t('socialUi.form.mapPinHint')}</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input placeholder="Latitude" value={latitude} onChange={(e) => setLatitude(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Input placeholder="Longitude" value={longitude} onChange={(e) => setLongitude(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Input placeholder={t('socialUi.form.latitude')} value={latitude} onChange={(e) => setLatitude(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Input placeholder={t('socialUi.form.longitude')} value={longitude} onChange={(e) => setLongitude(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={getCurrentLocation} disabled={isGettingLocation} className="mt-2 border-gray-700">
|
||||
{isGettingLocation ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Getting...</> : <><MapPin className="h-4 w-4 mr-2" />Use current location</>}
|
||||
{isGettingLocation ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />{t('socialUi.form.gettingLocation')}</> : <><MapPin className="h-4 w-4 mr-2" />{t('socialUi.form.useCurrentLocation')}</>}
|
||||
</Button>
|
||||
{existing?.geohash && !latitude && (
|
||||
<p className="text-xs text-gray-500">Leave blank to keep the current home turf location.</p>
|
||||
<p className="text-xs text-gray-500">{t('socialUi.form.leaveBlankLocation')}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -660,7 +664,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
<>
|
||||
{/* Vibe tags */}
|
||||
<div className="space-y-3">
|
||||
<Label>Vibe / Focus</Label>
|
||||
<Label>{t('socialUi.form.vibeLabel')}</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{VIBE_ENTRIES.map(([key, label]) => (
|
||||
<button
|
||||
|
|
@ -681,12 +685,12 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
|
||||
{/* Custom vibe / focus (user-defined hashtags) */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-gray-500">Add your own vibe or focus with a #</p>
|
||||
<p className="text-xs text-gray-500">{t('socialUi.form.customVibeHint')}</p>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">#</span>
|
||||
<Input
|
||||
placeholder="e.g. downhill, night-sessions, no-comply"
|
||||
placeholder={t('socialUi.form.customVibePlaceholder')}
|
||||
value={customVibeInput}
|
||||
onChange={(e) => setCustomVibeInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addCustomVibe(); } }}
|
||||
|
|
@ -696,7 +700,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
/>
|
||||
</div>
|
||||
<Button type="button" onClick={addCustomVibe} className={cn('shrink-0 gap-1', TEAL)}>
|
||||
<Plus className="h-4 w-4" /> Add
|
||||
<Plus className="h-4 w-4" /> {t('socialUi.form.add')}
|
||||
</Button>
|
||||
</div>
|
||||
{customVibes.length > 0 && (
|
||||
|
|
@ -711,7 +715,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
type="button"
|
||||
onClick={() => setCustomVibes(customVibes.filter((x) => x !== v))}
|
||||
className="text-teal-400/70 hover:text-red-400"
|
||||
aria-label={`Remove ${v}`}
|
||||
aria-label={t('socialUi.form.removeVibe', { vibe: v })}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
|
|
@ -728,12 +732,12 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
<div className="space-y-3 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
|
||||
<div>
|
||||
<Label className="flex items-center gap-2 text-teal-400">
|
||||
<Sparkles className="h-4 w-4" /> Crew Type
|
||||
<Sparkles className="h-4 w-4" /> {t('socialUi.form.crewType')}
|
||||
</Label>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Is your crew focused on a craft? Pick one to also show up in that scene in{' '}
|
||||
<strong className="text-gray-300">The Lab</strong>. Regular skate crews stay{' '}
|
||||
<strong className="text-gray-300">Mixed</strong>.
|
||||
{t('socialUi.form.crewTypeDescA')}{' '}
|
||||
<strong className="text-gray-300">{t('socialUi.form.theLab')}</strong>{t('socialUi.form.crewTypeDescB')}{' '}
|
||||
<strong className="text-gray-300">{t('socialUi.form.mixedWord')}</strong>{t('socialUi.form.crewTypeDescC')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
|
|
@ -780,21 +784,19 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
className="h-5 w-5 rounded-md object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
Crew Agent
|
||||
<span className="rounded-full bg-teal-500/15 px-1.5 py-0.5 text-[10px] font-bold uppercase text-teal-300">Beta</span>
|
||||
{t('socialUi.crew.agent')}
|
||||
<span className="rounded-full bg-teal-500/15 px-1.5 py-0.5 text-[10px] font-bold uppercase text-teal-300">{t('socialUi.form.beta')}</span>
|
||||
</Label>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Add <strong className="text-gray-300">Sasquatch</strong>, an AI guide who actually knows your crew.
|
||||
Members can ask about <strong className="text-gray-300">your next jam, who runs the crew, what you're
|
||||
looking for and your vibe</strong> — plus nearby spots, events, coaches and gear from live SkateSpots
|
||||
data. Runs right in the browser; no keys or setup. It's early (free-tier AI, so occasionally slow).
|
||||
{t('socialUi.form.agentDescA')} <strong className="text-gray-300">{t('socialUi.form.sasquatch')}</strong>{t('socialUi.form.agentDescB')}{' '}
|
||||
<strong className="text-gray-300">{t('socialUi.form.agentDescStrong')}</strong>{t('socialUi.form.agentDescC')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={agentEnabled}
|
||||
aria-label="Enable Crew Agent"
|
||||
aria-label={t('socialUi.form.enableAgent')}
|
||||
onClick={() => setAgentEnabled((v) => !v)}
|
||||
className={cn(
|
||||
'relative mt-1 h-6 w-11 flex-shrink-0 rounded-full transition-colors',
|
||||
|
|
@ -811,7 +813,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
</div>
|
||||
{agentEnabled && (
|
||||
<p className="flex items-center gap-1.5 text-[11px] font-bold text-teal-400 animate-in fade-in duration-200">
|
||||
<Check className="h-3.5 w-3.5" /> Sasquatch will appear on your crew page once you save.
|
||||
<Check className="h-3.5 w-3.5" /> {t('socialUi.form.agentEnabledNote')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -821,11 +823,11 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
<div className="space-y-3 rounded-lg border border-fuchsia-500/30 bg-fuchsia-500/[0.04] p-4">
|
||||
<div>
|
||||
<Label className="flex items-center gap-2 text-fuchsia-400">
|
||||
<Megaphone className="h-4 w-4" /> Open Call <span className="font-normal text-gray-500">· optional</span>
|
||||
<Megaphone className="h-4 w-4" /> {t('socialUi.form.openCall')} <span className="font-normal text-gray-500">{t('socialUi.form.optional')}</span>
|
||||
</Label>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Looking for a filmer, editor or new blood? Put out a call — it shows on your crew page so
|
||||
matching folks from <strong className="text-gray-300">The Lab</strong> can reach out.
|
||||
{t('socialUi.form.openCallDescA')}{' '}
|
||||
<strong className="text-gray-300">{t('socialUi.form.theLab')}</strong>{t('socialUi.form.openCallDescB')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
|
@ -857,19 +859,19 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<Label className="flex items-center gap-2 text-orange-400">
|
||||
<Target className="h-4 w-4" /> Crew Treasury <span className="font-normal text-gray-500">· optional</span>
|
||||
<Target className="h-4 w-4" /> {t('socialUi.treasury.title')} <span className="font-normal text-gray-500">{t('socialUi.form.optional')}</span>
|
||||
</Label>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Raise sats for a shared goal — fixing the local DIY spot, a crew trip, a comp prize pot.
|
||||
It's a transparent Lightning goal; zaps go <strong className="text-gray-300">straight to your wallet</strong>{' '}
|
||||
(via your profile's Lightning address). SkateSpots never holds the funds.
|
||||
{t('socialUi.form.treasuryDescA')}{' '}
|
||||
<strong className="text-gray-300">{t('socialUi.form.treasuryDescStrong')}</strong>{' '}
|
||||
{t('socialUi.form.treasuryDescB')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={goalEnabled}
|
||||
aria-label="Enable Crew Treasury"
|
||||
aria-label={t('socialUi.form.enableTreasury')}
|
||||
onClick={() => setGoalEnabled((v) => !v)}
|
||||
className={cn(
|
||||
'relative mt-1 h-6 w-11 flex-shrink-0 rounded-full transition-colors',
|
||||
|
|
@ -887,17 +889,17 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
{goalEnabled && (
|
||||
<div className="space-y-3 pt-1">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="goal-title" className="text-xs text-gray-400">What's the goal?</Label>
|
||||
<Label htmlFor="goal-title" className="text-xs text-gray-400">{t('socialUi.form.goalWhat')}</Label>
|
||||
<Input
|
||||
id="goal-title"
|
||||
placeholder="e.g. Concrete the DIY spot"
|
||||
placeholder={t('socialUi.form.goalPlaceholder')}
|
||||
value={goalTitle}
|
||||
onChange={(e) => setGoalTitle(e.target.value)}
|
||||
className="bg-gray-800 border-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="goal-target" className="text-xs text-gray-400">Target (sats)</Label>
|
||||
<Label htmlFor="goal-target" className="text-xs text-gray-400">{t('socialUi.form.goalTarget')}</Label>
|
||||
<Input
|
||||
id="goal-target"
|
||||
type="number"
|
||||
|
|
@ -910,7 +912,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
</div>
|
||||
<p className="flex items-start gap-1.5 text-[11px] text-gray-500">
|
||||
<Target className="mt-0.5 h-3 w-3 shrink-0 text-orange-400" />
|
||||
Make sure your Nostr profile has a Lightning address (lud16) so contributions can land.
|
||||
{t('socialUi.form.goalHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -920,24 +922,21 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
const membersBlock = (
|
||||
<div className="space-y-3 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
|
||||
<div>
|
||||
<Label className="flex items-center gap-2 text-teal-400"><Users className="h-4 w-4" /> Members</Label>
|
||||
<Label className="flex items-center gap-2 text-teal-400"><Users className="h-4 w-4" /> {t('socialUi.form.members')}</Label>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Add your crew's Nostr accounts. Each member confirms from their own key — until then they show as “pending”.
|
||||
{t('socialUi.form.membersDesc')}
|
||||
</p>
|
||||
<p className="mt-1 flex items-start gap-1 text-xs text-gray-500">
|
||||
<ShieldCheck className="mt-0.5 h-3 w-3 shrink-0 text-teal-400" />
|
||||
<span>
|
||||
Make trusted members <strong className="text-teal-300">admins</strong> — they can invite members
|
||||
and moderate the crew feed & chat with their own keys. You (the founder) always stay the
|
||||
owner of the crew's identity.
|
||||
{t('socialUi.form.adminsDescA')} <strong className="text-teal-300">{t('socialUi.form.adminsWord')}</strong> {t('socialUi.form.adminsDescB')}
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1 flex items-start gap-1 text-xs text-gray-500">
|
||||
<Eye className="mt-0.5 h-3 w-3 shrink-0 text-teal-400" />
|
||||
<span>
|
||||
Each member controls their <strong className="text-teal-300">own visibility</strong> — when they
|
||||
confirm from their key they can choose <em>Public</em> or <em>Crew-only</em> (hidden from
|
||||
outsiders). You can't set this for them; it's their call.
|
||||
{t('socialUi.form.visibilityDescA')} <strong className="text-teal-300">{t('socialUi.form.ownVisibility')}</strong> {t('socialUi.form.visibilityDescB')}{' '}
|
||||
<em>{t('socialUi.form.publicWord')}</em> {t('common.or')} <em>{t('socialUi.form.crewOnlyWord')}</em> {t('socialUi.form.visibilityDescC')}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -946,7 +945,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
{user && (
|
||||
<div className="rounded-lg border border-teal-500/20 bg-teal-500/[0.04] p-2.5">
|
||||
<p className="mb-1.5 flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-wider text-teal-400">
|
||||
<ShieldCheck className="h-3 w-3" /> Your roles (Team Leader)
|
||||
<ShieldCheck className="h-3 w-3" /> {t('socialUi.form.yourRoles')}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{CREW_ROLE_ORDER.map((role) => {
|
||||
|
|
@ -993,7 +992,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
disabled={!memberPreviewPk || members.includes(memberPreviewPk)}
|
||||
className={cn('shrink-0 gap-1', TEAL)}
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Add
|
||||
<Plus className="h-4 w-4" /> {t('socialUi.form.add')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
@ -1002,7 +1001,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
<MemberPreview pubkey={memberPreviewPk} alreadyAdded={members.includes(memberPreviewPk)} />
|
||||
) : memberInputDirty ? (
|
||||
<p className="text-xs text-amber-400/90">
|
||||
That doesn't look like a Nostr key yet — paste an <span className="font-mono">npub1…</span>, <span className="font-mono">nprofile1…</span>, or hex pubkey.
|
||||
{t('socialUi.form.notAKeyA')} <span className="font-mono">npub1…</span>{t('socialUi.form.notAKeyB')} <span className="font-mono">nprofile1…</span>{t('socialUi.form.notAKeyC')}
|
||||
</p>
|
||||
) : null}
|
||||
{memberError && <p className="text-xs text-red-400">{memberError}</p>}
|
||||
|
|
@ -1029,8 +1028,8 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
selectedSpots={selectedSpots}
|
||||
onSpotsChange={setSelectedSpots}
|
||||
maxSpots={10}
|
||||
label="Home Spots"
|
||||
description="Link the spots your crew haunts — they'll connect to your crew on the map"
|
||||
label={t('socialUi.form.homeSpots')}
|
||||
description={t('socialUi.form.homeSpotsDesc')}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -1039,13 +1038,12 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
const videoBlock = (
|
||||
<div className="space-y-2 rounded-lg border border-teal-500/30 bg-teal-500/[0.04] p-4">
|
||||
<Label htmlFor="crew-video" className="flex items-center gap-2 text-teal-400">
|
||||
<Film className="h-4 w-4" /> The Part <span className="font-normal text-gray-500">· optional</span>
|
||||
<Film className="h-4 w-4" /> {t('socialUi.video.title')} <span className="font-normal text-gray-500">{t('socialUi.form.optional')}</span>
|
||||
</Label>
|
||||
<p className="text-xs text-gray-500">
|
||||
Your crew's full part, montage or latest clip. Paste a{' '}
|
||||
<span className="font-semibold text-teal-300">YouTube</span> or{' '}
|
||||
<span className="font-semibold text-teal-300">Instagram</span> link — it'll play right on
|
||||
your crew page.
|
||||
{t('socialUi.form.videoDescA')}{' '}
|
||||
<span className="font-semibold text-teal-300">YouTube</span> {t('common.or')}{' '}
|
||||
<span className="font-semibold text-teal-300">Instagram</span>{t('socialUi.form.videoDescB')}
|
||||
</p>
|
||||
<Input
|
||||
id="crew-video"
|
||||
|
|
@ -1061,10 +1059,10 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
isCrewVideoUrl(videoUrl) ? (
|
||||
<p className="flex items-center gap-1.5 text-xs font-bold text-teal-400">
|
||||
<Film className="h-3.5 w-3.5" />
|
||||
{parseCrewVideo(videoUrl)?.source === 'youtube' ? 'YouTube video detected' : 'Instagram clip detected'} ✓
|
||||
{parseCrewVideo(videoUrl)?.source === 'youtube' ? t('socialUi.form.youtubeDetected') : t('socialUi.form.instagramDetected')} ✓
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-red-400">Only YouTube and Instagram links are supported.</p>
|
||||
<p className="text-xs text-red-400">{t('socialUi.form.onlyYouTubeInstagram')}</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1073,11 +1071,10 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
const sponsorBlock = (
|
||||
<div className="space-y-2 rounded-lg border border-amber-500/30 bg-amber-500/[0.04] p-4">
|
||||
<Label className="flex items-center gap-2 text-amber-400">
|
||||
<Store className="h-4 w-4" /> Sponsored by <span className="font-normal text-gray-500">· optional</span>
|
||||
<Store className="h-4 w-4" /> {t('socialUi.sponsorBanner.sponsoredBy')} <span className="font-normal text-gray-500">{t('socialUi.form.optional')}</span>
|
||||
</Label>
|
||||
<p className="text-xs text-gray-500">
|
||||
If a local shop backs your crew, pick them here to show a
|
||||
“Sponsored by” banner linking to their shop.
|
||||
{t('socialUi.form.sponsorDesc')}
|
||||
</p>
|
||||
<CrewSponsorPicker value={sponsorRef} onChange={setSponsorRef} />
|
||||
</div>
|
||||
|
|
@ -1086,25 +1083,20 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
const coordinatorBlock = (
|
||||
<div className="space-y-2 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
|
||||
<Label htmlFor="crew-coordinator" className="flex items-center gap-2 text-gray-300">
|
||||
<Server className="h-4 w-4" /> Custom Chat Coordinator <span className="font-normal text-gray-500">· experimental · your own relay</span>
|
||||
<Server className="h-4 w-4" /> {t('socialUi.coordinator.customTitle')} <span className="font-normal text-gray-500">{t('socialUi.coordinator.customBadge')}</span>
|
||||
</Label>
|
||||
<p className="text-xs text-gray-500">
|
||||
<span className="font-semibold text-gray-300">You don't need this.</span> Your crew already has private,
|
||||
end-to-end encrypted group chat built in — no setup. This field is only for crews who{' '}
|
||||
<span className="font-semibold text-gray-300">run their own</span>{' '}
|
||||
<span className="font-semibold text-gray-300">{t('socialUi.coordinator.customDescA')}</span> {t('socialUi.coordinator.customDescB')}{' '}
|
||||
<span className="font-semibold text-gray-300">{t('socialUi.coordinator.customRunOwn')}</span>{' '}
|
||||
<a
|
||||
href="https://github.com/Cordn-msg/cordn"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-teal-400 underline underline-offset-2 hover:text-teal-300"
|
||||
>
|
||||
Cordn coordinator
|
||||
{t('socialUi.coordinator.cordnCoordinator')}
|
||||
</a>{' '}
|
||||
— even on an old phone. Paste its <span className="font-mono">nprofile</span>, a cordn.net link, or an
|
||||
endpoint URL. A <span className="font-mono">wss://</span> endpoint is used as the crew's own chat relay
|
||||
(point it at a private one to keep even the metadata off public relays); an{' '}
|
||||
<span className="font-mono">https://</span> coordinator is addressed by its pubkey over the shared
|
||||
ContextVM relay. Leave blank to use the built-in chat.
|
||||
{t('socialUi.coordinator.customDescC')}
|
||||
</p>
|
||||
<Input
|
||||
id="crew-coordinator"
|
||||
|
|
@ -1122,7 +1114,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
</p>
|
||||
) : (
|
||||
<p className="text-[11px] text-amber-400">
|
||||
That doesn't look like a coordinator. Paste an nprofile, a cordn.net link, or an https/wss URL.
|
||||
{t('socialUi.coordinator.invalid')}
|
||||
</p>
|
||||
))}
|
||||
{myCoordinator && (
|
||||
|
|
@ -1134,7 +1126,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
onClick={() => setCoordinatorInput(coordinatorAddUrl(myCoordinator))}
|
||||
>
|
||||
<Server className="mr-1.5 h-3.5 w-3.5" />
|
||||
Use my coordinator
|
||||
{t('socialUi.coordinator.useMine')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1143,11 +1135,10 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
const nip05Block = (
|
||||
<div className="space-y-2 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
|
||||
<Label htmlFor="crew-nip05" className="flex items-center gap-2 text-teal-400">
|
||||
<AtSign className="h-4 w-4" /> Crew NIP-05 <span className="font-normal text-gray-500">· optional</span>
|
||||
<AtSign className="h-4 w-4" /> {t('socialUi.form.nip05Label')} <span className="font-normal text-gray-500">{t('socialUi.form.optional')}</span>
|
||||
</Label>
|
||||
<p className="text-xs text-gray-500">
|
||||
A verifiable handle you control on any domain, e.g. <code className="text-teal-300">crew@yourdomain.com</code>.
|
||||
It's verified against this crew's founder key — a green check shows when the domain confirms it.
|
||||
{t('socialUi.form.nip05DescA')} <code className="text-teal-300">crew@yourdomain.com</code>{t('socialUi.form.nip05DescB')}
|
||||
</p>
|
||||
<Input
|
||||
id="crew-nip05"
|
||||
|
|
@ -1163,30 +1154,30 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
|
||||
const linksBlock = (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-bold">Links</Label>
|
||||
<Label className="text-base font-bold">{t('socialUi.form.links')}</Label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="crew-instagram">Instagram</Label>
|
||||
<Input id="crew-instagram" placeholder="@crew" value={instagram} onChange={(e) => setInstagram(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="crew-website">Website</Label>
|
||||
<Label htmlFor="crew-website">{t('socialUi.form.website')}</Label>
|
||||
<Input id="crew-website" type="url" placeholder="https://crew.com" value={website} onChange={(e) => setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="crew-social">Other Socials</Label>
|
||||
<p className="text-xs text-gray-500">Add as many as you like — YouTube, TikTok, Discord, Telegram, etc.</p>
|
||||
<Label htmlFor="crew-social">{t('socialUi.form.otherSocials')}</Label>
|
||||
<p className="text-xs text-gray-500">{t('socialUi.form.otherSocialsDesc')}</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="crew-social"
|
||||
placeholder="@username or link"
|
||||
placeholder={t('socialUi.form.socialPlaceholder')}
|
||||
value={socialInput}
|
||||
onChange={(e) => setSocialInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addSocial(); } }}
|
||||
className="bg-gray-800 border-gray-700"
|
||||
/>
|
||||
<Button type="button" onClick={addSocial} className={cn('shrink-0 gap-1', TEAL)}>
|
||||
<Plus className="h-4 w-4" /> Add
|
||||
<Plus className="h-4 w-4" /> {t('socialUi.form.add')}
|
||||
</Button>
|
||||
</div>
|
||||
{socials.length > 0 && (
|
||||
|
|
@ -1200,7 +1191,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
size="icon"
|
||||
className="h-7 w-7 shrink-0 text-gray-400 hover:text-red-400"
|
||||
onClick={() => setSocials(socials.filter((_, idx) => idx !== i))}
|
||||
aria-label="Remove social link"
|
||||
aria-label={t('socialUi.form.removeSocial')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -1218,17 +1209,17 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
<div className="space-y-3 rounded-xl border border-purple-500/20 bg-purple-500/[0.04] p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Music className="h-4 w-4 text-purple-400" />
|
||||
<Label className="text-base font-bold">Crew Band <span className="text-xs font-normal text-gray-500">(optional)</span></Label>
|
||||
<Label className="text-base font-bold">{t('socialUi.crew.bandTitle')} <span className="text-xs font-normal text-gray-500">{t('socialUi.form.optionalParen')}</span></Label>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Got a band? Skate scenes and local music go hand in hand — drop your band name and a link to the tunes.
|
||||
{t('socialUi.form.bandDesc')}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="crew-band-name">Band name</Label>
|
||||
<Label htmlFor="crew-band-name">{t('socialUi.form.bandName')}</Label>
|
||||
<Input
|
||||
id="crew-band-name"
|
||||
placeholder="e.g. The Bail Outs"
|
||||
placeholder={t('socialUi.form.bandNamePlaceholder')}
|
||||
value={bandName}
|
||||
onChange={(e) => setBandName(e.target.value)}
|
||||
maxLength={100}
|
||||
|
|
@ -1236,7 +1227,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="crew-band-url">Music link</Label>
|
||||
<Label htmlFor="crew-band-url">{t('socialUi.form.musicLink')}</Label>
|
||||
<Input
|
||||
id="crew-band-url"
|
||||
type="url"
|
||||
|
|
@ -1259,17 +1250,17 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
>
|
||||
<Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-purple-300" />
|
||||
<span className="text-xs text-purple-200">
|
||||
You've got a band — tap to set your <strong>Crew Type</strong> to{' '}
|
||||
<strong>Band & Music</strong> so you also show up in The Lab's Music scene.
|
||||
{t('socialUi.form.bandNudgeA')} <strong>{t('socialUi.form.crewTypeWord')}</strong> {t('socialUi.form.bandNudgeB')}{' '}
|
||||
<strong>{t('socialUi.form.bandMusicWord')}</strong> {t('socialUi.form.bandNudgeC')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<p className="flex items-start gap-1.5 text-[11px] leading-snug text-gray-500">
|
||||
<Music className="mt-0.5 h-3 w-3 shrink-0 text-purple-400" />
|
||||
For a full band profile (genres, instruments, gigs), create a{' '}
|
||||
<strong className="text-gray-300">Musician</strong> in The Lab, then link it in the{' '}
|
||||
<strong className="text-gray-300">Linked Lab profiles</strong> section above.
|
||||
{t('socialUi.form.bandFooterA')}{' '}
|
||||
<strong className="text-gray-300">{t('socialUi.form.musicianWord')}</strong> {t('socialUi.form.bandFooterB')}{' '}
|
||||
<strong className="text-gray-300">{t('socialUi.form.linkedLabWord')}</strong> {t('socialUi.form.bandFooterC')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1283,9 +1274,9 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
<Card className="bg-gray-900 border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-teal-400" /> Edit Crew
|
||||
<Users className="h-5 w-5 text-teal-400" /> {t('socialUi.editCrew.title')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">Your crew's identity, home turf and members</CardDescription>
|
||||
<CardDescription className="text-gray-400">{t('socialUi.form.editDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{logoBlock}
|
||||
|
|
@ -1308,9 +1299,9 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
{bandBlock}
|
||||
<Button type="submit" className={cn('w-full uppercase font-black', TEAL)} disabled={busy}>
|
||||
{isPublishing ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Publishing...</>
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />{t('socialUi.form.publishing')}</>
|
||||
) : (
|
||||
<><Users className="h-4 w-4 mr-2" />Save Crew</>
|
||||
<><Users className="h-4 w-4 mr-2" />{t('socialUi.form.saveCrew')}</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
|
@ -1328,17 +1319,17 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
|
||||
const steps: { title: string; short: string; icon: typeof Users; hint: string; filled: boolean; content: ReactNode }[] = [
|
||||
{
|
||||
title: 'The Basics',
|
||||
short: 'Basics',
|
||||
title: t('socialUi.form.stepBasics'),
|
||||
short: t('socialUi.form.stepBasicsShort'),
|
||||
icon: Sparkles,
|
||||
hint: 'Just the essentials — you can spin up a crew with only this step.',
|
||||
hint: t('socialUi.form.stepBasicsHint'),
|
||||
filled: nameValid,
|
||||
content: (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col items-center gap-3 text-center sm:flex-row sm:items-start sm:text-left">
|
||||
{logoBlock}
|
||||
<p className="max-w-xs text-xs text-gray-500 sm:pt-8">
|
||||
A logo makes your crew instantly recognisable on the map and in feeds. Square works best.
|
||||
{t('socialUi.form.logoHint')}
|
||||
</p>
|
||||
</div>
|
||||
{nameBlock}
|
||||
|
|
@ -1348,10 +1339,10 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
),
|
||||
},
|
||||
{
|
||||
title: 'Identity & Vibe',
|
||||
short: 'Vibe',
|
||||
title: t('socialUi.form.stepVibe'),
|
||||
short: t('socialUi.form.stepVibeShort'),
|
||||
icon: Palette,
|
||||
hint: 'How your crew shows up around the app. All optional.',
|
||||
hint: t('socialUi.form.stepVibeHint'),
|
||||
filled: vibes.length > 0 || customVibes.length > 0 || focus !== 'mixed',
|
||||
content: (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -1361,10 +1352,10 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
),
|
||||
},
|
||||
{
|
||||
title: 'Squad & Spots',
|
||||
short: 'Squad',
|
||||
title: t('socialUi.form.stepSquad'),
|
||||
short: t('socialUi.form.stepSquadShort'),
|
||||
icon: Users,
|
||||
hint: 'Add your people and your home turf. You can always do this later.',
|
||||
hint: t('socialUi.form.stepSquadHint'),
|
||||
filled: members.length > 0 || selectedSpots.length > 0 || lookingFor.length > 0,
|
||||
content: (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -1375,10 +1366,10 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
),
|
||||
},
|
||||
{
|
||||
title: 'Extras',
|
||||
short: 'Extras',
|
||||
title: t('socialUi.form.stepExtras'),
|
||||
short: t('socialUi.form.stepExtrasShort'),
|
||||
icon: Plus,
|
||||
hint: 'Power-ups for later — skip anything you’re unsure about.',
|
||||
hint: t('socialUi.form.stepExtrasHint'),
|
||||
filled: hasExtras,
|
||||
content: (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -1402,7 +1393,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
const goNext = () => {
|
||||
// Only gate on the one truly-required field, at the step it lives on.
|
||||
if (step === 0 && !name.trim()) {
|
||||
toast({ title: 'Name your crew', description: 'A crew needs a name before you continue.', variant: 'destructive' });
|
||||
toast({ title: t('socialUi.form.nameCrew'), description: t('socialUi.form.nameCrewDesc'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
goToStep(Math.min(step + 1, lastStep));
|
||||
|
|
@ -1413,7 +1404,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Progress stepper */}
|
||||
<nav className="mb-6" aria-label="Progress">
|
||||
<nav className="mb-6" aria-label={t('socialUi.form.progress')}>
|
||||
<ol className="flex items-center">
|
||||
{steps.map((s, i) => {
|
||||
const isCurrent = i === step;
|
||||
|
|
@ -1421,7 +1412,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
const StepIcon = s.icon;
|
||||
const jumpTo = () => {
|
||||
if (i > step && !name.trim()) {
|
||||
toast({ title: 'Name your crew first', variant: 'destructive' });
|
||||
toast({ title: t('socialUi.form.nameCrewFirst'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
goToStep(i);
|
||||
|
|
@ -1433,7 +1424,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
onClick={jumpTo}
|
||||
className="group flex flex-col items-center gap-1.5"
|
||||
aria-current={isCurrent ? 'step' : undefined}
|
||||
aria-label={`Step ${i + 1}: ${s.title}`}
|
||||
aria-label={t('socialUi.form.stepAria', { step: i + 1, title: s.title })}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
|
|
@ -1479,7 +1470,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
<Card className="bg-gray-900 border-gray-700">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2 text-[11px] font-black uppercase tracking-widest text-teal-400/70">
|
||||
Step {step + 1} of {steps.length}
|
||||
{t('socialUi.form.stepOf', { step: step + 1, total: steps.length })}
|
||||
</div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<current.icon className="h-5 w-5 text-teal-400" /> {current.title}
|
||||
|
|
@ -1505,17 +1496,17 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
{nameValid ? (
|
||||
<>
|
||||
<Check className="h-3.5 w-3.5 text-teal-400" />
|
||||
Ready to roll — hit <strong className="text-teal-300">Create Crew</strong> whenever you like.
|
||||
{t('socialUi.form.readyToRollA')} <strong className="text-teal-300">{t('socialUi.crews.createCrew')}</strong> {t('socialUi.form.readyToRollB')}
|
||||
</>
|
||||
) : (
|
||||
<>Add a crew name back in <strong className="text-teal-300">Basics</strong> to finish.</>
|
||||
<>{t('socialUi.form.addNameInBasicsA')} <strong className="text-teal-300">{t('socialUi.form.stepBasicsShort')}</strong> {t('socialUi.form.addNameInBasicsB')}</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
{step > 0 ? (
|
||||
<Button type="button" variant="outline" onClick={goBack} disabled={busy} className="gap-1.5 border-gray-700">
|
||||
<ArrowLeftIcon className="h-4 w-4" /> Back
|
||||
<ArrowLeftIcon className="h-4 w-4" /> {t('common.back')}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="flex-1" />
|
||||
|
|
@ -1529,7 +1520,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (!name.trim()) {
|
||||
toast({ title: 'Name your crew first', variant: 'destructive' });
|
||||
toast({ title: t('socialUi.form.nameCrewFirst'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
goToStep(lastStep);
|
||||
|
|
@ -1537,19 +1528,19 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
disabled={busy}
|
||||
className="hidden text-gray-400 hover:text-white sm:inline-flex"
|
||||
>
|
||||
Skip to end
|
||||
{t('socialUi.form.skipToEnd')}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" onClick={goNext} disabled={busy} className={cn('flex-1 gap-1.5 font-black uppercase', TEAL)}>
|
||||
Continue <ArrowRight className="h-4 w-4" />
|
||||
{t('socialUi.form.continue')} <ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button type="submit" disabled={busy || !nameValid} className={cn('flex-1 font-black uppercase', TEAL)}>
|
||||
{isPublishing ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Publishing...</>
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />{t('socialUi.form.publishing')}</>
|
||||
) : (
|
||||
<><Users className="h-4 w-4 mr-2" />Create Crew</>
|
||||
<><Users className="h-4 w-4 mr-2" />{t('socialUi.crews.createCrew')}</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
|||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useAcceptCrewInvite, useDeclineCrewInvite } from '@/hooks/useCrewInvites';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { parseAddrCoord, type CrewInvite } from '@/lib/types';
|
||||
import { Users, Check, X, Loader2, ShieldCheck } from 'lucide-react';
|
||||
|
|
@ -24,6 +25,7 @@ export function CrewInviteCard({
|
|||
onResolved?: () => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const inviter = useAuthor(invite.inviter);
|
||||
const inviterName = inviter.data?.metadata?.name ?? genUserName(invite.inviter);
|
||||
const inviterPic = inviter.data?.metadata?.picture;
|
||||
|
|
@ -40,14 +42,14 @@ export function CrewInviteCard({
|
|||
await accept({ invite });
|
||||
setDone('accepted');
|
||||
toast({
|
||||
title: 'Invite accepted 🛹',
|
||||
description: `The crew founder confirms members on ${invite.crewName}. You'll appear on the roster once they approve.`,
|
||||
title: t('socialUi.inviteCard.acceptedTitle'),
|
||||
description: t('socialUi.inviteCard.acceptedDesc', { crew: invite.crewName }),
|
||||
});
|
||||
onResolved?.();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Could not accept',
|
||||
description: err instanceof Error ? err.message : 'Please try again.',
|
||||
title: t('socialUi.inviteCard.couldNotAccept'),
|
||||
description: err instanceof Error ? err.message : t('common.tryAgain'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -59,7 +61,7 @@ export function CrewInviteCard({
|
|||
setDone('declined');
|
||||
onResolved?.();
|
||||
} catch {
|
||||
toast({ title: 'Could not decline', variant: 'destructive' });
|
||||
toast({ title: t('socialUi.inviteCard.couldNotDecline'), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -69,12 +71,12 @@ export function CrewInviteCard({
|
|||
{done === 'accepted' ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 text-teal-400" />
|
||||
Accepted — <span className="font-bold text-white">{invite.crewName}</span> confirms members
|
||||
{t('socialUi.inviteCard.acceptedDone')} <span className="font-bold text-white">{invite.crewName}</span> {t('socialUi.inviteCard.confirmsMembers')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<X className="h-4 w-4 text-gray-500" />
|
||||
Declined invite to {invite.crewName}
|
||||
{t('socialUi.inviteCard.declinedDone', { crew: invite.crewName })}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -98,13 +100,13 @@ export function CrewInviteCard({
|
|||
</Avatar>
|
||||
{inviterName}
|
||||
</Link>{' '}
|
||||
invited you to join{' '}
|
||||
{t('socialUi.inviteCard.invitedYou')}{' '}
|
||||
<Link to={crewHref} className="font-black text-teal-300 hover:underline">
|
||||
{invite.crewName}
|
||||
</Link>
|
||||
{invite.role === 'admin' && (
|
||||
<span className="ml-1 inline-flex items-center gap-0.5 rounded-full bg-teal-500/20 px-1.5 py-0.5 text-[9px] font-black uppercase text-teal-300">
|
||||
<ShieldCheck className="h-2.5 w-2.5" /> Admin
|
||||
<ShieldCheck className="h-2.5 w-2.5" /> {t('socialUi.inviteCard.admin')}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
|
@ -121,7 +123,7 @@ export function CrewInviteCard({
|
|||
className="min-h-11 flex-1 gap-1.5 bg-teal-500 font-bold text-black hover:bg-teal-600"
|
||||
>
|
||||
{accepting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
|
||||
Accept
|
||||
{t('socialUi.inviteCard.accept')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -131,7 +133,7 @@ export function CrewInviteCard({
|
|||
className="min-h-11 gap-1.5 border-white/15 text-gray-400 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{declining ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
|
||||
Decline
|
||||
{t('socialUi.inviteCard.decline')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { Switch } from '@/components/ui/switch';
|
|||
import { Label } from '@/components/ui/label';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useSendCrewInvite } from '@/hooks/useCrewInvites';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
|
|
@ -77,6 +78,7 @@ function InviteePreview({ pubkey }: { pubkey: string }) {
|
|||
* 2. **Share link** — copy/share a link for people not yet on Nostr.
|
||||
*/
|
||||
export function CrewInviteDialog({ crew, isFounder, triggerClassName }: CrewInviteDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [copiedLink, setCopiedLink] = useState(false);
|
||||
|
|
@ -94,8 +96,8 @@ export function CrewInviteDialog({ crew, isFounder, triggerClassName }: CrewInvi
|
|||
|
||||
const crewUrl = `${window.location.origin}/crew/${crew.pubkey}/${crew.identifier}`;
|
||||
const inviteMessage =
|
||||
`Yo — join the ${crew.name} crew on SkateSpots 🛹\n${crewUrl}\n\n` +
|
||||
`Log in with your Nostr key, then request to join from the crew page.`;
|
||||
`${t('socialUi.inviteDialog.messageIntro', { crew: crew.name })}\n${crewUrl}\n\n` +
|
||||
`${t('socialUi.inviteDialog.messageBody')}`;
|
||||
|
||||
const copy = async (text: string, which: 'link' | 'msg') => {
|
||||
try {
|
||||
|
|
@ -107,16 +109,16 @@ export function CrewInviteDialog({ crew, isFounder, triggerClassName }: CrewInvi
|
|||
setCopiedMsg(true);
|
||||
setTimeout(() => setCopiedMsg(false), 2000);
|
||||
}
|
||||
toast({ title: 'Copied!' });
|
||||
toast({ title: t('common.copied') });
|
||||
} catch {
|
||||
toast({ title: 'Copy failed', variant: 'destructive' });
|
||||
toast({ title: t('socialUi.inviteDialog.copyFailed'), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
const nativeShare = async () => {
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({ title: `Join ${crew.name}`, text: inviteMessage, url: crewUrl });
|
||||
await navigator.share({ title: t('socialUi.inviteDialog.shareTitle', { crew: crew.name }), text: inviteMessage, url: crewUrl });
|
||||
} catch {
|
||||
/* cancelled */
|
||||
}
|
||||
|
|
@ -136,8 +138,8 @@ export function CrewInviteDialog({ crew, isFounder, triggerClassName }: CrewInvi
|
|||
isFounder,
|
||||
});
|
||||
toast({
|
||||
title: 'Invite sent! 🛹',
|
||||
description: "They'll get a notification to accept or decline.",
|
||||
title: t('socialUi.inviteDialog.sentTitle'),
|
||||
description: t('socialUi.inviteDialog.sentDesc'),
|
||||
});
|
||||
setInviteeInput('');
|
||||
setNote('');
|
||||
|
|
@ -145,8 +147,8 @@ export function CrewInviteDialog({ crew, isFounder, triggerClassName }: CrewInvi
|
|||
setOpen(false);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Could not send invite',
|
||||
description: err instanceof Error ? err.message : 'Please try again.',
|
||||
title: t('socialUi.inviteDialog.couldNotSend'),
|
||||
description: err instanceof Error ? err.message : t('common.tryAgain'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -161,26 +163,26 @@ export function CrewInviteDialog({ crew, isFounder, triggerClassName }: CrewInvi
|
|||
size="sm"
|
||||
className={cn('gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10', triggerClassName)}
|
||||
>
|
||||
<UserPlus className="h-3.5 w-3.5" /> Invite
|
||||
<UserPlus className="h-3.5 w-3.5" /> {t('socialUi.inviteDialog.trigger')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<UserPlus className="h-5 w-5 text-teal-400" /> Invite to {crew.name}
|
||||
<UserPlus className="h-5 w-5 text-teal-400" /> {t('socialUi.inviteDialog.title', { crew: crew.name })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Send an invite to someone's Nostr key, or share a link.
|
||||
{t('socialUi.inviteDialog.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs defaultValue="send" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="send" className="gap-1.5">
|
||||
<Send className="h-3.5 w-3.5" /> Send invite
|
||||
<Send className="h-3.5 w-3.5" /> {t('socialUi.inviteDialog.sendTab')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="share" className="gap-1.5">
|
||||
<Share2 className="h-3.5 w-3.5" /> Share link
|
||||
<Share2 className="h-3.5 w-3.5" /> {t('socialUi.inviteDialog.shareTab')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
|
@ -188,7 +190,7 @@ export function CrewInviteDialog({ crew, isFounder, triggerClassName }: CrewInvi
|
|||
<TabsContent value="send" className="space-y-4 pt-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold uppercase tracking-wide text-gray-500">
|
||||
Skater's npub
|
||||
{t('socialUi.inviteDialog.npubLabel')}
|
||||
</label>
|
||||
<Input
|
||||
value={inviteeInput}
|
||||
|
|
@ -199,7 +201,7 @@ export function CrewInviteDialog({ crew, isFounder, triggerClassName }: CrewInvi
|
|||
spellCheck={false}
|
||||
/>
|
||||
{invalidKey && (
|
||||
<p className="text-xs text-red-400">That doesn't look like a valid npub.</p>
|
||||
<p className="text-xs text-red-400">{t('socialUi.inviteDialog.invalidNpub')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -207,12 +209,12 @@ export function CrewInviteDialog({ crew, isFounder, triggerClassName }: CrewInvi
|
|||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold uppercase tracking-wide text-gray-500">
|
||||
Note <span className="font-normal text-gray-600">(optional)</span>
|
||||
{t('socialUi.inviteDialog.noteLabel')} <span className="font-normal text-gray-600">{t('socialUi.inviteDialog.optional')}</span>
|
||||
</label>
|
||||
<Textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value.slice(0, 500))}
|
||||
placeholder="Come skate with us…"
|
||||
placeholder={t('socialUi.inviteDialog.notePlaceholder')}
|
||||
rows={2}
|
||||
className="text-sm"
|
||||
/>
|
||||
|
|
@ -224,9 +226,9 @@ export function CrewInviteDialog({ crew, isFounder, triggerClassName }: CrewInvi
|
|||
<ShieldCheck className="h-4 w-4 text-teal-400" />
|
||||
<div>
|
||||
<Label htmlFor="invite-admin" className="text-sm font-bold text-white">
|
||||
Invite as admin
|
||||
{t('socialUi.inviteDialog.inviteAsAdmin')}
|
||||
</Label>
|
||||
<p className="text-[11px] text-gray-500">Can manage the crew & invite others.</p>
|
||||
<p className="text-[11px] text-gray-500">{t('socialUi.inviteDialog.inviteAsAdminDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch id="invite-admin" checked={asAdmin} onCheckedChange={setAsAdmin} />
|
||||
|
|
@ -240,17 +242,17 @@ export function CrewInviteDialog({ crew, isFounder, triggerClassName }: CrewInvi
|
|||
className="w-full gap-1.5 bg-teal-500 font-bold text-black hover:bg-teal-600"
|
||||
>
|
||||
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||
Send invite
|
||||
{t('socialUi.inviteDialog.sendButton')}
|
||||
</Button>
|
||||
<p className="text-center text-[11px] text-gray-500">
|
||||
They'll get a notification to accept or decline. Membership only counts once they accept.
|
||||
{t('socialUi.inviteDialog.sendFooter')}
|
||||
</p>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Share a link (for people not on Nostr yet) ── */}
|
||||
<TabsContent value="share" className="space-y-4 pt-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold uppercase tracking-wide text-gray-500">Invite link</label>
|
||||
<label className="text-xs font-bold uppercase tracking-wide text-gray-500">{t('socialUi.inviteDialog.inviteLink')}</label>
|
||||
<div className="flex gap-2">
|
||||
<Input readOnly value={crewUrl} className="font-mono text-xs" onFocus={(e) => e.target.select()} />
|
||||
<Button type="button" variant="outline" size="icon" className="shrink-0" onClick={() => copy(crewUrl, 'link')}>
|
||||
|
|
@ -260,15 +262,15 @@ export function CrewInviteDialog({ crew, isFounder, triggerClassName }: CrewInvi
|
|||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold uppercase tracking-wide text-gray-500">Message</label>
|
||||
<label className="text-xs font-bold uppercase tracking-wide text-gray-500">{t('socialUi.inviteDialog.messageLabel')}</label>
|
||||
<Textarea readOnly value={inviteMessage} rows={4} className="text-sm" />
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" className="flex-1 gap-1.5" onClick={() => copy(inviteMessage, 'msg')}>
|
||||
{copiedMsg ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
|
||||
Copy message
|
||||
{t('socialUi.inviteDialog.copyMessage')}
|
||||
</Button>
|
||||
<Button type="button" className="flex-1 gap-1.5 bg-teal-500 hover:bg-teal-600 text-black" onClick={nativeShare}>
|
||||
<Share2 className="h-4 w-4" /> Share
|
||||
<Share2 className="h-4 w-4" /> {t('common.share')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -276,11 +278,11 @@ export function CrewInviteDialog({ crew, isFounder, triggerClassName }: CrewInvi
|
|||
{isFounder && (
|
||||
<div className="rounded-lg border border-teal-500/30 bg-teal-500/5 p-3">
|
||||
<p className="mb-2 text-xs text-gray-400">
|
||||
As founder you can also add a member's key directly, then they confirm.
|
||||
{t('socialUi.inviteDialog.founderHint')}
|
||||
</p>
|
||||
<Link to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}>
|
||||
<Button type="button" variant="outline" size="sm" className="w-full gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10">
|
||||
<Edit className="h-3.5 w-3.5" /> Add member in crew settings
|
||||
<Edit className="h-3.5 w-3.5" /> {t('socialUi.inviteDialog.addMemberSettings')}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Label } from '@/components/ui/label';
|
|||
import { Badge } from '@/components/ui/badge';
|
||||
import { Users, X, Link2 } from 'lucide-react';
|
||||
import { useCrews } from '@/hooks/useCrews';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { SKATE_CREW_KIND } from '@/lib/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
|
@ -20,6 +21,7 @@ interface CrewLinkFieldProps {
|
|||
* Lab profile (studio) to its crew so each surfaces the other.
|
||||
*/
|
||||
export function CrewLinkField({ value, onChange, accentText = 'text-teal-400', accentBg = 'bg-teal-500' }: CrewLinkFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: allCrews } = useCrews(200);
|
||||
|
||||
|
|
@ -35,18 +37,17 @@ export function CrewLinkField({ value, onChange, accentText = 'text-teal-400', a
|
|||
<div className="space-y-3 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
|
||||
<div>
|
||||
<Label className={cn('flex items-center gap-2', accentText)}>
|
||||
<Link2 className="h-4 w-4" /> Linked Crew
|
||||
<Link2 className="h-4 w-4" /> {t('socialUi.linkField.title')}
|
||||
</Label>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Run a crew too? Link it so people can jump between your studio and your crew. Optional.
|
||||
Link this crew back to the profile from the crew editor and it'll show a green{' '}
|
||||
<span className="font-bold text-green-400">Verified</span> badge on both.
|
||||
{t('socialUi.linkField.description')}{' '}
|
||||
<span className="font-bold text-green-400">{t('common.verified')}</span> {t('socialUi.linkField.descriptionRest')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{myCrews.length === 0 ? (
|
||||
<p className="text-xs text-gray-500 italic">
|
||||
You don't have any crews yet. Create one on the Crews page to link it here.
|
||||
{t('socialUi.linkField.none')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
|
@ -76,7 +77,7 @@ export function CrewLinkField({ value, onChange, accentText = 'text-teal-400', a
|
|||
|
||||
{value && !myCrews.some((c) => coordFor(c.pubkey, c.identifier) === value) && (
|
||||
<Badge variant="outline" className="gap-1 text-[10px] text-gray-400">
|
||||
<Users className="h-3 w-3" /> Linked crew ({value.split(':')[2]})
|
||||
<Users className="h-3 w-3" /> {t('socialUi.linkField.linkedCrew', { id: value.split(':')[2] })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { CREW_LOOKING_FOR_LABELS } from '@/lib/types';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { Megaphone } from 'lucide-react';
|
||||
|
||||
/**
|
||||
|
|
@ -8,15 +9,16 @@ import { Megaphone } from 'lucide-react';
|
|||
* matching Lab providers can reach out. Slugs map to Lab scenes.
|
||||
*/
|
||||
export function CrewLookingForCard({ lookingFor }: { lookingFor: string[] }) {
|
||||
const { t } = useTranslation();
|
||||
if (!lookingFor || lookingFor.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card className="border-2 border-fuchsia-500/30 bg-black/90">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
|
||||
<Megaphone className="h-5 w-5 text-fuchsia-400" /> Open Call
|
||||
<Megaphone className="h-5 w-5 text-fuchsia-400" /> {t('socialUi.lookingFor.title')}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-gray-400">This crew is looking for:</p>
|
||||
<p className="text-xs text-gray-400">{t('socialUi.lookingFor.subtitle')}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useCrewNip05 } from '@/hooks/useCrewNip05';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CrewNip05BadgeProps {
|
||||
|
|
@ -23,6 +24,7 @@ interface CrewNip05BadgeProps {
|
|||
* - `none`: renders nothing.
|
||||
*/
|
||||
export function CrewNip05Badge({ nip05, founderPubkey, className }: CrewNip05BadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useCrewNip05(nip05, founderPubkey);
|
||||
|
||||
if (!nip05) return null;
|
||||
|
|
@ -45,7 +47,7 @@ export function CrewNip05Badge({ nip05, founderPubkey, className }: CrewNip05Bad
|
|||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
This domain points the handle at a different Nostr key than the crew founder. Not verified.
|
||||
{t('socialUi.nip05.mismatch')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
|
@ -67,7 +69,7 @@ export function CrewNip05Badge({ nip05, founderPubkey, className }: CrewNip05Bad
|
|||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Verified — the domain confirms this handle belongs to the crew's founder key.
|
||||
{t('socialUi.nip05.verified')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { Textarea } from '@/components/ui/textarea';
|
|||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useSendJoinRequest } from '@/hooks/useCrewInvites';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { UserPlus, Loader2, Bell } from 'lucide-react';
|
||||
import type { SkateCrew } from '@/lib/types';
|
||||
|
||||
|
|
@ -28,6 +29,7 @@ interface CrewRequestToJoinProps {
|
|||
* an optional note.
|
||||
*/
|
||||
export function CrewRequestToJoin({ crew }: CrewRequestToJoinProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: sendJoinRequest, isPending } = useSendJoinRequest();
|
||||
const { toast } = useToast();
|
||||
|
|
@ -47,15 +49,15 @@ export function CrewRequestToJoin({ crew }: CrewRequestToJoinProps) {
|
|||
try {
|
||||
await sendJoinRequest({ crew, note });
|
||||
toast({
|
||||
title: 'Request sent! 🛹',
|
||||
description: `The ${crew.name} leadership will see it in their notifications.`,
|
||||
title: t('socialUi.requestToJoin.sentTitle'),
|
||||
description: t('socialUi.requestToJoin.sentDesc', { crew: crew.name }),
|
||||
});
|
||||
setOpen(false);
|
||||
setNote('');
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Could not send request',
|
||||
description: err instanceof Error ? err.message : 'Please try again.',
|
||||
title: t('socialUi.requestToJoin.couldNotSend'),
|
||||
description: err instanceof Error ? err.message : t('common.tryAgain'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -69,23 +71,23 @@ export function CrewRequestToJoin({ crew }: CrewRequestToJoinProps) {
|
|||
size="sm"
|
||||
className="gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10"
|
||||
>
|
||||
<UserPlus className="h-3.5 w-3.5" /> Request to join
|
||||
<UserPlus className="h-3.5 w-3.5" /> {t('socialUi.requestToJoin.trigger')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<UserPlus className="h-5 w-5 text-teal-400" /> Join {crew.name}
|
||||
<UserPlus className="h-5 w-5 text-teal-400" /> {t('socialUi.requestToJoin.title', { crew: crew.name })}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="flex items-center gap-1.5">
|
||||
<Bell className="h-3 w-3 text-teal-400" />
|
||||
Notifies the crew's founder & admins so they can add you.
|
||||
{t('socialUi.requestToJoin.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="Say hi — where you skate, who you know, why you want in…"
|
||||
placeholder={t('socialUi.requestToJoin.placeholder')}
|
||||
rows={4}
|
||||
maxLength={1000}
|
||||
className="resize-none"
|
||||
|
|
@ -98,11 +100,11 @@ export function CrewRequestToJoin({ crew }: CrewRequestToJoinProps) {
|
|||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Sending…
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> {t('socialUi.requestToJoin.sending')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="mr-2 h-4 w-4" /> Send join request
|
||||
<UserPlus className="mr-2 h-4 w-4" /> {t('socialUi.requestToJoin.sendButton')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ZapButton } from '@/components/ZapButton';
|
||||
import { useCrewGoal } from '@/hooks/useCrewGoal';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Target, Zap, Users, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
|
|
@ -12,6 +13,7 @@ import { Target, Zap, Users, CheckCircle2 } from 'lucide-react';
|
|||
* zap receipts, so anyone can audit it.
|
||||
*/
|
||||
export function CrewTreasuryCard({ goalId }: { goalId?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: goal, isLoading } = useCrewGoal(goalId);
|
||||
|
||||
if (!goalId) return null;
|
||||
|
|
@ -48,7 +50,7 @@ export function CrewTreasuryCard({ goalId }: { goalId?: string }) {
|
|||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
|
||||
<Target className={cn('h-5 w-5', reached ? 'text-green-400' : 'text-orange-400')} />
|
||||
Crew Treasury
|
||||
{t('socialUi.treasury.title')}
|
||||
</CardTitle>
|
||||
{goal.summary && <p className="text-xs text-gray-400">{goal.summary}</p>}
|
||||
</CardHeader>
|
||||
|
|
@ -61,10 +63,10 @@ export function CrewTreasuryCard({ goalId }: { goalId?: string }) {
|
|||
<div className="space-y-1.5">
|
||||
<div className="flex items-end justify-between text-sm">
|
||||
<span className={cn('font-black', reached ? 'text-green-400' : 'text-orange-400')}>
|
||||
{goal.raisedSats.toLocaleString()} sats
|
||||
{goal.raisedSats.toLocaleString()} {t('common.sats')}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
of {goal.targetSats.toLocaleString()} goal
|
||||
{t('socialUi.treasury.ofGoal', { target: goal.targetSats.toLocaleString() })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 w-full overflow-hidden rounded-full bg-white/5">
|
||||
|
|
@ -82,17 +84,19 @@ export function CrewTreasuryCard({ goalId }: { goalId?: string }) {
|
|||
<span className="flex items-center gap-1">
|
||||
{reached ? (
|
||||
<span className="flex items-center gap-1 font-bold text-green-400">
|
||||
<CheckCircle2 className="h-3 w-3" /> Goal reached!
|
||||
<CheckCircle2 className="h-3 w-3" /> {t('socialUi.treasury.goalReached')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-bold text-orange-300">{goal.percent}% funded</span>
|
||||
<span className="font-bold text-orange-300">{t('socialUi.treasury.funded', { percent: goal.percent })}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{goal.contributions} contribution{goal.contributions === 1 ? '' : 's'}
|
||||
{daysLeft !== null && daysLeft > 0 && ` · ${daysLeft}d left`}
|
||||
{closed && ' · closed'}
|
||||
{goal.contributions === 1
|
||||
? t('socialUi.treasury.contributionOne', { count: goal.contributions })
|
||||
: t('socialUi.treasury.contributionMany', { count: goal.contributions })}
|
||||
{daysLeft !== null && daysLeft > 0 && ` · ${t('socialUi.treasury.daysLeft', { days: daysLeft })}`}
|
||||
{closed && ` · ${t('socialUi.treasury.closed')}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -106,7 +110,7 @@ export function CrewTreasuryCard({ goalId }: { goalId?: string }) {
|
|||
/>
|
||||
<p className="flex items-center justify-center gap-1 text-center text-[10px] text-gray-600">
|
||||
<Zap className="h-2.5 w-2.5" />
|
||||
Non-custodial · zaps go straight to the crew's Lightning wallet
|
||||
{t('socialUi.treasury.nonCustodial')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useShops } from '@/hooks/useShops';
|
|||
import { useBtcMapPlaces } from '@/hooks/useBtcMapPlaces';
|
||||
import { SPOT_TYPE_LABELS } from '@/lib/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
export interface DestinationPick {
|
||||
lat: number;
|
||||
|
|
@ -26,9 +27,9 @@ interface Candidate {
|
|||
}
|
||||
|
||||
const KIND_META: Record<Kind, { label: string; icon: typeof MapPin; className: string }> = {
|
||||
spot: { label: 'Spot', icon: MapPin, className: 'text-[#ff6600]' },
|
||||
shop: { label: 'Shop', icon: Store, className: 'text-emerald-400' },
|
||||
merchant: { label: 'Bitcoin', icon: Bitcoin, className: 'text-[#f7931a]' },
|
||||
spot: { label: 'placesUi.destination.spot', icon: MapPin, className: 'text-[#ff6600]' },
|
||||
shop: { label: 'placesUi.destination.shop', icon: Store, className: 'text-emerald-400' },
|
||||
merchant: { label: 'placesUi.destination.bitcoin', icon: Bitcoin, className: 'text-[#f7931a]' },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -48,6 +49,7 @@ export function DestinationSearch({
|
|||
onSelect: (pick: DestinationPick) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState('');
|
||||
const { data: spots } = useSkateSpots(2000);
|
||||
const { data: shops } = useShops(500);
|
||||
|
|
@ -120,14 +122,14 @@ export function DestinationSearch({
|
|||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search spots, shops, Bitcoin merchants…"
|
||||
placeholder={t('placesUi.destination.placeholder')}
|
||||
className="h-10 border-white/15 bg-white/5 pl-9 text-sm text-white placeholder:text-gray-600"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close search"
|
||||
aria-label={t('placesUi.destination.closeSearch')}
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
|
|
@ -137,12 +139,11 @@ export function DestinationSearch({
|
|||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
{query.trim().length < 2 ? (
|
||||
<p className="p-4 text-center text-xs text-gray-500">
|
||||
Type at least 2 characters. Searches skate spots, skate shops, and Bitcoin
|
||||
merchants{near ? ' near here' : ''}.
|
||||
{near ? t('placesUi.destination.typeCharsNear') : t('placesUi.destination.typeChars')}
|
||||
</p>
|
||||
) : results.length === 0 ? (
|
||||
<p className="p-4 text-center text-xs text-gray-500">
|
||||
Nothing matches “{query.trim()}”.
|
||||
{t('placesUi.destination.nothingMatches', { query: query.trim() })}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
|
|
@ -162,7 +163,7 @@ export function DestinationSearch({
|
|||
{c.sub && <span className="block truncate text-[11px] text-gray-500">{c.sub}</span>}
|
||||
</span>
|
||||
<span className={cn('shrink-0 text-[10px] font-black uppercase tracking-widest', meta.className)}>
|
||||
{meta.label}
|
||||
{t(meta.label)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { useCrewNip05 } from '@/hooks/useCrewNip05';
|
|||
import { SkateNip05Input } from '@/components/SkateNip05Input';
|
||||
import { SKATE_NIP05_DOMAIN, parseSkateNip05 } from '@/lib/skateNip05';
|
||||
import { isValidNoffer, isValidSilentPayment, isValidLightningAddress } from '@/lib/types';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
export const EditProfileForm: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
|
@ -36,6 +37,7 @@ export const EditProfileForm: React.FC = () => {
|
|||
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
|
||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// CLINK Offer (noffer1...) — a self-custodial Bitcoin tip code. Not part of
|
||||
// the standard NostrMetadata schema, so we keep it in its own state and merge
|
||||
|
|
@ -130,14 +132,14 @@ export const EditProfileForm: React.FC = () => {
|
|||
const [[_, url]] = await uploadFile(file);
|
||||
form.setValue(field, url);
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: `${field === 'picture' ? 'Profile picture' : 'Banner'} uploaded successfully`,
|
||||
title: t('settingsUi.editProfile.uploadSuccessTitle'),
|
||||
description: t('settingsUi.editProfile.uploadSuccessDesc', { field: field === 'picture' ? t('settingsUi.editProfile.profilePicture') : t('settingsUi.editProfile.bannerImage') }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to upload ${field}:`, error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: `Failed to upload ${field === 'picture' ? 'profile picture' : 'banner'}. Please try again.`,
|
||||
title: t('common.error'),
|
||||
description: t('settingsUi.editProfile.uploadErrorDesc', { field: field === 'picture' ? t('settingsUi.editProfile.profilePicture') : t('settingsUi.editProfile.bannerImage') }),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -146,8 +148,8 @@ export const EditProfileForm: React.FC = () => {
|
|||
const onSubmit = async (values: NostrMetadata) => {
|
||||
if (!user) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'You must be logged in to update your profile',
|
||||
title: t('common.error'),
|
||||
description: t('settingsUi.editProfile.mustBeLoggedIn'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -155,8 +157,8 @@ export const EditProfileForm: React.FC = () => {
|
|||
|
||||
if (nofferInvalid) {
|
||||
toast({
|
||||
title: 'Invalid CLINK code',
|
||||
description: 'That doesn\'t look like a valid noffer1… code. Clear it or paste a valid one.',
|
||||
title: t('settingsUi.editProfile.invalidClinkTitle'),
|
||||
description: t('settingsUi.editProfile.invalidClinkDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -164,8 +166,8 @@ export const EditProfileForm: React.FC = () => {
|
|||
|
||||
if (lud16Invalid) {
|
||||
toast({
|
||||
title: 'Invalid Lightning address',
|
||||
description: 'Use an email-like address such as you@walletofsatoshi.com.',
|
||||
title: t('settingsUi.editProfile.invalidLightningTitle'),
|
||||
description: t('settingsUi.editProfile.invalidLightningDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -173,8 +175,8 @@ export const EditProfileForm: React.FC = () => {
|
|||
|
||||
if (spInvalid) {
|
||||
toast({
|
||||
title: 'Invalid Silent Payment address',
|
||||
description: 'A Silent Payment address starts with "sp1" and is quite long. Clear it or paste a valid one.',
|
||||
title: t('settingsUi.editProfile.invalidSpTitle'),
|
||||
description: t('settingsUi.editProfile.invalidSpDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -182,8 +184,8 @@ export const EditProfileForm: React.FC = () => {
|
|||
|
||||
if (cordnInvalid) {
|
||||
toast({
|
||||
title: 'Invalid Cordn link',
|
||||
description: 'Paste a full https://… Cordn contact link.',
|
||||
title: t('settingsUi.editProfile.invalidCordnTitle'),
|
||||
description: t('settingsUi.editProfile.invalidCordnDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -278,14 +280,14 @@ export const EditProfileForm: React.FC = () => {
|
|||
queryClient.invalidateQueries({ queryKey: ['author', user.pubkey] });
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Your profile has been updated',
|
||||
title: t('settingsUi.editProfile.updateSuccessTitle'),
|
||||
description: t('settingsUi.editProfile.updateSuccessDesc'),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update profile:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to publish profile. Check your relay connections in Settings > Relays',
|
||||
title: t('common.error'),
|
||||
description: t('settingsUi.editProfile.updateErrorDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -300,8 +302,8 @@ export const EditProfileForm: React.FC = () => {
|
|||
{/* ── Section: Identity ── */}
|
||||
<FormSection
|
||||
icon={<UserRound className="h-4 w-4" />}
|
||||
title="Identity"
|
||||
description="How you show up across SkateSpots."
|
||||
title={t('settingsUi.editProfile.identityTitle')}
|
||||
description={t('settingsUi.editProfile.identityDesc')}
|
||||
accent="from-[#ff0040] to-[#ff6600]"
|
||||
>
|
||||
<FormField
|
||||
|
|
@ -309,9 +311,9 @@ export const EditProfileForm: React.FC = () => {
|
|||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Display name</FormLabel>
|
||||
<FormLabel>{t('settingsUi.editProfile.displayName')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Your name" {...field} />
|
||||
<Input placeholder={t('settingsUi.editProfile.displayNamePlaceholder')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
|
@ -323,9 +325,9 @@ export const EditProfileForm: React.FC = () => {
|
|||
name="about"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bio</FormLabel>
|
||||
<FormLabel>{t('settingsUi.editProfile.bio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="A few words about you and your skating…" className="resize-none min-h-[88px]" {...field} />
|
||||
<Textarea placeholder={t('settingsUi.editProfile.bioPlaceholder')} className="resize-none min-h-[88px]" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
|
@ -339,9 +341,9 @@ export const EditProfileForm: React.FC = () => {
|
|||
render={({ field }) => (
|
||||
<ImageUploadField
|
||||
field={field}
|
||||
label="Profile picture"
|
||||
label={t('settingsUi.editProfile.profilePicture')}
|
||||
placeholder="https://…/avatar.jpg"
|
||||
description="Upload or paste a square image."
|
||||
description={t('settingsUi.editProfile.pictureDesc')}
|
||||
previewType="square"
|
||||
onUpload={(file) => uploadPicture(file, 'picture')}
|
||||
/>
|
||||
|
|
@ -353,9 +355,9 @@ export const EditProfileForm: React.FC = () => {
|
|||
render={({ field }) => (
|
||||
<ImageUploadField
|
||||
field={field}
|
||||
label="Banner image"
|
||||
label={t('settingsUi.editProfile.bannerImage')}
|
||||
placeholder="https://…/banner.jpg"
|
||||
description="A wide image for the top of your profile."
|
||||
description={t('settingsUi.editProfile.bannerDesc')}
|
||||
previewType="wide"
|
||||
onUpload={(file) => uploadPicture(file, 'banner')}
|
||||
/>
|
||||
|
|
@ -368,7 +370,7 @@ export const EditProfileForm: React.FC = () => {
|
|||
name="website"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Website</FormLabel>
|
||||
<FormLabel>{t('settingsUi.editProfile.website')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://yourwebsite.com" {...field} />
|
||||
</FormControl>
|
||||
|
|
@ -389,8 +391,8 @@ export const EditProfileForm: React.FC = () => {
|
|||
{/* ── Section: Bitcoin & Payments ── */}
|
||||
<FormSection
|
||||
icon={<Bitcoin className="h-4 w-4" />}
|
||||
title="Bitcoin & Payments"
|
||||
description="Let people zap, tip and pay you directly — no middleman."
|
||||
title={t('settingsUi.editProfile.bitcoinTitle')}
|
||||
description={t('settingsUi.editProfile.bitcoinDesc')}
|
||||
accent="from-amber-500 to-orange-500"
|
||||
>
|
||||
{/* Lightning address */}
|
||||
|
|
@ -398,16 +400,16 @@ export const EditProfileForm: React.FC = () => {
|
|||
id="profile-lud16"
|
||||
icon={<Zap className="h-4 w-4" />}
|
||||
iconClass="text-yellow-500 bg-yellow-500/10"
|
||||
label="Lightning address"
|
||||
label={t('settingsUi.editProfile.lightningAddress')}
|
||||
value={lud16}
|
||||
onChange={setLud16}
|
||||
placeholder="you@yourdomain.com"
|
||||
status={
|
||||
lud16Invalid
|
||||
? { kind: 'error', text: 'Use an email-like address, e.g. you@yourdomain.com.' }
|
||||
? { kind: 'error', text: t('settingsUi.editProfile.lud16Invalid') }
|
||||
: lud16Trimmed
|
||||
? { kind: 'ok', text: 'Powers the “Zap” button on your profile.' }
|
||||
: { kind: 'hint', text: 'Instant Bitcoin tips. Powers the “Zap” button on your profile.' }
|
||||
? { kind: 'ok', text: t('settingsUi.editProfile.lud16Ok') }
|
||||
: { kind: 'hint', text: t('settingsUi.editProfile.lud16Hint') }
|
||||
}
|
||||
/>
|
||||
|
||||
|
|
@ -416,16 +418,16 @@ export const EditProfileForm: React.FC = () => {
|
|||
id="profile-sp"
|
||||
icon={<Bitcoin className="h-4 w-4" />}
|
||||
iconClass="text-orange-500 bg-orange-500/10"
|
||||
label="Silent Payment address"
|
||||
label={t('settingsUi.editProfile.silentPaymentAddress')}
|
||||
value={silentPayment}
|
||||
onChange={setSilentPayment}
|
||||
placeholder="sp1q…"
|
||||
status={
|
||||
spInvalid
|
||||
? { kind: 'error', text: 'Should start with “sp1” and be quite long.' }
|
||||
? { kind: 'error', text: t('settingsUi.editProfile.spInvalid') }
|
||||
: spValid
|
||||
? { kind: 'ok', text: 'Valid — a reusable, private on-chain address.' }
|
||||
: { kind: 'hint', text: 'Reusable static on-chain address (BIP-352). Share once; every payment is unlinkable.' }
|
||||
? { kind: 'ok', text: t('settingsUi.editProfile.spOk') }
|
||||
: { kind: 'hint', text: t('settingsUi.editProfile.spHint') }
|
||||
}
|
||||
/>
|
||||
|
||||
|
|
@ -434,16 +436,16 @@ export const EditProfileForm: React.FC = () => {
|
|||
id="profile-noffer"
|
||||
icon={<Zap className="h-4 w-4" />}
|
||||
iconClass="text-indigo-500 bg-indigo-500/10"
|
||||
label="CLINK tip code"
|
||||
label={t('settingsUi.editProfile.clinkTipCode')}
|
||||
value={noffer}
|
||||
onChange={setNoffer}
|
||||
placeholder="noffer1…"
|
||||
status={
|
||||
nofferInvalid
|
||||
? { kind: 'error', text: 'That doesn’t look like a valid noffer1… code.' }
|
||||
? { kind: 'error', text: t('settingsUi.editProfile.nofferInvalid') }
|
||||
: nofferValid
|
||||
? { kind: 'ok', text: 'Valid — a “Tip in Bitcoin” button will show on your profile.' }
|
||||
: { kind: 'hint', text: 'Self-custodial CLINK code so anyone can tip you over Nostr — no middleman.' }
|
||||
? { kind: 'ok', text: t('settingsUi.editProfile.nofferOk') }
|
||||
: { kind: 'hint', text: t('settingsUi.editProfile.nofferHint') }
|
||||
}
|
||||
/>
|
||||
</FormSection>
|
||||
|
|
@ -451,16 +453,16 @@ export const EditProfileForm: React.FC = () => {
|
|||
{/* ── Section: Skate profile ── */}
|
||||
<FormSection
|
||||
icon={<Award className="h-4 w-4" />}
|
||||
title="Skate Profile"
|
||||
description="Reppin’ your sponsors and how the crew reaches you."
|
||||
title={t('settingsUi.editProfile.skateProfileTitle')}
|
||||
description={t('settingsUi.editProfile.skateProfileDesc')}
|
||||
accent="from-pink-500 to-fuchsia-500"
|
||||
>
|
||||
{/* Badges — sponsor/backer chips, each with an optional link */}
|
||||
<div className="space-y-2.5">
|
||||
<Label className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Award className="h-4 w-4 text-pink-500" />
|
||||
Badges
|
||||
<span className="text-xs font-normal text-muted-foreground">(label + optional link)</span>
|
||||
{t('settingsUi.editProfile.badges')}
|
||||
<span className="text-xs font-normal text-muted-foreground">{t('settingsUi.editProfile.badgesHint')}</span>
|
||||
</Label>
|
||||
<div className="space-y-2">
|
||||
{badgeList.map((row, i) => (
|
||||
|
|
@ -468,7 +470,7 @@ export const EditProfileForm: React.FC = () => {
|
|||
<span className="text-pink-500/60 text-sm font-black w-4 text-center flex-shrink-0">•</span>
|
||||
<Input
|
||||
value={row.label}
|
||||
placeholder={i === 0 ? 'e.g. Independent Trucks' : 'Badge label…'}
|
||||
placeholder={i === 0 ? t('settingsUi.editProfile.badgeExample') : t('settingsUi.editProfile.badgeLabelPlaceholder')}
|
||||
onChange={(e) => {
|
||||
const next = [...badgeList];
|
||||
next[i] = { ...next[i], label: e.target.value };
|
||||
|
|
@ -478,7 +480,7 @@ export const EditProfileForm: React.FC = () => {
|
|||
/>
|
||||
<Input
|
||||
value={row.url}
|
||||
placeholder="https://… (optional)"
|
||||
placeholder={t('settingsUi.editProfile.badgeUrlPlaceholder')}
|
||||
onChange={(e) => {
|
||||
const next = [...badgeList];
|
||||
next[i] = { ...next[i], url: e.target.value };
|
||||
|
|
@ -496,7 +498,7 @@ export const EditProfileForm: React.FC = () => {
|
|||
const next = badgeList.filter((_, idx) => idx !== i);
|
||||
setBadgeList(next.length ? next : [{ label: '', url: '' }]);
|
||||
}}
|
||||
aria-label="Remove badge"
|
||||
aria-label={t('settingsUi.editProfile.removeBadge')}
|
||||
disabled={badgeList.length === 1 && !row.label && !row.url}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
|
|
@ -512,12 +514,11 @@ export const EditProfileForm: React.FC = () => {
|
|||
onClick={() => setBadgeList([...badgeList, { label: '', url: '' }])}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add badge
|
||||
{t('settingsUi.editProfile.addBadge')}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sponsors, shops or people who back your skating — and anything else you want to show off. Each
|
||||
becomes a clickable chip on your profile. Your{' '}
|
||||
<span className="text-[#ccff00]">@skate-spots.org</span> member badge is added automatically.
|
||||
{t('settingsUi.editProfile.badgesDescBefore')}{' '}
|
||||
<span className="text-[#ccff00]">@skate-spots.org</span> {t('settingsUi.editProfile.badgesDescAfter')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -526,16 +527,16 @@ export const EditProfileForm: React.FC = () => {
|
|||
id="profile-cordn"
|
||||
icon={<MessageSquareLock className="h-4 w-4" />}
|
||||
iconClass="text-teal-500 bg-teal-500/10"
|
||||
label="Coordinator"
|
||||
label={t('settingsUi.editProfile.coordinator')}
|
||||
value={cordn}
|
||||
onChange={setCordn}
|
||||
placeholder="nprofile1… · cordn.net/chat/coordinators?c=… · https://…"
|
||||
status={
|
||||
cordnInvalid
|
||||
? { kind: 'error', text: "That doesn't look like a coordinator. Paste an nprofile, a cordn.net link, or an https:// URL." }
|
||||
? { kind: 'error', text: t('settingsUi.editProfile.cordnInvalid') }
|
||||
: cordnTrimmed
|
||||
? { kind: 'ok', text: 'Shown on your profile — others can add it in one tap.' }
|
||||
: { kind: 'hint', text: 'The Cordn coordinator others use to message you. Private, encrypted, yours.' }
|
||||
? { kind: 'ok', text: t('settingsUi.editProfile.cordnOk') }
|
||||
: { kind: 'hint', text: t('settingsUi.editProfile.cordnHint') }
|
||||
}
|
||||
/>
|
||||
{myCoordinator && (
|
||||
|
|
@ -547,7 +548,7 @@ export const EditProfileForm: React.FC = () => {
|
|||
onClick={() => setCordn(coordinatorAddUrl(myCoordinator))}
|
||||
>
|
||||
<Server className="mr-1.5 h-3.5 w-3.5" />
|
||||
Use my coordinator
|
||||
{t('settingsUi.editProfile.useMyCoordinator')}
|
||||
</Button>
|
||||
)}
|
||||
</FormSection>
|
||||
|
|
@ -555,8 +556,8 @@ export const EditProfileForm: React.FC = () => {
|
|||
{/* ── Section: Account ── */}
|
||||
<FormSection
|
||||
icon={<Bot className="h-4 w-4" />}
|
||||
title="Account"
|
||||
description="Extra account settings."
|
||||
title={t('settingsUi.editProfile.accountTitle')}
|
||||
description={t('settingsUi.editProfile.accountDesc')}
|
||||
accent="from-slate-500 to-gray-500"
|
||||
>
|
||||
<FormField
|
||||
|
|
@ -565,8 +566,8 @@ export const EditProfileForm: React.FC = () => {
|
|||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-xl border p-4 bg-muted/20">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Bot account</FormLabel>
|
||||
<FormDescription>Mark this account as automated or a bot.</FormDescription>
|
||||
<FormLabel className="text-base">{t('settingsUi.editProfile.botAccount')}</FormLabel>
|
||||
<FormDescription>{t('settingsUi.editProfile.botAccountDesc')}</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
|
|
@ -580,9 +581,9 @@ export const EditProfileForm: React.FC = () => {
|
|||
<div className="sticky bottom-0 -mx-1 px-1 py-3 bg-gradient-to-t from-background via-background to-transparent">
|
||||
<Button type="submit" className="w-full h-11 font-bold" disabled={saving}>
|
||||
{saving ? (
|
||||
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Saving…</>
|
||||
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />{t('settingsUi.editProfile.saving')}</>
|
||||
) : (
|
||||
'Save Profile'
|
||||
t('settingsUi.editProfile.saveProfile')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -666,6 +667,7 @@ const Nip05Field: React.FC<{
|
|||
onChange: (v: string) => void;
|
||||
pubkey: string | undefined;
|
||||
}> = ({ value, onChange, pubkey }) => {
|
||||
const { t } = useTranslation();
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
const looksValid = /^[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,}$/.test(trimmed);
|
||||
|
||||
|
|
@ -682,19 +684,19 @@ const Nip05Field: React.FC<{
|
|||
|
||||
let status: { kind: 'hint' | 'ok' | 'error' | 'warn' | 'checking'; text: string };
|
||||
if (!trimmed) {
|
||||
status = { kind: 'hint', text: 'A verified handle like you@yourdomain.com — Nostr’s version of a blue check. Shows a ✓ badge on your profile and unlocks the “Verified” filter in the Skaters directory.' };
|
||||
status = { kind: 'hint', text: t('settingsUi.editProfile.nip05HintEmpty') };
|
||||
} else if (!looksValid) {
|
||||
status = { kind: 'error', text: 'Use an email-like handle, e.g. you@yourdomain.com — no “https://” or spaces.' };
|
||||
status = { kind: 'error', text: t('settingsUi.editProfile.nip05Invalid') };
|
||||
} else if (isLoading) {
|
||||
status = { kind: 'checking', text: 'Checking your domain…' };
|
||||
status = { kind: 'checking', text: t('settingsUi.editProfile.nip05Checking') };
|
||||
} else if (result?.status === 'verified') {
|
||||
status = { kind: 'ok', text: 'Verified — your domain confirms this handle belongs to you. ✓' };
|
||||
status = { kind: 'ok', text: t('settingsUi.editProfile.nip05Verified') };
|
||||
} else if (result?.status === 'mismatch') {
|
||||
status = { kind: 'error', text: 'Your domain maps this handle to a DIFFERENT key. Fix your .well-known/nostr.json so it points to this account.' };
|
||||
status = { kind: 'error', text: t('settingsUi.editProfile.nip05Mismatch') };
|
||||
} else if (result?.status === 'unreachable') {
|
||||
status = { kind: 'warn', text: 'Couldn’t reach your domain’s /.well-known/nostr.json yet. Saved handles still work once your domain is set up correctly.' };
|
||||
status = { kind: 'warn', text: t('settingsUi.editProfile.nip05Unreachable') };
|
||||
} else {
|
||||
status = { kind: 'hint', text: 'Enter your NIP-05 handle.' };
|
||||
status = { kind: 'hint', text: t('settingsUi.editProfile.nip05Enter') };
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -703,7 +705,7 @@ const Nip05Field: React.FC<{
|
|||
<span className="h-6 w-6 rounded-lg flex items-center justify-center text-[#00a3ff] bg-[#00a3ff]/10">
|
||||
<BadgeCheck className="h-4 w-4" />
|
||||
</span>
|
||||
NIP-05 verified handle
|
||||
{t('settingsUi.editProfile.nip05VerifiedHandle')}
|
||||
<a
|
||||
href="https://nostr.how/en/guides/get-verified"
|
||||
target="_blank"
|
||||
|
|
@ -711,7 +713,7 @@ const Nip05Field: React.FC<{
|
|||
className="ml-auto inline-flex items-center gap-1 text-xs font-normal text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<HelpCircle className="h-3.5 w-3.5" />
|
||||
How?
|
||||
{t('settingsUi.editProfile.nip05How')}
|
||||
</a>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
|
|
@ -763,8 +765,8 @@ const Nip05Field: React.FC<{
|
|||
{/* Free SkateSpots handles — claim one here and it fills the field above. */}
|
||||
<div className="space-y-2 rounded-lg border border-dashed border-muted-foreground/25 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No domain of your own? Claim a free{' '}
|
||||
<span className="font-semibold text-foreground">@{SKATE_NIP05_DOMAIN}</span> handle from SkateSpots.
|
||||
{t('settingsUi.editProfile.freeHandleBefore')}{' '}
|
||||
<span className="font-semibold text-foreground">@{SKATE_NIP05_DOMAIN}</span> {t('settingsUi.editProfile.freeHandleAfter')}
|
||||
</p>
|
||||
<SkateNip05Input
|
||||
id="skate-nip05-claim"
|
||||
|
|
@ -805,6 +807,7 @@ const ImageUploadField: React.FC<ImageUploadFieldProps> = ({
|
|||
onUpload,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
|
|
@ -817,11 +820,11 @@ const ImageUploadField: React.FC<ImageUploadFieldProps> = ({
|
|||
className={`group relative flex-shrink-0 overflow-hidden border-2 border-dashed border-muted-foreground/25 hover:border-primary/50 transition-colors bg-muted/30 flex items-center justify-center ${
|
||||
previewType === 'square' ? 'h-16 w-16 rounded-full' : 'h-16 w-28 rounded-lg'
|
||||
}`}
|
||||
aria-label={`Upload ${label}`}
|
||||
aria-label={t('settingsUi.editProfile.uploadLabel', { label })}
|
||||
>
|
||||
{field.value ? (
|
||||
<>
|
||||
<img src={field.value} alt={`${label} preview`} className="h-full w-full object-cover" />
|
||||
<img src={field.value} alt={t('settingsUi.editProfile.previewLabel', { label })} className="h-full w-full object-cover" />
|
||||
<span className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<Upload className="h-4 w-4 text-white" />
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { AlertTriangle, RefreshCw } from 'lucide-react';
|
|||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
/**
|
||||
* Shared failure state for data-driven pages.
|
||||
|
|
@ -11,8 +12,8 @@ import { cn } from '@/lib/utils';
|
|||
* unactionable. This keeps the failure honest and gives a retry path.
|
||||
*/
|
||||
export function ErrorState({
|
||||
title = 'Couldn’t load this',
|
||||
description = 'The network request failed. Check your connection and try again.',
|
||||
title,
|
||||
description,
|
||||
onRetry,
|
||||
className,
|
||||
}: {
|
||||
|
|
@ -21,16 +22,20 @@ export function ErrorState({
|
|||
onRetry?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const heading = title ?? t('chromeUi.errorTitle');
|
||||
const body = description ?? t('chromeUi.errorDescription');
|
||||
|
||||
return (
|
||||
<Card className={cn('border-2 border-dashed border-red-500/30 bg-black/50', className)}>
|
||||
<CardContent className="py-12 text-center">
|
||||
<AlertTriangle className="mx-auto mb-4 h-12 w-12 text-red-500/60" />
|
||||
<p className="mb-1 font-bold uppercase tracking-wide text-white">{title}</p>
|
||||
<p className="mx-auto mb-5 max-w-sm text-sm text-gray-400">{description}</p>
|
||||
<p className="mb-1 font-bold uppercase tracking-wide text-white">{heading}</p>
|
||||
<p className="mx-auto mb-5 max-w-sm text-sm text-gray-400">{body}</p>
|
||||
{onRetry && (
|
||||
<Button onClick={onRetry} className="gap-2 bg-white/10 font-bold uppercase hover:bg-white/20">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Try again
|
||||
{t('chromeUi.tryAgain')}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -22,9 +22,25 @@ import { TrustBadge } from '@/components/TrustBadge';
|
|||
import { OfficialBadge } from '@/components/OfficialBadge';
|
||||
import { ProBadge } from '@/components/ProBadge';
|
||||
import { ReportDialog } from '@/components/ReportDialog';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import type { SkateEvent } from '@/lib/types';
|
||||
import { EVENT_TYPE_LABELS } from '@/lib/types';
|
||||
|
||||
/** Map event type keys to translation keys (types live in lib/types; labels translate at render). */
|
||||
const EVENT_TYPE_KEYS: Record<string, string> = {
|
||||
contest: 'placesUi.eventTypes.contest',
|
||||
session: 'placesUi.eventTypes.session',
|
||||
meetup: 'placesUi.eventTypes.meetup',
|
||||
premiere: 'placesUi.eventTypes.premiere',
|
||||
workshop: 'placesUi.eventTypes.workshop',
|
||||
demo: 'placesUi.eventTypes.demo',
|
||||
jam: 'placesUi.eventTypes.jam',
|
||||
};
|
||||
|
||||
function eventTypeLabel(t: (key: string) => string, type: string): string {
|
||||
return EVENT_TYPE_KEYS[type] ? t(EVENT_TYPE_KEYS[type]) : (EVENT_TYPE_LABELS[type] || type);
|
||||
}
|
||||
|
||||
interface EventCardProps {
|
||||
event: SkateEvent;
|
||||
compact?: boolean;
|
||||
|
|
@ -54,6 +70,7 @@ function formatTime(timestamp: number): string {
|
|||
}
|
||||
|
||||
export function EventCard({ event, compact = false, goingCount: goingCountProp }: EventCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const author = useAuthor(event.pubkey);
|
||||
const { user } = useCurrentUser();
|
||||
const metadata = author.data?.metadata;
|
||||
|
|
@ -169,7 +186,7 @@ export function EventCard({ event, compact = false, goingCount: goingCountProp }
|
|||
key={type}
|
||||
className="bg-primary text-primary-foreground"
|
||||
>
|
||||
{EVENT_TYPE_LABELS[type] || type}
|
||||
{eventTypeLabel(t, type)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -178,7 +195,7 @@ export function EventCard({ event, compact = false, goingCount: goingCountProp }
|
|||
<div className="absolute bottom-3 left-3 right-3 flex items-center gap-1.5 text-[11px] font-bold text-white/90">
|
||||
<Sparkles className="h-3 w-3 flex-shrink-0 text-[#ccff00]" />
|
||||
<span className="truncate">
|
||||
<span className="text-white/60 uppercase tracking-wide">Presented by </span>
|
||||
<span className="text-white/60 uppercase tracking-wide">{t('placesUi.sponsors.presentedBy')} </span>
|
||||
{leadSponsor.name}
|
||||
{sponsors.length > 1 && (
|
||||
<span className="text-white/60"> +{sponsors.length - 1}</span>
|
||||
|
|
@ -250,7 +267,7 @@ export function EventCard({ event, compact = false, goingCount: goingCountProp }
|
|||
{goingCount > 0 ? (
|
||||
<span className="flex items-center gap-1.5 text-sm font-bold text-green-400">
|
||||
<Users className="h-4 w-4" />
|
||||
{goingCount} going
|
||||
{goingCount} {t('placesUi.attendees.goingCount')}
|
||||
</span>
|
||||
) : (
|
||||
<span />
|
||||
|
|
@ -280,7 +297,7 @@ export function EventCard({ event, compact = false, goingCount: goingCountProp }
|
|||
className="text-red-500 focus:text-red-500"
|
||||
>
|
||||
<Flag className="h-4 w-4 mr-2" />
|
||||
Report
|
||||
{t('placesUi.eventCard.report')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
export type EventStatus = 'soon' | 'upcoming' | 'live' | 'ended';
|
||||
|
||||
|
|
@ -8,15 +9,17 @@ interface CountdownInfo {
|
|||
label: string;
|
||||
}
|
||||
|
||||
function computeStatus(start: number, end?: number): CountdownInfo {
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
function computeStatus(start: number, end: number | undefined, t: Translate): CountdownInfo {
|
||||
const now = Date.now() / 1000;
|
||||
const effectiveEnd = end ?? start + 3 * 3600; // assume ~3h if no end
|
||||
|
||||
if (now >= start && now < effectiveEnd) {
|
||||
return { status: 'live', label: 'Happening now' };
|
||||
return { status: 'live', label: t('placesUi.countdown.happeningNow') };
|
||||
}
|
||||
if (now >= effectiveEnd) {
|
||||
return { status: 'ended', label: 'Ended' };
|
||||
return { status: 'ended', label: t('placesUi.countdown.ended') };
|
||||
}
|
||||
|
||||
const diff = start - now;
|
||||
|
|
@ -25,15 +28,15 @@ function computeStatus(start: number, end?: number): CountdownInfo {
|
|||
const mins = Math.floor((diff % 3600) / 60);
|
||||
|
||||
if (days >= 1) {
|
||||
return { status: days <= 3 ? 'soon' : 'upcoming', label: days === 1 ? 'Tomorrow' : `In ${days} days` };
|
||||
return { status: days <= 3 ? 'soon' : 'upcoming', label: days === 1 ? t('placesUi.countdown.tomorrow') : t('placesUi.countdown.inDays', { count: days }) };
|
||||
}
|
||||
if (hours >= 1) {
|
||||
return { status: 'soon', label: `In ${hours}h ${mins}m` };
|
||||
return { status: 'soon', label: t('placesUi.countdown.inHoursMins', { hours, mins }) };
|
||||
}
|
||||
if (mins >= 1) {
|
||||
return { status: 'soon', label: `In ${mins} min` };
|
||||
return { status: 'soon', label: t('placesUi.countdown.inMins', { count: mins }) };
|
||||
}
|
||||
return { status: 'soon', label: 'Starting now' };
|
||||
return { status: 'soon', label: t('placesUi.countdown.startingNow') };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -50,6 +53,7 @@ export function EventCountdown({
|
|||
className?: string;
|
||||
}) {
|
||||
const [tick, forceTick] = useState(0);
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Re-render periodically so the countdown stays fresh without a heavy timer.
|
||||
useEffect(() => {
|
||||
|
|
@ -59,7 +63,7 @@ export function EventCountdown({
|
|||
|
||||
// `tick` is a dependency so the label actually recomputes on each interval.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const info = useMemo(() => computeStatus(start, end), [start, end, tick]);
|
||||
const info = useMemo(() => computeStatus(start, end, t), [start, end, tick]);
|
||||
|
||||
const styles: Record<EventStatus, string> = {
|
||||
live: 'bg-[#ff0040] text-white border-[#ff0040] animate-pulse',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { ExternalLink, Sparkles } from 'lucide-react';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { EVENT_SPONSOR_TIERS, type EventSponsor } from '@/lib/types';
|
||||
|
||||
function SponsorLogo({ sponsor, large }: { sponsor: EventSponsor; large?: boolean }) {
|
||||
|
|
@ -59,6 +60,7 @@ export function EventSponsors({
|
|||
sponsors: EventSponsor[];
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!sponsors || sponsors.length === 0) return null;
|
||||
|
||||
const title = sponsors.filter((s) => s.tier === 'title');
|
||||
|
|
@ -69,7 +71,7 @@ export function EventSponsors({
|
|||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-[#ccff00]" />
|
||||
<h3 className="text-xs font-black uppercase tracking-widest text-gray-400">
|
||||
Presented by
|
||||
{t('placesUi.sponsors.presentedBy')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -12,19 +12,21 @@ import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
|
|||
import { genUserName } from '@/lib/genUserName';
|
||||
import { isAdminPubkey } from '@/lib/admins';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import type { EventUpdate, EventUpdateKind, SkateEvent } from '@/lib/types';
|
||||
|
||||
const UPDATE_KIND_META: Record<
|
||||
EventUpdateKind,
|
||||
{ icon: typeof Info; label: string; color: string; ring: string }
|
||||
> = {
|
||||
update: { icon: Info, label: 'Update', color: 'text-sky-400', ring: 'bg-sky-500/15' },
|
||||
reschedule: { icon: CalendarClock, label: 'New time', color: 'text-amber-400', ring: 'bg-amber-500/15' },
|
||||
relocation: { icon: MapPin, label: 'New spot', color: 'text-[#ff0040]', ring: 'bg-[#ff0040]/15' },
|
||||
cancelled: { icon: XCircle, label: 'Cancelled', color: 'text-red-400', ring: 'bg-red-500/15' },
|
||||
update: { icon: Info, label: 'placesUi.updates.kindUpdate', color: 'text-sky-400', ring: 'bg-sky-500/15' },
|
||||
reschedule: { icon: CalendarClock, label: 'placesUi.updates.kindNewTime', color: 'text-amber-400', ring: 'bg-amber-500/15' },
|
||||
relocation: { icon: MapPin, label: 'placesUi.updates.kindNewSpot', color: 'text-[#ff0040]', ring: 'bg-[#ff0040]/15' },
|
||||
cancelled: { icon: XCircle, label: 'placesUi.updates.kindCancelled', color: 'text-red-400', ring: 'bg-red-500/15' },
|
||||
};
|
||||
|
||||
function UpdateItem({ update }: { update: EventUpdate }) {
|
||||
const { t } = useTranslation();
|
||||
const author = useAuthor(update.pubkey);
|
||||
const name = author.data?.metadata?.name ?? genUserName(update.pubkey);
|
||||
const pic = author.data?.metadata?.picture;
|
||||
|
|
@ -54,7 +56,7 @@ function UpdateItem({ update }: { update: EventUpdate }) {
|
|||
<span className="text-sm font-bold text-white">{name}</span>
|
||||
{update.authorRole === 'admin' && (
|
||||
<span className="rounded-full border border-yellow-500/40 bg-yellow-500/15 px-1.5 py-0.5 text-[9px] font-black uppercase tracking-wide text-yellow-400">
|
||||
Admin
|
||||
{t('placesUi.updates.admin')}
|
||||
</span>
|
||||
)}
|
||||
{update.updateKind !== 'update' && (
|
||||
|
|
@ -65,7 +67,7 @@ function UpdateItem({ update }: { update: EventUpdate }) {
|
|||
meta.color,
|
||||
)}
|
||||
>
|
||||
{meta.label}
|
||||
{t(meta.label)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[11px] text-gray-600">
|
||||
|
|
@ -85,6 +87,7 @@ function UpdateItem({ update }: { update: EventUpdate }) {
|
|||
* Posting p-tags every RSVPer so they get a notification.
|
||||
*/
|
||||
export function EventUpdates({ event }: { event: SkateEvent }) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { toast } = useToast();
|
||||
const coord = eventCoord(event);
|
||||
|
|
@ -114,13 +117,13 @@ export function EventUpdates({ event }: { event: SkateEvent }) {
|
|||
setKind('update');
|
||||
const reach = new Set([...going, ...tentative]).size;
|
||||
toast({
|
||||
title: 'Update posted',
|
||||
description: reach > 0 ? `${reach} ${reach === 1 ? 'person' : 'people'} notified.` : 'Everyone will see it on the event.',
|
||||
title: t('placesUi.updates.posted'),
|
||||
description: reach > 0 ? t('placesUi.updates.notifiedCount', { count: reach }) : t('placesUi.updates.notifiedEveryone'),
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Could not post update',
|
||||
description: err instanceof Error ? err.message : 'Please try again.',
|
||||
title: t('placesUi.updates.postError'),
|
||||
description: err instanceof Error ? err.message : t('placesUi.updates.tryAgain'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -147,12 +150,11 @@ export function EventUpdates({ event }: { event: SkateEvent }) {
|
|||
)}
|
||||
>
|
||||
<Megaphone className="h-4 w-4" />
|
||||
{isHost ? 'Post an update' : 'Post an admin update'}
|
||||
{isHost ? t('placesUi.updates.postUpdate') : t('placesUi.updates.postAdminUpdate')}
|
||||
</div>
|
||||
{!isHost && (
|
||||
<p className="mb-2 text-[11px] text-yellow-200/80">
|
||||
You're posting as a SkateSpots admin. It'll be clearly labelled as an admin
|
||||
notice, not from the host.
|
||||
{t('placesUi.updates.adminNote')}
|
||||
</p>
|
||||
)}
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
|
|
@ -173,7 +175,7 @@ export function EventUpdates({ event }: { event: SkateEvent }) {
|
|||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
{m.label}
|
||||
{t(m.label)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
@ -181,7 +183,7 @@ export function EventUpdates({ event }: { event: SkateEvent }) {
|
|||
<Textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Rain — moved to Sunday 2pm. Meet at the back ledge…"
|
||||
placeholder={t('placesUi.updates.placeholder')}
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
className="resize-none border-white/10 bg-black/40 text-sm"
|
||||
|
|
@ -189,8 +191,8 @@ export function EventUpdates({ event }: { event: SkateEvent }) {
|
|||
<div className="mt-2 flex items-center justify-between">
|
||||
<span className="text-[11px] text-gray-500">
|
||||
{going.length + tentative.length > 0
|
||||
? `Notifies ${new Set([...going, ...tentative]).size} RSVP${new Set([...going, ...tentative]).size === 1 ? '' : "'s"}`
|
||||
: 'Visible to everyone on this event'}
|
||||
? t('placesUi.updates.notifiesRsvps', { count: new Set([...going, ...tentative]).size })
|
||||
: t('placesUi.updates.visibleToEveryone')}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -204,7 +206,7 @@ export function EventUpdates({ event }: { event: SkateEvent }) {
|
|||
)}
|
||||
>
|
||||
{isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
|
||||
Post
|
||||
{t('placesUi.updates.post')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -213,7 +215,7 @@ export function EventUpdates({ event }: { event: SkateEvent }) {
|
|||
{/* Feed */}
|
||||
{isLoading && !hasUpdates ? (
|
||||
<div className="flex items-center gap-2 py-4 text-sm text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading updates…
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> {t('placesUi.updates.loading')}
|
||||
</div>
|
||||
) : hasUpdates ? (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -224,7 +226,7 @@ export function EventUpdates({ event }: { event: SkateEvent }) {
|
|||
) : (
|
||||
!canPost && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No updates from the host yet. RSVP and you'll be notified if anything changes.
|
||||
{t('placesUi.updates.noUpdates')}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useMemo, useState, useRef, useEffect } from 'react';
|
|||
import { Link } from 'react-router-dom';
|
||||
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Route, MapPin, Clock, Users } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import type { SkateEvent, SkateTour } from '@/lib/types';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
|
@ -36,10 +37,29 @@ interface EventsCalendarProps {
|
|||
// ─── Date helpers (local time) ────────────────────────────────────────────────
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const MONTHS = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
const WEEKDAY_KEYS = [
|
||||
'placesUi.calendar.weekdays.sun',
|
||||
'placesUi.calendar.weekdays.mon',
|
||||
'placesUi.calendar.weekdays.tue',
|
||||
'placesUi.calendar.weekdays.wed',
|
||||
'placesUi.calendar.weekdays.thu',
|
||||
'placesUi.calendar.weekdays.fri',
|
||||
'placesUi.calendar.weekdays.sat',
|
||||
];
|
||||
const WEEKDAY_SHORT_KEYS = [
|
||||
'placesUi.calendar.weekdaysShort.sun',
|
||||
'placesUi.calendar.weekdaysShort.mon',
|
||||
'placesUi.calendar.weekdaysShort.tue',
|
||||
'placesUi.calendar.weekdaysShort.wed',
|
||||
'placesUi.calendar.weekdaysShort.thu',
|
||||
'placesUi.calendar.weekdaysShort.fri',
|
||||
'placesUi.calendar.weekdaysShort.sat',
|
||||
];
|
||||
const MONTH_KEYS = [
|
||||
'placesUi.calendar.months.january', 'placesUi.calendar.months.february', 'placesUi.calendar.months.march',
|
||||
'placesUi.calendar.months.april', 'placesUi.calendar.months.may', 'placesUi.calendar.months.june',
|
||||
'placesUi.calendar.months.july', 'placesUi.calendar.months.august', 'placesUi.calendar.months.september',
|
||||
'placesUi.calendar.months.october', 'placesUi.calendar.months.november', 'placesUi.calendar.months.december',
|
||||
];
|
||||
|
||||
/** Midnight (local) for a given date. */
|
||||
|
|
@ -122,6 +142,7 @@ function formatDayLong(d: Date): string {
|
|||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function EventsCalendar({ events, tours, goingCounts, show, anchorUnix }: EventsCalendarProps) {
|
||||
const { t } = useTranslation();
|
||||
const today = useMemo(() => startOfDay(new Date()), []);
|
||||
const [view, setView] = useState<Date>(() => {
|
||||
const base = anchorUnix ? new Date(anchorUnix * 1000) : today;
|
||||
|
|
@ -210,7 +231,7 @@ export function EventsCalendar({ events, tours, goingCounts, show, anchorUnix }:
|
|||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-baseline gap-2 min-w-0">
|
||||
<h2 className="text-lg md:text-2xl font-black uppercase tracking-tight truncate">
|
||||
{MONTHS[view.getMonth()]}
|
||||
{t(MONTH_KEYS[view.getMonth()])}
|
||||
</h2>
|
||||
<span className="text-gray-500 text-lg md:text-2xl font-black">{view.getFullYear()}</span>
|
||||
</div>
|
||||
|
|
@ -220,11 +241,11 @@ export function EventsCalendar({ events, tours, goingCounts, show, anchorUnix }:
|
|||
onClick={goToToday}
|
||||
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-[11px] font-bold uppercase tracking-wide text-gray-300 transition-all hover:border-white/25 hover:text-white active:scale-95"
|
||||
>
|
||||
Today
|
||||
{t('placesUi.calendar.today')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous month"
|
||||
aria-label={t('placesUi.calendar.prevMonth')}
|
||||
onClick={() => goToMonth(-1)}
|
||||
className="rounded-full border border-white/10 bg-white/5 p-2 text-gray-300 transition-all hover:border-white/25 hover:text-white active:scale-95"
|
||||
>
|
||||
|
|
@ -232,7 +253,7 @@ export function EventsCalendar({ events, tours, goingCounts, show, anchorUnix }:
|
|||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next month"
|
||||
aria-label={t('placesUi.calendar.nextMonth')}
|
||||
onClick={() => goToMonth(1)}
|
||||
className="rounded-full border border-white/10 bg-white/5 p-2 text-gray-300 transition-all hover:border-white/25 hover:text-white active:scale-95"
|
||||
>
|
||||
|
|
@ -243,10 +264,10 @@ export function EventsCalendar({ events, tours, goingCounts, show, anchorUnix }:
|
|||
|
||||
{/* Weekday labels */}
|
||||
<div className="grid grid-cols-7 gap-1 md:gap-1.5 mb-1 md:mb-2">
|
||||
{WEEKDAYS.map((wd) => (
|
||||
<div key={wd} className="text-center text-[10px] md:text-xs font-bold uppercase tracking-wider text-gray-500 py-1">
|
||||
<span className="md:hidden">{wd[0]}</span>
|
||||
<span className="hidden md:inline">{wd}</span>
|
||||
{WEEKDAY_KEYS.map((key, i) => (
|
||||
<div key={key} className="text-center text-[10px] md:text-xs font-bold uppercase tracking-wider text-gray-500 py-1">
|
||||
<span className="md:hidden">{t(WEEKDAY_SHORT_KEYS[i])}</span>
|
||||
<span className="hidden md:inline">{t(key)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -265,7 +286,7 @@ export function EventsCalendar({ events, tours, goingCounts, show, anchorUnix }:
|
|||
key={cell.getTime()}
|
||||
type="button"
|
||||
onClick={() => setSelected(startOfDay(cell))}
|
||||
aria-label={`${formatDayLong(cell)}${has ? `, ${dayItems.length} ${show}` : ''}`}
|
||||
aria-label={`${formatDayLong(cell)}${has ? `, ${t(show === 'events' ? 'placesUi.calendar.eventsOnDay' : 'placesUi.calendar.toursOnDay', { count: dayItems.length })}` : ''}`}
|
||||
aria-pressed={isSelected}
|
||||
className={cn(
|
||||
'relative aspect-square rounded-lg md:rounded-xl border flex flex-col items-center justify-start pt-1 md:pt-1.5 transition-all active:scale-95',
|
||||
|
|
@ -309,11 +330,13 @@ export function EventsCalendar({ events, tours, goingCounts, show, anchorUnix }:
|
|||
<div className="mt-4 flex items-center justify-between text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={cn('h-2 w-2 rounded-full', accent.dot)} />
|
||||
{show === 'events' ? 'Event' : 'Tour'} day
|
||||
{show === 'events' ? t('placesUi.calendar.eventDay') : t('placesUi.calendar.tourDay')}
|
||||
</span>
|
||||
<span>
|
||||
<span className={cn('font-bold', accent.text)}>{totalThisMonth}</span>{' '}
|
||||
{show === 'events' ? 'event' : 'tour'}{totalThisMonth === 1 ? '' : 's'} this month
|
||||
{show === 'events'
|
||||
? t('placesUi.calendar.eventsThisMonth')
|
||||
: t('placesUi.calendar.toursThisMonth')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -325,7 +348,7 @@ export function EventsCalendar({ events, tours, goingCounts, show, anchorUnix }:
|
|||
? <CalendarIcon className={cn('h-4 w-4', accent.text)} />
|
||||
: <Route className={cn('h-4 w-4', accent.text)} />}
|
||||
<h3 className="text-sm font-black uppercase tracking-wide">
|
||||
{sameDay(selected, today) ? 'Today' : formatDayLong(selected)}
|
||||
{sameDay(selected, today) ? t('placesUi.calendar.today') : formatDayLong(selected)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
|
|
@ -355,7 +378,7 @@ export function EventsCalendar({ events, tours, goingCounts, show, anchorUnix }:
|
|||
{multiDay
|
||||
? `${new Date(it.start * 1000).toLocaleDateString([], { month: 'short', day: 'numeric' })} – ${new Date((it.end as number) * 1000).toLocaleDateString([], { month: 'short', day: 'numeric' })}`
|
||||
: it.allDay
|
||||
? 'All day'
|
||||
? t('placesUi.calendar.allDay')
|
||||
: formatTime(it.start)}
|
||||
</span>
|
||||
{it.location && (
|
||||
|
|
@ -367,7 +390,7 @@ export function EventsCalendar({ events, tours, goingCounts, show, anchorUnix }:
|
|||
{it.kind === 'event' && (it.goingCount ?? 0) > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{it.goingCount} going
|
||||
{it.goingCount} {t('placesUi.calendar.going')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -380,10 +403,10 @@ export function EventsCalendar({ events, tours, goingCounts, show, anchorUnix }:
|
|||
) : (
|
||||
<div className="rounded-xl border border-dashed border-white/10 py-8 px-4 text-center">
|
||||
<p className="text-sm text-gray-500">
|
||||
Nothing on this day.
|
||||
{t('placesUi.calendar.nothingOnDay')}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-600">
|
||||
Tap a highlighted day to see what's on.
|
||||
{t('placesUi.calendar.tapHighlighted')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,14 +3,11 @@ import { Switch } from '@/components/ui/switch';
|
|||
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
|
||||
import { usePlatformFee, type FeeType } from '@/hooks/usePlatformFee';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
interface FeaturedUpgradeProps {
|
||||
/** Which platform fee this upgrade drives. */
|
||||
type: Extract<FeeType, 'featuredEvent' | 'featuredTour'>;
|
||||
/** Noun shown to the user, e.g. "event" or "tour". */
|
||||
noun: string;
|
||||
/** Where featured items appear, e.g. "events page" or "tours". */
|
||||
placement: string;
|
||||
enabled: boolean;
|
||||
onEnabledChange: (enabled: boolean) => void;
|
||||
className?: string;
|
||||
|
|
@ -25,14 +22,18 @@ interface FeaturedUpgradeProps {
|
|||
*/
|
||||
export function FeaturedUpgrade({
|
||||
type,
|
||||
noun,
|
||||
placement,
|
||||
enabled,
|
||||
onEnabledChange,
|
||||
className,
|
||||
}: FeaturedUpgradeProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isActive, isPlatformOwner, daysRemaining } = usePlatformFee(type);
|
||||
|
||||
const isEvent = type === 'featuredEvent';
|
||||
const heading = t(isEvent ? 'placesUi.featured.headingEvent' : 'placesUi.featured.headingTour');
|
||||
const body = t(isEvent ? 'placesUi.featured.bodyEvent' : 'placesUi.featured.bodyTour');
|
||||
const context = t(isEvent ? 'placesUi.featured.contextEvent' : 'placesUi.featured.contextTour');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -48,17 +49,16 @@ export function FeaturedUpgrade({
|
|||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-black uppercase tracking-wide text-sm text-[#ccff00]">
|
||||
Feature this {noun} <span className="text-gray-500 font-normal normal-case">· optional</span>
|
||||
{heading} <span className="text-gray-500 font-normal normal-case">· {t('placesUi.featured.optional')}</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
Free to post. Featured {noun}s get a <strong className="text-[#ccff00]">badge</strong> and sit
|
||||
{' '}<strong className="text-[#ccff00]">at the top</strong> of {placement}.
|
||||
{body}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{isActive ? (
|
||||
<span className="flex items-center gap-1 text-[11px] font-bold text-green-400 flex-shrink-0">
|
||||
<Check className="h-3.5 w-3.5" /> Active
|
||||
<Check className="h-3.5 w-3.5" /> {t('placesUi.featured.active')}
|
||||
</span>
|
||||
) : (
|
||||
<Switch
|
||||
|
|
@ -72,8 +72,8 @@ export function FeaturedUpgrade({
|
|||
{isActive && (
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
{isPlatformOwner
|
||||
? 'Platform owner — featured is always on.'
|
||||
: `Featured active · ${daysRemaining} day${daysRemaining !== 1 ? 's' : ''} remaining.`}
|
||||
? t('placesUi.featured.platformOwner')
|
||||
: t('placesUi.featured.daysRemaining', { count: daysRemaining })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -83,7 +83,7 @@ export function FeaturedUpgrade({
|
|||
<PlatformFeePayment
|
||||
type={type}
|
||||
onSuccess={() => { /* hook auto-updates isActive */ }}
|
||||
context={`Your ${noun} stays listed for free either way — featured just adds the badge & boost.`}
|
||||
context={context}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -97,12 +97,5 @@ export function FeaturedEventUpgrade(props: {
|
|||
onEnabledChange: (enabled: boolean) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<FeaturedUpgrade
|
||||
type="featuredEvent"
|
||||
noun="event"
|
||||
placement="the events page"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <FeaturedUpgrade type="featuredEvent" {...props} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { useAuthor } from '@/hooks/useAuthor';
|
|||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { Users, UserPlus, UserMinus, Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
|
||||
function FollowingUserCard({ pubkey, onUnfollow }: { pubkey: string; onUnfollow: (pubkey: string) => void }) {
|
||||
|
|
@ -18,6 +19,7 @@ function FollowingUserCard({ pubkey, onUnfollow }: { pubkey: string; onUnfollow:
|
|||
const metadata = author.data?.metadata;
|
||||
const displayName = metadata?.name ?? genUserName(pubkey);
|
||||
const [isUnfollowing, setIsUnfollowing] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleUnfollow = async () => {
|
||||
setIsUnfollowing(true);
|
||||
|
|
@ -61,7 +63,7 @@ function FollowingUserCard({ pubkey, onUnfollow }: { pubkey: string; onUnfollow:
|
|||
) : (
|
||||
<UserMinus className="h-3 w-3" />
|
||||
)}
|
||||
Unfollow
|
||||
{t('settingsUi.followList.unfollow')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -100,6 +102,7 @@ function FollowerUserCard({ pubkey }: { pubkey: string }) {
|
|||
|
||||
export function FollowListManager() {
|
||||
const { user } = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
const { data: following = [], isLoading: followingLoading } = useFollowing();
|
||||
const { data: followers = [], isLoading: followersLoading } = useFollowers();
|
||||
const { unfollow, followByIdentifier, isFollowing } = useFollowActions();
|
||||
|
|
@ -123,10 +126,10 @@ export function FollowListManager() {
|
|||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-purple-400">
|
||||
<Users className="h-5 w-5" />
|
||||
Your Nostr Network
|
||||
{t('settingsUi.followList.title')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-300">
|
||||
Manage who you follow. Following users helps build your Web of Trust and determines which spots show trust badges.
|
||||
{t('settingsUi.followList.desc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -134,11 +137,11 @@ export function FollowListManager() {
|
|||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="following" className="gap-2">
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Following ({following.length})
|
||||
{t('settingsUi.followList.following', { count: following.length })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="followers" className="gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Followers ({followers.length})
|
||||
{t('settingsUi.followList.followers', { count: followers.length })}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
|
@ -163,11 +166,11 @@ export function FollowListManager() {
|
|||
) : (
|
||||
<UserPlus className="h-4 w-4" />
|
||||
)}
|
||||
Follow
|
||||
{t('settingsUi.followList.follow')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
💡 Paste a Nostr pubkey (npub1...), NIP-05 address (user@domain.com), or hex pubkey
|
||||
{t('settingsUi.followList.followHint')}
|
||||
</p>
|
||||
</form>
|
||||
|
||||
|
|
@ -199,9 +202,9 @@ export function FollowListManager() {
|
|||
) : (
|
||||
<div className="text-center py-8 border-2 border-dashed border-gray-700 rounded-lg">
|
||||
<Users className="h-12 w-12 mx-auto mb-3 text-gray-600" />
|
||||
<p className="text-gray-400 mb-2">You're not following anyone yet</p>
|
||||
<p className="text-gray-400 mb-2">{t('settingsUi.followList.notFollowing')}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Follow skaters to see their spots with ✅ VERIFIED badges
|
||||
{t('settingsUi.followList.notFollowingHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -210,8 +213,7 @@ export function FollowListManager() {
|
|||
{following.length > 0 && (
|
||||
<div className="p-4 rounded-lg bg-green-950/30 border border-green-500/30 text-sm">
|
||||
<p className="text-green-400">
|
||||
<strong>✅ Trust Badges Active:</strong> Spots created by people you follow will now show VERIFIED badges.
|
||||
Spots by their friends will show TRUSTED badges.
|
||||
<strong>{t('settingsUi.followList.trustActiveStrong')}</strong> {t('settingsUi.followList.trustActive')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -241,9 +243,9 @@ export function FollowListManager() {
|
|||
) : (
|
||||
<div className="text-center py-8 border-2 border-dashed border-gray-700 rounded-lg">
|
||||
<Users className="h-12 w-12 mx-auto mb-3 text-gray-600" />
|
||||
<p className="text-gray-400 mb-2">No followers yet</p>
|
||||
<p className="text-gray-400 mb-2">{t('settingsUi.followList.noFollowers')}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Share your Nostr profile to gain followers!
|
||||
{t('settingsUi.followList.noFollowersHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -253,20 +255,20 @@ export function FollowListManager() {
|
|||
{/* Info Card */}
|
||||
<Card className="bg-blue-950/30 border-2 border-blue-500/50 mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm text-blue-400">💡 How Following Affects Trust Badges</CardTitle>
|
||||
<CardTitle className="text-sm text-blue-400">{t('settingsUi.followList.howTitle')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-gray-300">
|
||||
<p>
|
||||
<strong className="text-white">✅ VERIFIED (Green):</strong> Spots created by people you directly follow
|
||||
<strong className="text-white">{t('settingsUi.followList.verifiedStrong')}</strong> {t('settingsUi.followList.verifiedDesc')}
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-white">🟡 TRUSTED (Yellow):</strong> Spots created by people your friends follow (2 hops)
|
||||
<strong className="text-white">{t('settingsUi.followList.trustedStrong')}</strong> {t('settingsUi.followList.trustedDesc')}
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-white">⚪ Unverified:</strong> Spots by users with no connection to your network
|
||||
<strong className="text-white">{t('settingsUi.followList.unverifiedStrong')}</strong> {t('settingsUi.followList.unverifiedDesc')}
|
||||
</p>
|
||||
<p className="text-xs text-blue-400 mt-4">
|
||||
💡 Tip: Follow active skateboarders to see more verified spots and build your trust network!
|
||||
{t('settingsUi.followList.tip')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
|
|||
import { toast } from '@/hooks/useToast';
|
||||
import { PRO_LABEL_NAMESPACE, PRO_LABEL_VALUE, DAY_SECONDS } from '@/lib/sasquatchPro';
|
||||
import { BadgeCheck, Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
function decodeTarget(value: string): string | null {
|
||||
const v = value.trim();
|
||||
|
|
@ -41,6 +42,7 @@ export function GrantProCard() {
|
|||
const [target, setTarget] = useState('');
|
||||
const [days, setDays] = useState('30');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isOwner = !!user?.pubkey && PLATFORM_OWNER_PUBKEYS.includes(user.pubkey);
|
||||
if (!isOwner) return null;
|
||||
|
|
@ -48,12 +50,12 @@ export function GrantProCard() {
|
|||
const grant = async () => {
|
||||
const pubkey = decodeTarget(target);
|
||||
if (!pubkey) {
|
||||
toast({ title: 'Enter a valid npub (or hex pubkey)', variant: 'destructive' });
|
||||
toast({ title: t('settingsUi.grantPro.invalidNpub'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
const n = Number(days);
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
toast({ title: 'Days must be a positive number', variant: 'destructive' });
|
||||
toast({ title: t('settingsUi.grantPro.invalidDays'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (!user?.signer) return;
|
||||
|
|
@ -82,14 +84,17 @@ export function GrantProCard() {
|
|||
);
|
||||
if (!results.some((r) => r.status === 'fulfilled')) throw new Error('No relay accepted the grant.');
|
||||
toast({
|
||||
title: 'Pro granted ⚡',
|
||||
description: `${nip19.npubEncode(pubkey).slice(0, 14)}… until ${new Date(expiry * 1000).toLocaleDateString()}`,
|
||||
title: t('settingsUi.grantPro.grantedTitle'),
|
||||
description: t('settingsUi.grantPro.grantedDesc', {
|
||||
npub: `${nip19.npubEncode(pubkey).slice(0, 14)}…`,
|
||||
date: new Date(expiry * 1000).toLocaleDateString(),
|
||||
}),
|
||||
});
|
||||
setTarget('');
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Grant failed',
|
||||
description: err instanceof Error ? err.message : 'Try again.',
|
||||
title: t('settingsUi.grantPro.failedTitle'),
|
||||
description: err instanceof Error ? err.message : t('common.tryAgain'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -102,20 +107,19 @@ export function GrantProCard() {
|
|||
<CardContent className="py-3.5 space-y-2.5">
|
||||
<p className="flex items-center gap-2 text-sm font-bold text-white">
|
||||
<BadgeCheck className="h-4 w-4 text-[#ccff00]" />
|
||||
Grant Sasquatch Pro
|
||||
{t('settingsUi.grantPro.title')}
|
||||
<span className="rounded bg-[#ccff00]/10 px-1.5 py-0.5 text-[9px] font-black uppercase tracking-widest text-[#ccff00]/70">
|
||||
Owner
|
||||
{t('settingsUi.grantPro.owner')}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Manual fallback for a verified payment the zap receipt missed. Publishes an owner-signed
|
||||
grant; the user can't forge it.
|
||||
{t('settingsUi.grantPro.desc')}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
placeholder="npub1… (or hex)"
|
||||
placeholder={t('settingsUi.grantPro.targetPlaceholder')}
|
||||
className="h-9 flex-1 border-white/10 bg-black/40 text-sm text-white"
|
||||
spellCheck={false}
|
||||
disabled={busy}
|
||||
|
|
@ -134,7 +138,7 @@ export function GrantProCard() {
|
|||
className="h-9 gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <BadgeCheck className="h-4 w-4" />}
|
||||
Grant
|
||||
{t('settingsUi.grantPro.grant')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
useEndorseJoinRequest,
|
||||
} from '@/hooks/useCrewInvites';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { parseAddrCoord, type CrewEndorsement, type CrewJoinRequest, type SkateCrew } from '@/lib/types';
|
||||
import { UserPlus, Check, X, Loader2, Hand, ThumbsUp, Clock } from 'lucide-react';
|
||||
|
|
@ -38,13 +39,14 @@ function EndorserAvatar({ pubkey }: { pubkey: string }) {
|
|||
|
||||
/** Readable "Vouched by Rider, Mia +1" line for the endorsement strip. */
|
||||
function EndorserNames({ pubkeys }: { pubkeys: string[] }) {
|
||||
const { t } = useTranslation();
|
||||
const first = useAuthor(pubkeys[0]);
|
||||
const firstName = first.data?.metadata?.name ?? genUserName(pubkeys[0]);
|
||||
const extra = pubkeys.length - 1;
|
||||
return (
|
||||
<span className="font-bold text-emerald-300">
|
||||
{firstName}
|
||||
{extra > 0 && <span className="font-normal text-emerald-400/70"> +{extra} more</span>}
|
||||
{extra > 0 && <span className="font-normal text-emerald-400/70"> {t('socialUi.joinRequest.moreCount', { count: extra })}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -70,6 +72,7 @@ export function JoinRequestCard({
|
|||
onResolved?: () => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const requester = useAuthor(request.requester);
|
||||
const requesterName = requester.data?.metadata?.name ?? genUserName(request.requester);
|
||||
|
|
@ -95,14 +98,14 @@ export function JoinRequestCard({
|
|||
await approve({ request, crew });
|
||||
setDone('approved');
|
||||
toast({
|
||||
title: `Added ${requesterName} to ${crew.name} 🛹`,
|
||||
description: "They'll confirm from their side to appear as a member.",
|
||||
title: t('socialUi.joinRequest.addedTitle', { name: requesterName, crew: crew.name }),
|
||||
description: t('socialUi.joinRequest.addedDesc'),
|
||||
});
|
||||
onResolved?.();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Could not add member',
|
||||
description: err instanceof Error ? err.message : 'Please try again.',
|
||||
title: t('socialUi.collectiveJoinRequest.couldNotAdd'),
|
||||
description: err instanceof Error ? err.message : t('common.tryAgain'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -113,14 +116,14 @@ export function JoinRequestCard({
|
|||
await endorse({ request, crew });
|
||||
setDone('endorsed');
|
||||
toast({
|
||||
title: `You vouched for ${requesterName} 🤝`,
|
||||
description: `The founder of ${crew.name} will see your endorsement and can add them.`,
|
||||
title: t('socialUi.joinRequest.vouchedTitle', { name: requesterName }),
|
||||
description: t('socialUi.joinRequest.vouchedDesc', { crew: crew.name }),
|
||||
});
|
||||
onResolved?.();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Could not endorse',
|
||||
description: err instanceof Error ? err.message : 'Please try again.',
|
||||
title: t('socialUi.joinRequest.couldNotEndorse'),
|
||||
description: err instanceof Error ? err.message : t('common.tryAgain'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -132,7 +135,7 @@ export function JoinRequestCard({
|
|||
setDone('dismissed');
|
||||
onResolved?.();
|
||||
} catch {
|
||||
toast({ title: 'Could not dismiss', variant: 'destructive' });
|
||||
toast({ title: t('socialUi.collectiveJoinRequest.couldNotDismiss'), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -142,17 +145,17 @@ export function JoinRequestCard({
|
|||
{done === 'approved' ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 text-emerald-400" />
|
||||
Added <span className="font-bold text-white">{requesterName}</span> to {crew.name}
|
||||
{t('socialUi.collectiveJoinRequest.addedDone')} <span className="font-bold text-white">{requesterName}</span> {t('socialUi.collectiveJoinRequest.addedTo')} {crew.name}
|
||||
</>
|
||||
) : done === 'endorsed' ? (
|
||||
<>
|
||||
<ThumbsUp className="h-4 w-4 text-emerald-400" />
|
||||
You vouched for <span className="font-bold text-white">{requesterName}</span> — founder will confirm
|
||||
{t('socialUi.joinRequest.endorsedDoneA')} <span className="font-bold text-white">{requesterName}</span> {t('socialUi.joinRequest.endorsedDoneB')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<X className="h-4 w-4 text-gray-500" />
|
||||
Dismissed request from {requesterName}
|
||||
{t('socialUi.collectiveJoinRequest.dismissedDone', { name: requesterName })}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -184,7 +187,7 @@ export function JoinRequestCard({
|
|||
</Avatar>
|
||||
{requesterName}
|
||||
</Link>{' '}
|
||||
wants to join{' '}
|
||||
{t('socialUi.collectiveJoinRequest.wantsToJoin')}{' '}
|
||||
<Link to={crewHref} className="font-black text-emerald-300 hover:underline">
|
||||
{crew.name}
|
||||
</Link>
|
||||
|
|
@ -204,7 +207,7 @@ export function JoinRequestCard({
|
|||
))}
|
||||
</div>
|
||||
<p className="min-w-0 flex-1 truncate text-[11px] text-emerald-200/90">
|
||||
Vouched by <EndorserNames pubkeys={endorserPubkeys} />
|
||||
{t('socialUi.joinRequest.vouchedBy')} <EndorserNames pubkeys={endorserPubkeys} />
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -218,12 +221,12 @@ export function JoinRequestCard({
|
|||
className="h-8 flex-1 gap-1.5 bg-emerald-500 font-bold text-black hover:bg-emerald-600"
|
||||
>
|
||||
{approving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <UserPlus className="h-3.5 w-3.5" />}
|
||||
{hasEndorsements ? 'Approve & add' : 'Add to crew'}
|
||||
{hasEndorsements ? t('socialUi.joinRequest.approveAndAdd') : t('socialUi.joinRequest.addToCrew')}
|
||||
</Button>
|
||||
) : isAdmin ? (
|
||||
iEndorsed ? (
|
||||
<span className="flex flex-1 items-center gap-1.5 text-xs font-bold text-emerald-400/90">
|
||||
<Clock className="h-3.5 w-3.5" /> Vouched — waiting on the founder
|
||||
<Clock className="h-3.5 w-3.5" /> {t('socialUi.joinRequest.vouchedWaiting')}
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
|
|
@ -233,11 +236,11 @@ export function JoinRequestCard({
|
|||
className="h-8 flex-1 gap-1.5 bg-emerald-500 font-bold text-black hover:bg-emerald-600"
|
||||
>
|
||||
{endorsing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ThumbsUp className="h-3.5 w-3.5" />}
|
||||
Vouch for them
|
||||
{t('socialUi.joinRequest.vouchForThem')}
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<span className="flex-1 text-xs text-gray-500">Crew leadership only.</span>
|
||||
<span className="flex-1 text-xs text-gray-500">{t('socialUi.joinRequest.leadershipOnly')}</span>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -247,14 +250,14 @@ export function JoinRequestCard({
|
|||
className="h-8 gap-1.5 border-white/15 text-gray-400 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{dismissing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
|
||||
Dismiss
|
||||
{t('socialUi.collectiveJoinRequest.dismiss')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Micro-help so the admin path isn't a mystery. */}
|
||||
{isAdmin && !iEndorsed && (
|
||||
<p className="mt-1.5 text-[11px] leading-snug text-gray-500">
|
||||
Admins can’t add members directly — vouching tells the founder you back this rider.
|
||||
{t('socialUi.joinRequest.adminHelp')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@ function MobileHeaderUserMenu({
|
|||
onAddAccount: () => void;
|
||||
}) {
|
||||
const author = useAuthor(pubkey);
|
||||
const { t } = useTranslation();
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = metadata?.name ?? genUserName(pubkey);
|
||||
const avatar = metadata?.picture;
|
||||
|
|
@ -194,7 +195,7 @@ function MobileHeaderUserMenu({
|
|||
<UserStatusIndicator pubkey={pubkey} size="sm" />
|
||||
<span className="text-green-400 text-xs font-bold uppercase tracking-wide hidden xs:inline">
|
||||
<CheckCircle2 className="h-3 w-3 inline mr-1" />
|
||||
In
|
||||
{t('chromeUi.inBadge')}
|
||||
</span>
|
||||
{/* Green online indicator dot */}
|
||||
<span className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-green-500 border-2 border-black rounded-full xs:hidden" />
|
||||
|
|
@ -217,7 +218,7 @@ function MobileHeaderUserMenu({
|
|||
<p className="font-bold text-sm truncate">{displayName}</p>
|
||||
<p className="text-xs text-green-500 flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Logged In
|
||||
{t('chromeUi.loggedIn')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -232,62 +233,62 @@ function MobileHeaderUserMenu({
|
|||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
|
||||
</span>
|
||||
<span className="text-green-400 font-bold">Set Status</span>
|
||||
<span className="text-green-400 font-bold">{t('chromeUi.setStatus')}</span>
|
||||
<span className="ml-auto text-xs text-green-500">🛹</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/create-spot" className="flex items-center gap-2 cursor-pointer">
|
||||
<Plus className="h-4 w-4 text-[#ccff00]" />
|
||||
<span className="text-[#ccff00] font-medium">Add Spot</span>
|
||||
<span className="text-[#ccff00] font-medium">{t('chromeUi.addSpot')}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={`/${nip19.npubEncode(pubkey)}`} className="flex items-center gap-2 cursor-pointer">
|
||||
<User className="h-4 w-4" />
|
||||
View Profile
|
||||
{t('nav.viewProfile')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/settings" className="flex items-center gap-2 cursor-pointer">
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
{t('nav.settings')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/settings?section=mydata" className="flex items-center gap-2 cursor-pointer">
|
||||
<Database className="h-4 w-4 text-[#ccff00]" />
|
||||
<span className="text-[#ccff00]">My Data</span>
|
||||
<span className="text-[#ccff00]">{t('chromeUi.myData')}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/stash" className="flex items-center gap-2 cursor-pointer">
|
||||
<Package className="h-4 w-4 text-purple-400" />
|
||||
<span className="text-purple-400">My Stash</span>
|
||||
<span className="text-purple-400">{t('chromeUi.myStash')}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/wallet" className="flex items-center gap-2 cursor-pointer">
|
||||
<Wallet className="h-4 w-4 text-orange-400" />
|
||||
<span className="text-orange-400">Wallet</span>
|
||||
<span className="text-orange-400">{t('nav.wallet')}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/leaderboards" className="flex items-center gap-2 cursor-pointer">
|
||||
<BarChart3 className="h-4 w-4 text-[#ccff00]" />
|
||||
<span className="text-[#ccff00]">Leaderboards</span>
|
||||
<span className="text-[#ccff00]">{t('nav.leaderboards')}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/marketplace" className="flex items-center gap-2 cursor-pointer">
|
||||
<Package className="h-4 w-4 text-orange-400" />
|
||||
<span className="text-orange-400">Marketplace</span>
|
||||
<span className="text-orange-400">{t('nav.marketplace')}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* ── Accounts: switch between logged-in npubs + add another ── */}
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-2 py-1 text-[10px] font-black uppercase tracking-widest text-gray-500">
|
||||
Accounts
|
||||
{t('chromeUi.accounts')}
|
||||
</div>
|
||||
{otherUsers.map((account) => {
|
||||
const name = account.metadata?.name ?? genUserName(account.pubkey);
|
||||
|
|
@ -310,7 +311,7 @@ function MobileHeaderUserMenu({
|
|||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Plus className="h-4 w-4 text-teal-400" />
|
||||
<span className="text-teal-400">Add another account</span>
|
||||
<span className="text-teal-400">{t('chromeUi.addAnotherAccount')}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
|
@ -319,7 +320,7 @@ function MobileHeaderUserMenu({
|
|||
className="flex items-center gap-2 cursor-pointer text-red-400"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Log Out
|
||||
{t('chromeUi.logOut')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
@ -493,7 +494,7 @@ function MobileNav({
|
|||
'block w-full truncate text-center text-[9px] xs:text-[10px] leading-tight tracking-wide uppercase transition-all',
|
||||
tabActive ? 'font-extrabold' : 'font-semibold',
|
||||
)}>
|
||||
{isLoggedIn ? 'Profile' : 'Log In'}
|
||||
{isLoggedIn ? t('chromeUi.profile') : t('chromeUi.logIn')}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
|
|
@ -542,7 +543,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
href="#main"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-3 focus:left-3 focus:z-[100] focus:rounded-lg focus:bg-[#ccff00] focus:px-4 focus:py-2 focus:text-sm focus:font-black focus:uppercase focus:tracking-wide focus:text-black focus:shadow-lg"
|
||||
>
|
||||
Skip to content
|
||||
{t('chromeUi.skipToContent')}
|
||||
</a>
|
||||
{/* Desktop Header */}
|
||||
<header className="hidden md:flex sticky top-0 z-50 border-b border-red-500/30 bg-black/95 backdrop-blur">
|
||||
|
|
@ -618,14 +619,14 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
<DropdownMenuItem asChild>
|
||||
<Link to="/skaters" className="flex items-center gap-2 cursor-pointer">
|
||||
<PersonStanding className="h-4 w-4" />
|
||||
Skaters
|
||||
{t('nav.skaters')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{user && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={`/${nip19.npubEncode(user.pubkey)}`} className="flex items-center gap-2 cursor-pointer">
|
||||
<User className="h-4 w-4" />
|
||||
View Profile
|
||||
{t('nav.viewProfile')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
|
@ -639,7 +640,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
<DropdownMenuItem asChild>
|
||||
<Link to="/glossary" className="flex items-center gap-2 cursor-pointer">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
Glossary
|
||||
{t('nav.glossary')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
|
|
@ -651,20 +652,20 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
<DropdownMenuItem asChild>
|
||||
<Link to="/card" className="flex items-center gap-2 cursor-pointer">
|
||||
<CreditCard className="h-4 w-4 text-[#ccff00]" />
|
||||
<span className="text-[#ccff00]">Rider Card</span>
|
||||
<span className="text-[#ccff00]">{t('nav.riderCard')}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/supporters" className="flex items-center gap-2 cursor-pointer">
|
||||
<Heart className="h-4 w-4 text-[#ff0040]" />
|
||||
<span className="text-[#ff0040]">Supporters ⚡</span>
|
||||
<span className="text-[#ff0040]">{t('nav.supporters')}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/newsletter" className="flex items-center gap-2 cursor-pointer">
|
||||
<Radio className="h-4 w-4 text-[#ccff00]" />
|
||||
<span className="text-[#ccff00]">Newsletter</span>
|
||||
<span className="text-[#ccff00]">{t('nav.newsletter')}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
|
@ -684,7 +685,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
? "text-[#ccff00] bg-[#ccff00]/10"
|
||||
: "text-gray-400 hover:text-[#ccff00] hover:bg-[#ccff00]/10"
|
||||
)}
|
||||
title="My Collections"
|
||||
title={t('chromeUi.myCollections')}
|
||||
>
|
||||
<Bookmark className="h-5 w-5" />
|
||||
</Button>
|
||||
|
|
@ -717,13 +718,13 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
variant="outline"
|
||||
className="gap-2 px-3 border-green-500/50 text-green-400 hover:text-green-300 hover:bg-green-500/10 hover:border-green-500"
|
||||
onClick={() => setShowStatusDialog(true)}
|
||||
title="Set Your Status"
|
||||
title={t('chromeUi.setYourStatus')}
|
||||
>
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
|
||||
</span>
|
||||
<span className="text-sm font-bold">Status</span>
|
||||
<span className="text-sm font-bold">{t('chromeUi.status')}</span>
|
||||
</Button>
|
||||
)}
|
||||
{/* Notifications (crew invites & membership requests) */}
|
||||
|
|
@ -739,14 +740,14 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-gray-400 hover:text-[#ff0040] hover:bg-[#ff0040]/10"
|
||||
title="View my profile"
|
||||
title={t('chromeUi.viewMyProfile')}
|
||||
>
|
||||
<User className="h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Link to="/settings">
|
||||
<Button variant="ghost" size="icon" title="Settings">
|
||||
<Button variant="ghost" size="icon" title={t('nav.settings')}>
|
||||
<Settings className="h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
|
@ -801,7 +802,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-80 overflow-y-auto">
|
||||
<SheetTitle className="sr-only">Menu</SheetTitle>
|
||||
<SheetTitle className="sr-only">{t('chromeUi.menu')}</SheetTitle>
|
||||
<div className="flex flex-col gap-6 mt-6 pb-8">
|
||||
{/* Wallet Card - Mobile Menu */}
|
||||
{user && (
|
||||
|
|
@ -823,7 +824,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-sm">{t('wallet.cashu.title')}</p>
|
||||
<p className="text-xs text-muted-foreground">Ecash</p>
|
||||
<p className="text-xs text-muted-foreground">{t('chromeUi.ecash')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{hasCashuWalletHint() && (
|
||||
|
|
@ -849,7 +850,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
))}
|
||||
<NavLink
|
||||
href="/skaters"
|
||||
label="Skaters"
|
||||
label={t('nav.skaters')}
|
||||
icon={PersonStanding}
|
||||
isActive={isActive('/skaters')}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
|
|
@ -873,7 +874,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
{user && (
|
||||
<NavLink
|
||||
href={`/${nip19.npubEncode(user.pubkey)}`}
|
||||
label="View Profile"
|
||||
label={t('nav.viewProfile')}
|
||||
icon={User}
|
||||
isActive={false}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
|
|
@ -882,7 +883,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
{user && (
|
||||
<NavLink
|
||||
href="/messages"
|
||||
label="Messages"
|
||||
label={t('nav.messages')}
|
||||
icon={MessageCircle}
|
||||
isActive={isActive('/messages')}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
|
|
@ -891,7 +892,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
{user && (
|
||||
<NavLink
|
||||
href="/contacts"
|
||||
label="Contacts"
|
||||
label={t('nav.contacts')}
|
||||
icon={Users}
|
||||
isActive={isActive('/contacts')}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
|
|
@ -900,7 +901,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
{user && (
|
||||
<NavLink
|
||||
href="/collections"
|
||||
label="My Collections"
|
||||
label={t('chromeUi.myCollections')}
|
||||
icon={Bookmark}
|
||||
isActive={isActive('/collections')}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
|
|
@ -909,7 +910,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
{user && (
|
||||
<NavLink
|
||||
href="/stash"
|
||||
label="My Stash"
|
||||
label={t('chromeUi.myStash')}
|
||||
icon={Package}
|
||||
isActive={isActive('/stash')}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
|
|
@ -924,7 +925,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
/>
|
||||
<NavLink
|
||||
href="/glossary"
|
||||
label="Glossary"
|
||||
label={t('nav.glossary')}
|
||||
icon={BookOpen}
|
||||
isActive={isActive('/glossary')}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
|
|
@ -938,21 +939,21 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
/>
|
||||
<NavLink
|
||||
href="/card"
|
||||
label="Rider Card"
|
||||
label={t('nav.riderCard')}
|
||||
icon={CreditCard}
|
||||
isActive={isActive('/card')}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
<NavLink
|
||||
href="/supporters"
|
||||
label="Supporters ⚡"
|
||||
label={t('nav.supporters')}
|
||||
icon={Heart}
|
||||
isActive={isActive('/supporters')}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
<NavLink
|
||||
href="/newsletter"
|
||||
label="Newsletter"
|
||||
label={t('nav.newsletter')}
|
||||
icon={Radio}
|
||||
isActive={isActive('/newsletter')}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
|
|
@ -974,7 +975,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
className="flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 text-muted-foreground hover:bg-muted hover:text-foreground text-left"
|
||||
>
|
||||
<SlidersHorizontal className="h-5 w-5" />
|
||||
<span className="font-medium">Customize nav bar</span>
|
||||
<span className="font-medium">{t('chromeUi.customizeNavBar')}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/* Language Toggle in Mobile Menu */}
|
||||
|
|
@ -998,7 +999,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
<p className="font-bold text-base truncate">{displayName}</p>
|
||||
<p className="text-sm text-green-500 flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
Logged In
|
||||
{t('chromeUi.loggedIn')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1015,7 +1016,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
}}
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
Log Out
|
||||
{t('chromeUi.logOut')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
|
|
@ -1023,9 +1024,9 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
{/* Not Logged In - Clear CTA */}
|
||||
<div className="p-4 rounded-xl bg-gradient-to-br from-[#ff0040]/10 to-[#ff0040]/5 border border-[#ff0040]/20 text-center">
|
||||
<User className="h-10 w-10 mx-auto mb-2 text-[#ff0040]/50" />
|
||||
<p className="font-bold text-base mb-1">Not Logged In</p>
|
||||
<p className="font-bold text-base mb-1">{t('chromeUi.notLoggedIn')}</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Log in to add spots, zap skaters, and save your favorites
|
||||
{t('chromeUi.loggedOutPrompt')}
|
||||
</p>
|
||||
<LoginArea className="w-full justify-center" />
|
||||
</div>
|
||||
|
|
@ -1055,9 +1056,9 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
<div className="container px-4">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-4 text-sm text-gray-400">
|
||||
<span className="flex items-center gap-2">
|
||||
Made with <span className="text-red-500">❤️</span> for the skateboarding community
|
||||
{t('chromeUi.madeWith')} <span className="text-red-500">❤️</span> {t('chromeUi.forSkateCommunity')}
|
||||
<span className="inline-flex items-center rounded-full border border-[#ff0040]/50 bg-[#ff0040]/10 px-2 py-0.5 text-[10px] font-black uppercase tracking-wider text-[#ff0040]">
|
||||
Beta
|
||||
{t('chromeUi.beta')}
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
|
|
@ -1065,28 +1066,28 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
to="/newsletter"
|
||||
className="hover:text-[#ccff00] transition-colors font-medium"
|
||||
>
|
||||
Newsletter
|
||||
{t('nav.newsletter')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/license"
|
||||
className="hover:text-[#ccff00] transition-colors font-medium"
|
||||
>
|
||||
License
|
||||
{t('chromeUi.license')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/privacy"
|
||||
className="hover:text-[#ccff00] transition-colors font-medium"
|
||||
>
|
||||
Privacy
|
||||
{t('chromeUi.privacy')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/supporters"
|
||||
className="hover:text-[#ccff00] transition-colors font-medium"
|
||||
>
|
||||
Collaborators & Friends
|
||||
{t('chromeUi.collaborators')}
|
||||
</Link>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span>Not sponsored by</span>
|
||||
<span>{t('chromeUi.notSponsoredBy')}</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<img
|
||||
src="https://opensats.org/favicon.ico"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ 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 { VisibilityPicker } from '@/components/ConfirmMembershipCard';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { MemberVisibility } from '@/lib/types';
|
||||
|
|
@ -33,6 +34,7 @@ export function MemberVisibilityControl({
|
|||
claimedMembers,
|
||||
accentBg = 'bg-teal-500 hover:bg-teal-600',
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { toast } = useToast();
|
||||
const { mutateAsync: republish, isPending } = useConfirmMembership();
|
||||
|
|
@ -64,16 +66,16 @@ export function MemberVisibilityControl({
|
|||
try {
|
||||
await republish({ kind, founderPubkey, identifier, collectiveName, visibility });
|
||||
toast({
|
||||
title: 'Visibility updated',
|
||||
title: t('socialUi.memberVisibility.updatedTitle'),
|
||||
description:
|
||||
visibility === 'crew'
|
||||
? 'You now appear only to crew-mates.'
|
||||
: 'You now appear publicly in this crew.',
|
||||
? t('socialUi.memberVisibility.updatedCrew')
|
||||
: t('socialUi.memberVisibility.updatedPublic'),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Could not update',
|
||||
description: error instanceof Error ? error.message : 'Please try again.',
|
||||
title: t('socialUi.memberVisibility.couldNotUpdate'),
|
||||
description: error instanceof Error ? error.message : t('common.tryAgain'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -89,7 +91,7 @@ export function MemberVisibilityControl({
|
|||
<Globe className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
<p className="text-xs font-black uppercase tracking-widest text-gray-400">
|
||||
Your visibility in this crew
|
||||
{t('socialUi.memberVisibility.title')}
|
||||
</p>
|
||||
</div>
|
||||
<VisibilityPicker value={visibility} onChange={setVisibility} accentBg={accentBg.split(' ')[0]} />
|
||||
|
|
@ -101,7 +103,7 @@ export function MemberVisibilityControl({
|
|||
className={cn('w-full gap-2 font-bold', accentBg)}
|
||||
>
|
||||
{isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
Save visibility
|
||||
{t('socialUi.memberVisibility.save')}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import {
|
|||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
// Kind constants
|
||||
const KINDS = {
|
||||
|
|
@ -122,6 +123,7 @@ function EventRow({
|
|||
event: NostrEvent;
|
||||
onDelete: (event: NostrEvent) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const config = KIND_CONFIG[event.kind] || { label: `Kind ${event.kind}`, icon: FileJson, color: 'text-gray-400', category: 'other' };
|
||||
const Icon = config.icon;
|
||||
|
||||
|
|
@ -130,7 +132,7 @@ function EventRow({
|
|||
// For reactions, show what was reacted to
|
||||
if (event.kind === KINDS.REACTION) {
|
||||
const emoji = event.content || '❤️';
|
||||
return `${emoji} reaction`;
|
||||
return t('settingsUi.myData.reactionTitle', { emoji });
|
||||
}
|
||||
|
||||
// Check for title tag
|
||||
|
|
@ -213,6 +215,7 @@ function SelectiveDeletionDialog({
|
|||
onDeleteSelected: (events: NostrEvent[]) => Promise<void>;
|
||||
isDeleting: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedEvents, setSelectedEvents] = useState<Set<string>>(new Set());
|
||||
const [filterKind, setFilterKind] = useState<number | 'all'>('all');
|
||||
|
|
@ -271,15 +274,15 @@ function SelectiveDeletionDialog({
|
|||
|
||||
// Available filter tabs
|
||||
const filterTabs: { kind: number | 'all'; label: string; icon: typeof MapPin; color: string }[] = [
|
||||
{ kind: 'all', label: 'All', icon: Database, color: 'text-white' },
|
||||
...(eventsByKind[KINDS.SPOT]?.length ? [{ kind: KINDS.SPOT, label: 'Spots', icon: MapPin, color: 'text-[#ccff00]' }] : []),
|
||||
...(eventsByKind[KINDS.EVENT]?.length ? [{ kind: KINDS.EVENT, label: 'Events', icon: Calendar, color: 'text-[#ff0040]' }] : []),
|
||||
...(eventsByKind[KINDS.SHOP]?.length ? [{ kind: KINDS.SHOP, label: 'Shops', icon: Store, color: 'text-purple-400' }] : []),
|
||||
...(eventsByKind[KINDS.LISTING]?.length ? [{ kind: KINDS.LISTING, label: 'Listings', icon: ShoppingBag, color: 'text-orange-400' }] : []),
|
||||
...(eventsByKind[KINDS.COLLECTION]?.length ? [{ kind: KINDS.COLLECTION, label: 'Collections', icon: FolderHeart, color: 'text-pink-400' }] : []),
|
||||
...((eventsByKind[KINDS.COACH]?.length || eventsByKind[KINDS.FILMER]?.length) ? [{ kind: KINDS.COACH, label: 'Lab', icon: GraduationCap, color: 'text-green-400' }] : []),
|
||||
...((eventsByKind[KINDS.POST]?.length || eventsByKind[KINDS.COMMENT]?.length) ? [{ kind: KINDS.POST, label: 'Posts', icon: MessageSquare, color: 'text-blue-400' }] : []),
|
||||
...(eventsByKind[KINDS.REACTION]?.length ? [{ kind: KINDS.REACTION, label: 'Reactions', icon: Heart, color: 'text-pink-400' }] : []),
|
||||
{ kind: 'all', label: t('settingsUi.myData.all'), icon: Database, color: 'text-white' },
|
||||
...(eventsByKind[KINDS.SPOT]?.length ? [{ kind: KINDS.SPOT, label: t('settingsUi.myData.spots'), icon: MapPin, color: 'text-[#ccff00]' }] : []),
|
||||
...(eventsByKind[KINDS.EVENT]?.length ? [{ kind: KINDS.EVENT, label: t('settingsUi.myData.events'), icon: Calendar, color: 'text-[#ff0040]' }] : []),
|
||||
...(eventsByKind[KINDS.SHOP]?.length ? [{ kind: KINDS.SHOP, label: t('settingsUi.myData.shops'), icon: Store, color: 'text-purple-400' }] : []),
|
||||
...(eventsByKind[KINDS.LISTING]?.length ? [{ kind: KINDS.LISTING, label: t('settingsUi.myData.listings'), icon: ShoppingBag, color: 'text-orange-400' }] : []),
|
||||
...(eventsByKind[KINDS.COLLECTION]?.length ? [{ kind: KINDS.COLLECTION, label: t('settingsUi.myData.collections'), icon: FolderHeart, color: 'text-pink-400' }] : []),
|
||||
...((eventsByKind[KINDS.COACH]?.length || eventsByKind[KINDS.FILMER]?.length) ? [{ kind: KINDS.COACH, label: t('settingsUi.myData.lab'), icon: GraduationCap, color: 'text-green-400' }] : []),
|
||||
...((eventsByKind[KINDS.POST]?.length || eventsByKind[KINDS.COMMENT]?.length) ? [{ kind: KINDS.POST, label: t('settingsUi.myData.posts'), icon: MessageSquare, color: 'text-blue-400' }] : []),
|
||||
...(eventsByKind[KINDS.REACTION]?.length ? [{ kind: KINDS.REACTION, label: t('settingsUi.myData.reactions'), icon: Heart, color: 'text-pink-400' }] : []),
|
||||
];
|
||||
|
||||
// Handle special filter cases (Lab = Coach + Filmer, Posts = Post + Comment)
|
||||
|
|
@ -301,17 +304,17 @@ function SelectiveDeletionDialog({
|
|||
className="flex-1 border-red-500/50 text-red-400 hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{isDeleting ? 'Deleting...' : 'Request Deletion'}
|
||||
{isDeleting ? t('settingsUi.myData.deleting') : t('settingsUi.myData.requestDeletion')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="bg-gray-900 border-gray-700 max-w-lg max-h-[85vh] flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2 text-white">
|
||||
<Trash2 className="h-5 w-5 text-red-400" />
|
||||
Select Content to Delete
|
||||
{t('settingsUi.myData.selectContentTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
Select the specific items you want to request deletion for.
|
||||
{t('settingsUi.myData.selectContentDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -351,13 +354,13 @@ function SelectiveDeletionDialog({
|
|||
<div className="flex items-center justify-between flex-shrink-0">
|
||||
<div className="flex gap-2 text-xs">
|
||||
<button onClick={selectAllVisible} className="text-[#ccff00] hover:underline">
|
||||
Select All {filterKind !== 'all' ? 'Visible' : ''}
|
||||
{filterKind !== 'all' ? t('settingsUi.myData.selectAllVisible') : t('settingsUi.myData.selectAll')}
|
||||
</button>
|
||||
<span className="text-gray-600">|</span>
|
||||
<button onClick={selectNone} className="text-gray-400 hover:underline">Clear</button>
|
||||
<button onClick={selectNone} className="text-gray-400 hover:underline">{t('common.clear')}</button>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
{selectedCount} selected
|
||||
{t('settingsUi.myData.selectedCount', { count: selectedCount })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -365,7 +368,7 @@ function SelectiveDeletionDialog({
|
|||
<div className="flex-1 overflow-y-auto space-y-1.5 pr-1 min-h-0">
|
||||
{displayEvents.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
No items in this category
|
||||
{t('settingsUi.myData.noItemsCategory')}
|
||||
</div>
|
||||
) : (
|
||||
displayEvents.map((event) => {
|
||||
|
|
@ -407,8 +410,7 @@ function SelectiveDeletionDialog({
|
|||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-yellow-200 text-xs">
|
||||
<strong>Important:</strong> Nostr is decentralized. Relays <em>should</em> honor deletion requests,
|
||||
but some may not. This is not a guaranteed permanent deletion.
|
||||
<strong>{t('settingsUi.myData.decentralizedStrong')}</strong> {t('settingsUi.myData.decentralizedNote')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -420,7 +422,7 @@ function SelectiveDeletionDialog({
|
|||
onClick={() => setOpen(false)}
|
||||
className="border-gray-700"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
|
|
@ -428,10 +430,10 @@ function SelectiveDeletionDialog({
|
|||
className="bg-red-500 hover:bg-red-600 text-white"
|
||||
>
|
||||
{isDeleting ? (
|
||||
'Deleting...'
|
||||
t('settingsUi.myData.deleting')
|
||||
) : (
|
||||
<>
|
||||
Delete {selectedCount} {selectedCount === 1 ? 'Item' : 'Items'}
|
||||
{t('settingsUi.myData.deleteCount', { count: selectedCount, s: selectedCount === 1 ? '' : 's' })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
|
@ -443,6 +445,7 @@ function SelectiveDeletionDialog({
|
|||
|
||||
export function MyDataTab() {
|
||||
const { user } = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
const { nostr } = useNostr();
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
|
@ -526,8 +529,8 @@ export function MyDataTab() {
|
|||
const handleExportData = () => {
|
||||
if (!userEvents || userEvents.length === 0) {
|
||||
toast({
|
||||
title: 'No data to export',
|
||||
description: 'You haven\'t published any events yet.',
|
||||
title: t('settingsUi.myData.noDataTitle'),
|
||||
description: t('settingsUi.myData.noDataDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -554,8 +557,8 @@ export function MyDataTab() {
|
|||
URL.revokeObjectURL(url);
|
||||
|
||||
toast({
|
||||
title: 'Data exported!',
|
||||
description: `Downloaded ${userEvents.length} events as JSON.`,
|
||||
title: t('settingsUi.myData.exportedTitle'),
|
||||
description: t('settingsUi.myData.exportedDesc', { count: userEvents.length }),
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -579,16 +582,16 @@ export function MyDataTab() {
|
|||
});
|
||||
|
||||
toast({
|
||||
title: 'Deletion requested',
|
||||
description: 'The deletion request has been published to relays.',
|
||||
title: t('settingsUi.myData.deletionRequestedTitle'),
|
||||
description: t('settingsUi.myData.deletionRequestedDesc'),
|
||||
});
|
||||
|
||||
refetch();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete event:', error);
|
||||
toast({
|
||||
title: 'Deletion failed',
|
||||
description: 'Could not publish deletion request. Please try again.',
|
||||
title: t('settingsUi.myData.deletionFailedTitle'),
|
||||
description: t('settingsUi.myData.deletionFailedDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -627,8 +630,8 @@ export function MyDataTab() {
|
|||
});
|
||||
|
||||
toast({
|
||||
title: 'Deletion requested',
|
||||
description: `Requested deletion of ${eventsToDelete.length} items. Note: Some relays may not honor this request.`,
|
||||
title: t('settingsUi.myData.deletionRequestedTitle'),
|
||||
description: t('settingsUi.myData.deletionRequestedMany', { count: eventsToDelete.length }),
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['my-data'] });
|
||||
|
|
@ -636,8 +639,8 @@ export function MyDataTab() {
|
|||
} catch (error) {
|
||||
console.error('Failed to delete events:', error);
|
||||
toast({
|
||||
title: 'Deletion failed',
|
||||
description: 'Could not publish deletion request. Please try again.',
|
||||
title: t('settingsUi.myData.deletionFailedTitle'),
|
||||
description: t('settingsUi.myData.deletionFailedDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -650,9 +653,9 @@ export function MyDataTab() {
|
|||
<Card className="border-dashed">
|
||||
<CardContent className="py-12 text-center">
|
||||
<Database className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
|
||||
<h3 className="text-lg font-medium mb-2">Login Required</h3>
|
||||
<h3 className="text-lg font-medium mb-2">{t('settingsUi.myData.loginRequired')}</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Log in to view and manage your data.
|
||||
{t('settingsUi.myData.loginRequiredDesc')}
|
||||
</p>
|
||||
<LoginArea className="justify-center" />
|
||||
</CardContent>
|
||||
|
|
@ -669,10 +672,10 @@ export function MyDataTab() {
|
|||
<CardHeader className="pb-4">
|
||||
<CardTitle className="flex items-center gap-2 text-white">
|
||||
<Database className="h-5 w-5 text-[#ccff00]" />
|
||||
My Data
|
||||
{t('settingsUi.myData.title')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-300">
|
||||
Your content on the Nostr network. You own this data - export it anytime or request deletion.
|
||||
{t('settingsUi.myData.desc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 sm:space-y-6">
|
||||
|
|
@ -680,17 +683,17 @@ export function MyDataTab() {
|
|||
<div className="space-y-2 sm:space-y-3">
|
||||
{/* Row 1: Main content */}
|
||||
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
|
||||
<StatCard icon={MapPin} label="Spots" count={stats.spots} color="text-[#ccff00]" isLoading={isLoading} />
|
||||
<StatCard icon={Calendar} label="Events" count={stats.events} color="text-[#ff0040]" isLoading={isLoading} />
|
||||
<StatCard icon={Store} label="Shops" count={stats.shops} color="text-purple-400" isLoading={isLoading} />
|
||||
<StatCard icon={ShoppingBag} label="Listings" count={stats.listings} color="text-orange-400" isLoading={isLoading} />
|
||||
<StatCard icon={MapPin} label={t('settingsUi.myData.spots')} count={stats.spots} color="text-[#ccff00]" isLoading={isLoading} />
|
||||
<StatCard icon={Calendar} label={t('settingsUi.myData.events')} count={stats.events} color="text-[#ff0040]" isLoading={isLoading} />
|
||||
<StatCard icon={Store} label={t('settingsUi.myData.shops')} count={stats.shops} color="text-purple-400" isLoading={isLoading} />
|
||||
<StatCard icon={ShoppingBag} label={t('settingsUi.myData.listings')} count={stats.listings} color="text-orange-400" isLoading={isLoading} />
|
||||
</div>
|
||||
{/* Row 2: Social & Lab */}
|
||||
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
|
||||
<StatCard icon={MessageSquare} label="Posts" count={stats.posts + stats.comments} color="text-blue-400" isLoading={isLoading} />
|
||||
<StatCard icon={GraduationCap} label="Lab" count={stats.labProfiles} color="text-green-400" isLoading={isLoading} subtitle="Coach/Filmer" />
|
||||
<StatCard icon={Heart} label="Reactions" count={stats.reactions} color="text-pink-400" isLoading={isLoading} />
|
||||
<StatCard icon={Zap} label="Zaps" count={stats.zapsReceived} color="text-yellow-400" isLoading={isLoading} subtitle="Received" />
|
||||
<StatCard icon={MessageSquare} label={t('settingsUi.myData.posts')} count={stats.posts + stats.comments} color="text-blue-400" isLoading={isLoading} />
|
||||
<StatCard icon={GraduationCap} label={t('settingsUi.myData.lab')} count={stats.labProfiles} color="text-green-400" isLoading={isLoading} subtitle={t('settingsUi.myData.coachFilmer')} />
|
||||
<StatCard icon={Heart} label={t('settingsUi.myData.reactions')} count={stats.reactions} color="text-pink-400" isLoading={isLoading} />
|
||||
<StatCard icon={Zap} label={t('settingsUi.myData.zaps')} count={stats.zapsReceived} color="text-yellow-400" isLoading={isLoading} subtitle={t('settingsUi.myData.received')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -702,7 +705,7 @@ export function MyDataTab() {
|
|||
className="flex-1 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export All Data
|
||||
{t('settingsUi.myData.exportAll')}
|
||||
</Button>
|
||||
|
||||
<SelectiveDeletionDialog
|
||||
|
|
@ -718,7 +721,7 @@ export function MyDataTab() {
|
|||
<Card className="bg-black/50 border-gray-800">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg text-white">Recent Activity</CardTitle>
|
||||
<CardTitle className="text-lg text-white">{t('settingsUi.myData.recentActivity')}</CardTitle>
|
||||
{userEvents && userEvents.length > 10 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -726,7 +729,7 @@ export function MyDataTab() {
|
|||
onClick={() => setShowAllActivity(!showAllActivity)}
|
||||
className="text-[#ccff00] hover:text-[#ccff00]/80"
|
||||
>
|
||||
{showAllActivity ? 'Show Less' : `View All (${userEvents.length})`}
|
||||
{showAllActivity ? t('common.showLess') : t('settingsUi.myData.viewAll', { count: userEvents.length })}
|
||||
<ExternalLink className="h-3 w-3 ml-1" />
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -742,9 +745,9 @@ export function MyDataTab() {
|
|||
) : !userEvents?.length ? (
|
||||
<div className="text-center py-8">
|
||||
<Database className="h-10 w-10 mx-auto mb-3 text-gray-600" />
|
||||
<p className="text-gray-400">No data published yet</p>
|
||||
<p className="text-gray-400">{t('settingsUi.myData.noDataPublished')}</p>
|
||||
<p className="text-gray-500 text-sm mt-1">
|
||||
Start by <Link to="/create-spot" className="text-[#ccff00] hover:underline">adding a spot</Link>!
|
||||
{t('settingsUi.myData.startBy')} <Link to="/create-spot" className="text-[#ccff00] hover:underline">{t('settingsUi.myData.addingASpot')}</Link>!
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -760,29 +763,28 @@ export function MyDataTab() {
|
|||
{/* Data Ownership Info */}
|
||||
<Card className="bg-blue-950/30 border-blue-500/30">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-blue-400 text-base">About Your Data</CardTitle>
|
||||
<CardTitle className="text-blue-400 text-base">{t('settingsUi.myData.aboutYourData')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-gray-300">
|
||||
<p>
|
||||
<strong className="text-white">You own your data.</strong> All content is signed with your cryptographic keys
|
||||
and stored on Nostr relays. No company controls it.
|
||||
<strong className="text-white">{t('settingsUi.myData.ownDataStrong')}</strong> {t('settingsUi.myData.ownData')}
|
||||
</p>
|
||||
<ul className="space-y-2 ml-4">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-[#ccff00]">✓</span>
|
||||
<span><strong>Export anytime</strong> - Download all your events as JSON</span>
|
||||
<span><strong>{t('settingsUi.myData.exportAnytimeStrong')}</strong> {t('settingsUi.myData.exportAnytime')}</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-[#ccff00]">✓</span>
|
||||
<span><strong>Portable</strong> - Use your keys in any Nostr app</span>
|
||||
<span><strong>{t('settingsUi.myData.portableStrong')}</strong> {t('settingsUi.myData.portable')}</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-[#ccff00]">✓</span>
|
||||
<span><strong>Selective deletion</strong> - Choose exactly what to delete</span>
|
||||
<span><strong>{t('settingsUi.myData.selectiveStrong')}</strong> {t('settingsUi.myData.selective')}</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-yellow-400">⚠</span>
|
||||
<span><strong>Decentralized caveat</strong> - Some relays may retain data despite deletion requests</span>
|
||||
<span><strong>{t('settingsUi.myData.caveatStrong')}</strong> {t('settingsUi.myData.caveat')}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { useAuthor } from '@/hooks/useAuthor';
|
|||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useCrewmates } from '@/hooks/useCrewmates';
|
||||
import { useFollowing } from '@/hooks/useFollowing';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MessageCircle, Search, Users, UserCheck, ArrowRight, CheckCircle2 } from 'lucide-react';
|
||||
|
|
@ -93,6 +94,7 @@ function PersonRow({
|
|||
* paste-only flow so starting a DM feels like a real contacts screen.
|
||||
*/
|
||||
export function NewMessageDialog({ open, onOpenChange }: NewMessageDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useCurrentUser();
|
||||
const { crewmates, isLoading: crewmatesLoading } = useCrewmates();
|
||||
|
|
@ -115,19 +117,19 @@ export function NewMessageDialog({ open, onOpenChange }: NewMessageDialogProps)
|
|||
group: 'crew',
|
||||
hint:
|
||||
cm.sharedCrews.length > 0
|
||||
? `In your crew${cm.sharedCrews.length > 1 ? 's' : ''}: ${cm.sharedCrews.join(', ')}`
|
||||
: 'Crew-mate',
|
||||
? t('socialUi.newMessage.inCrew', { crews: cm.sharedCrews.join(', ') })
|
||||
: t('socialUi.newMessage.crewmate'),
|
||||
});
|
||||
}
|
||||
|
||||
for (const pk of following ?? []) {
|
||||
if (pk === user?.pubkey || seen.has(pk)) continue;
|
||||
seen.add(pk);
|
||||
list.push({ pubkey: pk, group: 'following', hint: 'Following' });
|
||||
list.push({ pubkey: pk, group: 'following', hint: t('socialUi.newMessage.following') });
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [crewmates, following, user?.pubkey]);
|
||||
}, [crewmates, following, user?.pubkey, t]);
|
||||
|
||||
const crewCandidates = candidates.filter((c) => c.group === 'crew');
|
||||
const followingCandidates = candidates.filter((c) => c.group === 'following');
|
||||
|
|
@ -179,7 +181,7 @@ export function NewMessageDialog({ open, onOpenChange }: NewMessageDialogProps)
|
|||
}
|
||||
goToConversation(pubkey);
|
||||
} catch {
|
||||
setRawError('That doesn\u2019t look like a valid npub or public key.');
|
||||
setRawError(t('socialUi.newMessage.invalidKey'));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -192,10 +194,10 @@ export function NewMessageDialog({ open, onOpenChange }: NewMessageDialogProps)
|
|||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-white">
|
||||
<MessageCircle className="h-5 w-5 text-[#ff0040]" />
|
||||
New Message
|
||||
{t('socialUi.dms.newMessage')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-gray-400 text-sm">
|
||||
Search your people by name, or paste an npub to message anyone. Select two or more to start a group.
|
||||
{t('socialUi.newMessage.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -208,7 +210,7 @@ export function NewMessageDialog({ open, onOpenChange }: NewMessageDialogProps)
|
|||
setQuery(e.target.value);
|
||||
setRawError('');
|
||||
}}
|
||||
placeholder="Search name or paste npub1..."
|
||||
placeholder={t('socialUi.newMessage.searchPlaceholder')}
|
||||
className="bg-gray-900 border-gray-700 text-white pl-9 h-11"
|
||||
autoFocus
|
||||
/>
|
||||
|
|
@ -220,7 +222,7 @@ export function NewMessageDialog({ open, onOpenChange }: NewMessageDialogProps)
|
|||
onClick={handleOpenRaw}
|
||||
className="w-full bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold h-11"
|
||||
>
|
||||
Message this key
|
||||
{t('socialUi.newMessage.messageThisKey')}
|
||||
<ArrowRight className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -231,16 +233,16 @@ export function NewMessageDialog({ open, onOpenChange }: NewMessageDialogProps)
|
|||
<ScrollArea className="h-[min(50vh,360px)] pr-2">
|
||||
{isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-gray-500">
|
||||
Loading your people...
|
||||
{t('socialUi.newMessage.loadingPeople')}
|
||||
</div>
|
||||
) : !hasAnyCandidates ? (
|
||||
<div className="py-10 text-center px-4">
|
||||
<Users className="h-10 w-10 mx-auto mb-3 text-gray-600" />
|
||||
<p className="text-sm text-gray-400">
|
||||
No crew-mates or follows yet.
|
||||
{t('socialUi.newMessage.noPeople')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
Paste someone's npub above to start a conversation.
|
||||
{t('socialUi.newMessage.noPeopleHint')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -248,7 +250,7 @@ export function NewMessageDialog({ open, onOpenChange }: NewMessageDialogProps)
|
|||
{crewCandidates.length > 0 && (
|
||||
<div>
|
||||
<p className="flex items-center gap-1.5 px-1 pb-1.5 text-[11px] font-bold uppercase tracking-wide text-teal-400">
|
||||
<Users className="h-3.5 w-3.5" /> Your crews
|
||||
<Users className="h-3.5 w-3.5" /> {t('socialUi.newMessage.yourCrews')}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{crewCandidates.map((c) => (
|
||||
|
|
@ -266,7 +268,7 @@ export function NewMessageDialog({ open, onOpenChange }: NewMessageDialogProps)
|
|||
{followingCandidates.length > 0 && (
|
||||
<div>
|
||||
<p className="flex items-center gap-1.5 px-1 pb-1.5 text-[11px] font-bold uppercase tracking-wide text-gray-400">
|
||||
<UserCheck className="h-3.5 w-3.5" /> Following
|
||||
<UserCheck className="h-3.5 w-3.5" /> {t('socialUi.newMessage.following')}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{followingCandidates.map((c) => (
|
||||
|
|
@ -288,12 +290,12 @@ export function NewMessageDialog({ open, onOpenChange }: NewMessageDialogProps)
|
|||
|
||||
{selected.length > 0 && (
|
||||
<div className="flex items-center gap-2 border-t border-white/5 pt-3">
|
||||
<span className="text-xs text-gray-400">{selected.length} selected</span>
|
||||
<span className="text-xs text-gray-400">{t('socialUi.newMessage.selected', { count: selected.length })}</span>
|
||||
<Button
|
||||
onClick={startChat}
|
||||
className="ml-auto h-10 bg-[#ff0040] font-bold text-white hover:bg-[#ff0040]/90"
|
||||
>
|
||||
{selected.length > 1 ? `Start group (${selected.length})` : 'Message'}
|
||||
{selected.length > 1 ? t('socialUi.newMessage.startGroup', { count: selected.length }) : t('socialUi.newMessage.message')}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
import { useNewsletterIssues } from '@/hooks/useNewsletterIssues';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { isAdminPubkey } from '@/lib/admins';
|
||||
import { buildIssueArticle, issueSlug, type StaticIssueLike } from '@/lib/newsletterArticle';
|
||||
|
||||
|
|
@ -18,6 +19,7 @@ import { buildIssueArticle, issueSlug, type StaticIssueLike } from '@/lib/newsle
|
|||
* untouched — this just mirrors the archive onto Nostr for subscribers/readers.
|
||||
*/
|
||||
export function NewsletterBackfillPanel({ issues }: { issues?: StaticIssueLike[] }) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
const { getWriteRelays } = useSkateRelays();
|
||||
|
|
@ -42,7 +44,7 @@ export function NewsletterBackfillPanel({ issues }: { issues?: StaticIssueLike[]
|
|||
const ordered = [...missing].sort((a, b) => a.number - b.number);
|
||||
for (const issue of ordered) {
|
||||
try {
|
||||
if (!user) throw new Error('Not logged in');
|
||||
if (!user) throw new Error(t('walletUi.newsletter.notLoggedIn'));
|
||||
const template = buildIssueArticle(issue);
|
||||
const event = await user.signer.signEvent({
|
||||
kind: template.kind,
|
||||
|
|
@ -69,13 +71,13 @@ export function NewsletterBackfillPanel({ issues }: { issues?: StaticIssueLike[]
|
|||
|
||||
if (failures.length === 0) {
|
||||
toast({
|
||||
title: `Backfilled ${published} issue${published === 1 ? '' : 's'} 🛹`,
|
||||
description: 'The archive now lives on Nostr for subscribers and readers.',
|
||||
title: t('walletUi.newsletter.backfilled', { count: published }),
|
||||
description: t('walletUi.newsletter.backfilledDesc'),
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: `Published ${published}, ${failures.length} failed`,
|
||||
description: `Retry to finish: #${failures.map((n) => String(n).padStart(3, '0')).join(', #')}.`,
|
||||
title: t('walletUi.newsletter.partialBackfill', { published, failed: failures.length }),
|
||||
description: t('walletUi.newsletter.retryFinish', { list: failures.map((n) => `#${String(n).padStart(3, '0')}`).join(', ') }),
|
||||
variant: 'destructive',
|
||||
});
|
||||
setDone(false);
|
||||
|
|
@ -85,7 +87,7 @@ export function NewsletterBackfillPanel({ issues }: { issues?: StaticIssueLike[]
|
|||
if (missing.length === 0) {
|
||||
return (
|
||||
<p className="mt-1 flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-green-500/70">
|
||||
<Check className="h-3 w-3" /> Archive synced to Nostr
|
||||
<Check className="h-3 w-3" /> {t('walletUi.newsletter.archiveSynced')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
|
@ -96,12 +98,10 @@ export function NewsletterBackfillPanel({ issues }: { issues?: StaticIssueLike[]
|
|||
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-yellow-500" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-bold text-yellow-200">
|
||||
{missing.length} archive issue{missing.length === 1 ? '' : 's'} not on Nostr yet
|
||||
{t('walletUi.newsletter.missingCount', { count: missing.length })}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[11px] leading-snug text-gray-400">
|
||||
Publish {missing.map((i) => `#${String(i.number).padStart(3, '0')}`).join(', ')} as
|
||||
NIP-23 articles so subscribers and long-form readers can see the full archive. The page
|
||||
keeps its rich layout regardless.
|
||||
{t('walletUi.newsletter.backfillDesc', { list: missing.map((i) => `#${String(i.number).padStart(3, '0')}`).join(', ') })}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -116,7 +116,7 @@ export function NewsletterBackfillPanel({ issues }: { issues?: StaticIssueLike[]
|
|||
) : (
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{running ? 'Publishing…' : done ? 'Done' : `Backfill ${missing.length} to Nostr`}
|
||||
{running ? t('walletUi.newsletter.publishing') : done ? t('common.done') : t('walletUi.newsletter.backfill', { count: missing.length })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { Label } from '@/components/ui/label';
|
|||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { isAdminPubkey } from '@/lib/admins';
|
||||
import { NEWSLETTER_KIND, NEWSLETTER_TAG } from '@/lib/types';
|
||||
|
||||
|
|
@ -35,6 +36,7 @@ function slugify(input: string): string {
|
|||
* their notification bell's "News" tab and it appears on the /newsletter page.
|
||||
*/
|
||||
export function NewsletterComposer() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
const { getWriteRelays } = useSkateRelays();
|
||||
|
|
@ -98,11 +100,11 @@ export function NewsletterComposer() {
|
|||
),
|
||||
);
|
||||
if (results.every((r) => r.status === 'rejected')) {
|
||||
throw new Error('Could not reach any relay — check your connection and try again.');
|
||||
throw new Error(t('walletUi.newsletter.couldNotReachRelay'));
|
||||
}
|
||||
toast({
|
||||
title: 'Issue published! 🛹',
|
||||
description: 'Subscribers will see it in their notification bell.',
|
||||
title: t('walletUi.newsletter.issuePublished'),
|
||||
description: t('walletUi.newsletter.issuePublishedDesc'),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['newsletter-issues'] });
|
||||
setOpen(false);
|
||||
|
|
@ -113,8 +115,8 @@ export function NewsletterComposer() {
|
|||
setBody('');
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Publish failed',
|
||||
description: err instanceof Error ? err.message : 'Please try again.',
|
||||
title: t('walletUi.newsletter.publishFailed'),
|
||||
description: err instanceof Error ? err.message : t('walletUi.newsletter.tryAgain'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -129,23 +131,22 @@ export function NewsletterComposer() {
|
|||
variant="outline"
|
||||
className="gap-2 border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10"
|
||||
>
|
||||
<PenLine className="h-4 w-4" /> New issue
|
||||
<PenLine className="h-4 w-4" /> {t('walletUi.newsletter.newIssue')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg w-[calc(100vw-2rem)] bg-black border-2 border-[#ccff00]/30">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-white">
|
||||
<Sparkles className="h-4 w-4 text-[#ccff00]" /> Publish a newsletter issue
|
||||
<Sparkles className="h-4 w-4 text-[#ccff00]" /> {t('walletUi.newsletter.composerTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
Published as a NIP-23 article (kind 30023) on the community relay. Subscribers get it in
|
||||
their notification bell. Body supports Markdown.
|
||||
{t('walletUi.newsletter.composerDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs text-gray-400">Title</Label>
|
||||
<Label className="text-xs text-gray-400">{t('walletUi.newsletter.fieldTitle')}</Label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
|
|
@ -157,7 +158,7 @@ export function NewsletterComposer() {
|
|||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-400">
|
||||
Identifier (slug) <span className="text-gray-600">· leave blank to auto-generate</span>
|
||||
{t('walletUi.newsletter.identifier')} <span className="text-gray-600">· {t('walletUi.newsletter.leaveBlank')}</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={slug}
|
||||
|
|
@ -167,12 +168,12 @@ export function NewsletterComposer() {
|
|||
className="mt-1 font-mono text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-gray-600">
|
||||
Re-using an existing slug edits that issue instead of creating a new one.
|
||||
{t('walletUi.newsletter.reusingSlug')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-400">Summary <span className="text-gray-600">· optional</span></Label>
|
||||
<Label className="text-xs text-gray-400">{t('walletUi.newsletter.summary')} <span className="text-gray-600">· {t('walletUi.newsletter.optional')}</span></Label>
|
||||
<Input
|
||||
value={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
|
|
@ -183,7 +184,7 @@ export function NewsletterComposer() {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-400">Cover image URL <span className="text-gray-600">· optional</span></Label>
|
||||
<Label className="text-xs text-gray-400">{t('walletUi.newsletter.coverImage')} <span className="text-gray-600">· {t('walletUi.newsletter.optional')}</span></Label>
|
||||
<Input
|
||||
value={image}
|
||||
onChange={(e) => setImage(e.target.value)}
|
||||
|
|
@ -193,14 +194,14 @@ export function NewsletterComposer() {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-400">Body (Markdown)</Label>
|
||||
<Label className="text-xs text-gray-400">{t('walletUi.newsletter.bodyMarkdown')}</Label>
|
||||
<Textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder={'## What shipped\n\n- New thing one\n- New thing two\n\nRead more at **skate-spots.org**.'}
|
||||
className="mt-1 min-h-[200px] font-mono text-sm leading-relaxed"
|
||||
/>
|
||||
<p className="mt-1 text-right text-[10px] text-gray-600">{body.length} characters</p>
|
||||
<p className="mt-1 text-right text-[10px] text-gray-600">{t('walletUi.newsletter.characters', { count: body.length })}</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
|
|
@ -209,7 +210,7 @@ export function NewsletterComposer() {
|
|||
className="w-full gap-2 bg-[#ccff00] font-black uppercase text-black hover:bg-[#ccff00]/90"
|
||||
>
|
||||
{isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||
Publish issue
|
||||
{t('walletUi.newsletter.publishIssue')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
|||
import { ZapButton } from '@/components/ZapButton';
|
||||
import { NewsletterSubscribeButton } from '@/components/NewsletterSubscribeButton';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { NEWSLETTER_KIND, parseNewsletterIssue, type SkateNewsletterIssue } from '@/lib/types';
|
||||
|
|
@ -100,6 +101,7 @@ function renderMarkdown(md: string) {
|
|||
}
|
||||
|
||||
export function NewsletterIssueView({ pubkey, identifier }: { pubkey: string; identifier: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
const author = useAuthor(pubkey);
|
||||
|
||||
|
|
@ -163,11 +165,11 @@ export function NewsletterIssueView({ pubkey, identifier }: { pubkey: string; id
|
|||
to="/newsletter"
|
||||
className="mb-6 inline-flex items-center gap-2 text-sm text-gray-400 transition-colors hover:text-[#ccff00]"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" /> All issues
|
||||
<ArrowLeft className="h-4 w-4" /> {t('walletUi.newsletter.allIssues')}
|
||||
</Link>
|
||||
|
||||
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-[#ccff00]/30 bg-[#ccff00]/5 px-3 py-1.5 text-[10px] font-black uppercase tracking-widest text-[#ccff00]">
|
||||
<Radio className="h-3 w-3" /> SkateSpots Newsletter
|
||||
<Radio className="h-3 w-3" /> {t('walletUi.newsletter.brand')}
|
||||
</div>
|
||||
|
||||
{issue.image && (
|
||||
|
|
@ -203,7 +205,7 @@ export function NewsletterIssueView({ pubkey, identifier }: { pubkey: string; id
|
|||
|
||||
{/* Footer CTA: zap the issue + subscribe */}
|
||||
<div className="mt-10 flex flex-col items-center gap-4 rounded-2xl border-2 border-[#ccff00]/20 bg-white/[0.03] p-6 text-center">
|
||||
<p className="text-sm font-bold text-white">Dig the drop? Zap it and never miss the next one.</p>
|
||||
<p className="text-sm font-bold text-white">{t('walletUi.newsletter.digTheDrop')}</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<ZapButton target={issue.event} className="bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90" />
|
||||
<NewsletterSubscribeButton />
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { X, Radio, Pencil } from 'lucide-react';
|
|||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
import { NEWSLETTER_ISSUES } from '@/lib/newsletterIssues';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { isAdminPubkey } from '@/lib/admins';
|
||||
|
||||
/**
|
||||
|
|
@ -41,6 +42,7 @@ function formatIssueNumber(n: number): string {
|
|||
// ─── Floating "Latest Drop" pill ─────────────────────────────────────────────
|
||||
|
||||
function LatestDropPill() {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const latest = NEWSLETTER_ISSUES[0];
|
||||
const [dismissed, setDismissed] = useLocalStorage<number>(
|
||||
|
|
@ -70,16 +72,16 @@ function LatestDropPill() {
|
|||
>
|
||||
<Radio className="h-3.5 w-3.5 text-[#ccff00] animate-pulse flex-shrink-0" />
|
||||
<span className="text-xs font-black text-[#ccff00] uppercase tracking-wide whitespace-nowrap">
|
||||
Drop {formatIssueNumber(latest.number)} is out
|
||||
{t('walletUi.newsletter.dropIsOut', { number: formatIssueNumber(latest.number) })}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-500 hidden sm:inline">
|
||||
· {age === 0 ? 'Today' : age === 1 ? 'Yesterday' : `${age}d ago`}
|
||||
· {age === 0 ? t('time.today') : age === 1 ? t('time.yesterday') : t('walletUi.newsletter.daysAgo', { count: age })}
|
||||
</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setDismissed(latest.number)}
|
||||
className="flex items-center justify-center w-7 h-7 flex-shrink-0 rounded-full bg-black/98 border border-white/10 text-gray-500 hover:text-white hover:border-white/30 transition-colors touch-manipulation active:scale-95"
|
||||
aria-label="Dismiss"
|
||||
aria-label={t('walletUi.newsletter.dismiss')}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
|
|
@ -90,6 +92,7 @@ function LatestDropPill() {
|
|||
// ─── Monday admin reminder ────────────────────────────────────────────────────
|
||||
|
||||
function MondayAdminReminder() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const location = useLocation();
|
||||
const [dismissed, setDismissed] = useLocalStorage<string>(
|
||||
|
|
@ -121,10 +124,10 @@ function MondayAdminReminder() {
|
|||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[#ccff00] font-black text-xs uppercase tracking-wide mb-1">
|
||||
It's Monday
|
||||
{t('walletUi.newsletter.itsMonday')}
|
||||
</p>
|
||||
<p className="text-gray-300 text-xs leading-relaxed mb-3">
|
||||
Time to drop the weekly newsletter. One click and the AI writes it from the git log.
|
||||
{t('walletUi.newsletter.mondayDesc')}
|
||||
</p>
|
||||
<a
|
||||
href={WRITE_NEWSLETTER_URL}
|
||||
|
|
@ -134,13 +137,13 @@ function MondayAdminReminder() {
|
|||
onClick={() => setDismissed(todayKey)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Write Drop #{String(NEWSLETTER_ISSUES[0].number + 1).padStart(3, '0')}
|
||||
{t('walletUi.newsletter.writeDrop', { number: `#${String(NEWSLETTER_ISSUES[0].number + 1).padStart(3, '0')}` })}
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setDismissed(todayKey)}
|
||||
className="flex-shrink-0 text-gray-600 hover:text-gray-400 transition-colors"
|
||||
aria-label="Dismiss reminder"
|
||||
aria-label={t('walletUi.newsletter.dismissReminder')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { LoginArea } from '@/components/auth/LoginArea';
|
|||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNewsletterSubscription } from '@/hooks/useNewsletterSubscription';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
|
|
@ -13,6 +14,7 @@ import { cn } from '@/lib/utils';
|
|||
* login prompt instead.
|
||||
*/
|
||||
export function NewsletterSubscribeButton({ className }: { className?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { isSubscribed, isLoading, setSubscribed, isUpdating } = useNewsletterSubscription();
|
||||
const { toast } = useToast();
|
||||
|
|
@ -21,7 +23,7 @@ export function NewsletterSubscribeButton({ className }: { className?: string })
|
|||
if (!user) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center gap-2', className)}>
|
||||
<p className="text-xs text-gray-500">Log in with Nostr to get new drops in your mailbox.</p>
|
||||
<p className="text-xs text-gray-500">{t('walletUi.newsletter.loginPrompt')}</p>
|
||||
<LoginArea className="max-w-60" />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -35,19 +37,19 @@ export function NewsletterSubscribeButton({ className }: { className?: string })
|
|||
setJustSubscribed(true);
|
||||
setTimeout(() => setJustSubscribed(false), 2500);
|
||||
toast({
|
||||
title: "You're subscribed! 🛹",
|
||||
description: 'New drops will land in your notification bell under "News".',
|
||||
title: t('walletUi.newsletter.subscribedToast'),
|
||||
description: t('walletUi.newsletter.subscribedToastDesc'),
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'Unsubscribed',
|
||||
description: 'You can still read every issue here anytime.',
|
||||
title: t('walletUi.newsletter.unsubscribed'),
|
||||
description: t('walletUi.newsletter.unsubscribedDesc'),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: err instanceof Error ? err.message : 'Please try again.',
|
||||
title: t('walletUi.addToCollection.somethingWrong'),
|
||||
description: err instanceof Error ? err.message : t('walletUi.newsletter.tryAgain'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -56,7 +58,7 @@ export function NewsletterSubscribeButton({ className }: { className?: string })
|
|||
if (isLoading) {
|
||||
return (
|
||||
<Button disabled className={cn('gap-2 h-11', className)}>
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Checking…
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> {t('walletUi.newsletter.checking')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -82,8 +84,8 @@ export function NewsletterSubscribeButton({ className }: { className?: string })
|
|||
<BellOff className="hidden h-4 w-4 group-hover:block" />
|
||||
</>
|
||||
)}
|
||||
<span className="group-hover:hidden">Subscribed</span>
|
||||
<span className="hidden group-hover:inline">Unsubscribe</span>
|
||||
<span className="group-hover:hidden">{t('walletUi.newsletter.subscribed')}</span>
|
||||
<span className="hidden group-hover:inline">{t('walletUi.newsletter.unsubscribe')}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -98,7 +100,7 @@ export function NewsletterSubscribeButton({ className }: { className?: string })
|
|||
)}
|
||||
>
|
||||
{isUpdating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Bell className="h-4 w-4" />}
|
||||
Subscribe to the newsletter
|
||||
{t('walletUi.newsletter.subscribeCta')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Card, CardContent } from '@/components/ui/card';
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { NIP05_PROVIDERS } from '@/lib/nip05Providers';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
interface Nip05RequiredProps {
|
||||
/** What the user is trying to do, e.g. "tour organizers", "sellers". */
|
||||
|
|
@ -29,10 +30,11 @@ export function Nip05Required({
|
|||
color = '#ef4444',
|
||||
className,
|
||||
}: Nip05RequiredProps) {
|
||||
const { t } = useTranslation();
|
||||
const providerList = (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-black uppercase tracking-widest text-gray-400">
|
||||
Get a free / cheap NIP-05
|
||||
{t('settingsUi.nip05Required.getFree')}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{NIP05_PROVIDERS.map((p) => (
|
||||
|
|
@ -58,7 +60,7 @@ export function Nip05Required({
|
|||
: 'border-yellow-500/30 bg-yellow-500/15 text-yellow-400'
|
||||
)}
|
||||
>
|
||||
{p.free ? 'Free' : 'Paid'}
|
||||
{p.free ? t('common.free') : t('settingsUi.nip05Required.paid')}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
|
|
@ -77,11 +79,11 @@ export function Nip05Required({
|
|||
<AlertTriangle className="h-5 w-5 flex-shrink-0 mt-0.5" style={{ color }} />
|
||||
<div>
|
||||
<h3 className="font-bold mb-1" style={{ color }}>
|
||||
NIP-05 Verification Required
|
||||
{t('settingsUi.nip05Required.requiredTitle')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-300">
|
||||
{reason ??
|
||||
`To prevent spam and build trust, ${role} need a verified Nostr address (NIP-05).`}
|
||||
t('settingsUi.nip05Required.bannerReason', { role })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -93,7 +95,7 @@ export function Nip05Required({
|
|||
className="font-bold"
|
||||
style={{ borderColor: `${color}55`, color }}
|
||||
>
|
||||
Add NIP-05 in Settings
|
||||
{t('settingsUi.nip05Required.addInSettings')}
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
|
|
@ -114,14 +116,14 @@ export function Nip05Required({
|
|||
<ShieldCheck className="h-8 w-8" style={{ color }} />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold mb-2" style={{ color }}>
|
||||
NIP-05 Verification Required
|
||||
{t('settingsUi.nip05Required.requiredTitle')}
|
||||
</h2>
|
||||
<p className="text-gray-300 mb-2">
|
||||
{reason ??
|
||||
`To prevent spam and build trust, ${role} must have NIP-05 verification.`}
|
||||
t('settingsUi.nip05Required.gateReason', { role })}
|
||||
</p>
|
||||
<p className="text-gray-500 text-sm mb-6">
|
||||
NIP-05 proves your identity by linking your Nostr profile to a domain — like{' '}
|
||||
{t('settingsUi.nip05Required.explainer')}{' '}
|
||||
<span className="text-gray-300">you@example.com</span>.
|
||||
</p>
|
||||
|
||||
|
|
@ -129,7 +131,7 @@ export function Nip05Required({
|
|||
|
||||
<Link to="/settings?section=nip05">
|
||||
<Button className="bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold">
|
||||
Update Your Profile
|
||||
{t('settingsUi.nip05Required.updateProfile')}
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { useNewsletterRead } from '@/hooks/useNewsletterRead';
|
|||
import { useNewsletterSubscription } from '@/hooks/useNewsletterSubscription';
|
||||
import { useConfirmMembership } from '@/hooks/useMembershipAttestation';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { NEWSLETTER_KIND, SKATE_CREW_KIND, type MemberVisibility, type SkateCrew, type SkateNewsletterIssue } from '@/lib/types';
|
||||
|
|
@ -34,6 +35,7 @@ import { cn } from '@/lib/utils';
|
|||
* can confirm (with a visibility choice) right from the bell.
|
||||
*/
|
||||
function PendingConfirmCard({ crew, onResolved }: { crew: SkateCrew; onResolved?: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const isPending = useIsPendingMember(crew);
|
||||
const founder = useAuthor(crew.pubkey);
|
||||
const founderName = founder.data?.metadata?.name ?? genUserName(crew.pubkey);
|
||||
|
|
@ -56,12 +58,12 @@ function PendingConfirmCard({ crew, onResolved }: { crew: SkateCrew; onResolved?
|
|||
visibility,
|
||||
});
|
||||
setDone(true);
|
||||
toast({ title: `You're in ${crew.name}! 🛹`, description: 'Membership confirmed.' });
|
||||
toast({ title: t('socialUi.bell.youAreIn', { crew: crew.name }), description: t('socialUi.bell.membershipConfirmed') });
|
||||
onResolved?.();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Could not confirm',
|
||||
description: err instanceof Error ? err.message : 'Please try again.',
|
||||
title: t('socialUi.bell.couldNotConfirm'),
|
||||
description: err instanceof Error ? err.message : t('common.tryAgain'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -82,18 +84,18 @@ function PendingConfirmCard({ crew, onResolved }: { crew: SkateCrew; onResolved?
|
|||
</Avatar>
|
||||
{founderName}
|
||||
</span>{' '}
|
||||
added you to{' '}
|
||||
{t('socialUi.bell.addedYouTo')}{' '}
|
||||
<Link to={`/crew/${crew.pubkey}/${crew.identifier}`} className="font-black text-teal-300 hover:underline">
|
||||
{crew.name}
|
||||
</Link>
|
||||
. Confirm to appear as a member.
|
||||
. {t('socialUi.bell.confirmToAppear')}
|
||||
</p>
|
||||
|
||||
{/* Visibility toggle */}
|
||||
<div className="mt-2 grid grid-cols-2 gap-1.5">
|
||||
{([
|
||||
{ key: 'public', label: 'Public', icon: Globe },
|
||||
{ key: 'crew', label: 'Crew only', icon: EyeOff },
|
||||
{ key: 'public', label: t('socialUi.confirmMembership.public'), icon: Globe },
|
||||
{ key: 'crew', label: t('socialUi.confirmMembership.crewOnly'), icon: EyeOff },
|
||||
] as const).map(({ key, label, icon: Icon }) => {
|
||||
const active = visibility === key;
|
||||
return (
|
||||
|
|
@ -122,7 +124,7 @@ function PendingConfirmCard({ crew, onResolved }: { crew: SkateCrew; onResolved?
|
|||
className="mt-2.5 h-8 w-full gap-1.5 bg-teal-500 font-bold text-black hover:bg-teal-600"
|
||||
>
|
||||
{confirming ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
|
||||
Confirm membership
|
||||
{t('socialUi.confirmMembership.confirmButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -140,6 +142,7 @@ function NewsletterItem({
|
|||
unread: boolean;
|
||||
onNavigate: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: NEWSLETTER_KIND,
|
||||
pubkey: issue.pubkey,
|
||||
|
|
@ -174,7 +177,7 @@ function NewsletterItem({
|
|||
</div>
|
||||
{issue.summary && <p className="line-clamp-2 text-xs text-gray-400">{issue.summary}</p>}
|
||||
<p className="mt-1 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-gray-600">
|
||||
{date} · Read <ArrowRight className="h-2.5 w-2.5" />
|
||||
{date} · {t('socialUi.bell.read')} <ArrowRight className="h-2.5 w-2.5" />
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
|
@ -182,10 +185,11 @@ function NewsletterItem({
|
|||
}
|
||||
|
||||
function EmptyState({ label }: { label: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SharedEmptyState
|
||||
icon={Bell}
|
||||
title="You're all caught up"
|
||||
title={t('socialUi.bell.allCaughtUp')}
|
||||
hint={label}
|
||||
className="py-10"
|
||||
/>
|
||||
|
|
@ -203,6 +207,7 @@ function EmptyState({ label }: { label: string }) {
|
|||
* bell (or hitting "mark all read") clears the badge. Only renders when logged in.
|
||||
*/
|
||||
export function NotificationBell({ className }: { className?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { user, invites, listedCrews, joinRequests, collectiveRequests, isLoading: crewLoading } =
|
||||
useCrewNotifications();
|
||||
|
|
@ -286,8 +291,8 @@ export function NotificationBell({ className }: { className?: string }) {
|
|||
badgeTotal > 0 && 'text-[#ccff00]',
|
||||
className,
|
||||
)}
|
||||
title="Notifications"
|
||||
aria-label={`Notifications${badgeTotal > 0 ? ` (${badgeTotal} new)` : ''}`}
|
||||
title={t('socialUi.bell.notifications')}
|
||||
aria-label={badgeTotal > 0 ? t('socialUi.bell.notificationsNew', { count: badgeTotal }) : t('socialUi.bell.notifications')}
|
||||
>
|
||||
{/* Soft glow behind the bell when there's something waiting */}
|
||||
{badgeTotal > 0 && (
|
||||
|
|
@ -305,7 +310,7 @@ export function NotificationBell({ className }: { className?: string }) {
|
|||
{badgeTotal > 0 && (
|
||||
<span
|
||||
aria-live="polite"
|
||||
aria-label={`${badgeTotal} unread notifications`}
|
||||
aria-label={t('socialUi.bell.unreadCount', { count: badgeTotal })}
|
||||
className="absolute -right-1 -top-1 flex min-w-[18px] items-center justify-center rounded-full bg-[#ccff00] px-1 text-[10px] font-black leading-[18px] text-black shadow-[0_0_8px_rgba(204,255,0,0.6)] ring-2 ring-black"
|
||||
>
|
||||
{badgeTotal > 9 ? '9+' : badgeTotal}
|
||||
|
|
@ -324,7 +329,7 @@ export function NotificationBell({ className }: { className?: string }) {
|
|||
<span className="relative flex h-6 w-6 items-center justify-center rounded-lg bg-teal-500/15">
|
||||
<Bell className="h-3.5 w-3.5 text-teal-400" />
|
||||
</span>
|
||||
Notifications
|
||||
{t('socialUi.bell.notifications')}
|
||||
{badgeTotal > 0 && (
|
||||
<span className="rounded-full bg-[#ccff00] px-1.5 text-[10px] font-black leading-4 text-black">
|
||||
{badgeTotal > 99 ? '99+' : badgeTotal}
|
||||
|
|
@ -337,11 +342,11 @@ export function NotificationBell({ className }: { className?: string }) {
|
|||
onClick={markAllRead}
|
||||
className="flex items-center gap-1 rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[10px] font-black uppercase tracking-wide text-gray-300 transition-all active:scale-95 hover:border-teal-400/40 hover:bg-teal-500/10 hover:text-teal-300"
|
||||
>
|
||||
<CheckCheck className="h-3 w-3" /> Mark read
|
||||
<CheckCheck className="h-3 w-3" /> {t('socialUi.bell.markRead')}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide text-gray-600">
|
||||
All clear
|
||||
{t('socialUi.bell.allClear')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -352,34 +357,34 @@ export function NotificationBell({ className }: { className?: string }) {
|
|||
value="all"
|
||||
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-teal-400 data-[state=active]:bg-transparent data-[state=active]:text-teal-400"
|
||||
>
|
||||
All
|
||||
{t('socialUi.bell.tabAll')}
|
||||
{badgeTotal > 0 && <span className="ml-1 text-teal-400">{badgeTotal}</span>}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="trades"
|
||||
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-[#ff6600] data-[state=active]:bg-transparent data-[state=active]:text-[#ff6600]"
|
||||
>
|
||||
<Handshake className="mr-1 h-3 w-3" /> Trades
|
||||
<Handshake className="mr-1 h-3 w-3" /> {t('socialUi.bell.tabTrades')}
|
||||
{tradeNotifs.length > 0 && <span className="ml-1 text-[#ff6600]">{tradeNotifs.length}</span>}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="social"
|
||||
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-teal-400 data-[state=active]:bg-transparent data-[state=active]:text-teal-400"
|
||||
>
|
||||
<Heart className="mr-1 h-3 w-3" /> Social
|
||||
<Heart className="mr-1 h-3 w-3" /> {t('socialUi.bell.tabSocial')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="news"
|
||||
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-[#ccff00] data-[state=active]:bg-transparent data-[state=active]:text-[#ccff00]"
|
||||
>
|
||||
<Radio className="mr-1 h-3 w-3" /> News
|
||||
<Radio className="mr-1 h-3 w-3" /> {t('socialUi.bell.tabNews')}
|
||||
{unreadNewsletter > 0 && <span className="ml-1 text-[#ccff00]">{unreadNewsletter}</span>}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="crews"
|
||||
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-teal-400 data-[state=active]:bg-transparent data-[state=active]:text-teal-400"
|
||||
>
|
||||
<Users className="mr-1 h-3 w-3" /> Crews
|
||||
<Users className="mr-1 h-3 w-3" /> {t('socialUi.bell.tabCrews')}
|
||||
{crewTotal > 0 && <span className="ml-1 text-teal-400">{crewTotal}</span>}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
|
@ -389,11 +394,11 @@ export function NotificationBell({ className }: { className?: string }) {
|
|||
<TabsContent value="all" className="m-0 p-3">
|
||||
{isLoading && badgeTotal === 0 && (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Checking for updates…
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> {t('socialUi.bell.checkingUpdates')}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && crewTotal === 0 && activity.length === 0 && newsletterIssues.length === 0 && tradeNotifs.length === 0 && (
|
||||
<EmptyState label="Offers, trades, zaps, replies, newsletter drops and crew activity will show up here." />
|
||||
<EmptyState label={t('socialUi.bell.emptyAll')} />
|
||||
)}
|
||||
<div className="space-y-2.5">
|
||||
{/* Buying/selling steps first — they're the most time-sensitive. */}
|
||||
|
|
@ -441,11 +446,11 @@ export function NotificationBell({ className }: { className?: string }) {
|
|||
<TabsContent value="trades" className="m-0 p-3">
|
||||
{tradeLoading && tradeNotifs.length === 0 && (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Checking offers and trades…
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> {t('socialUi.bell.checkingTrades')}
|
||||
</div>
|
||||
)}
|
||||
{!tradeLoading && tradeNotifs.length === 0 && (
|
||||
<EmptyState label="Offers, counter-offers and trade confirmations all land here — whether you're selling, buying or swapping." />
|
||||
<EmptyState label={t('socialUi.bell.emptyTrades')} />
|
||||
)}
|
||||
<div className="space-y-2.5">
|
||||
{tradeNotifs.map((n) => (
|
||||
|
|
@ -463,11 +468,11 @@ export function NotificationBell({ className }: { className?: string }) {
|
|||
<TabsContent value="social" className="m-0 p-3">
|
||||
{activityLoading && activity.length === 0 && (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Checking for updates…
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> {t('socialUi.bell.checkingUpdates')}
|
||||
</div>
|
||||
)}
|
||||
{!activityLoading && activity.length === 0 && (
|
||||
<EmptyState label="Zaps, reactions, reposts, mentions, replies, event RSVPs and host updates all land here." />
|
||||
<EmptyState label={t('socialUi.bell.emptySocial')} />
|
||||
)}
|
||||
<div className="space-y-2.5">
|
||||
{activity.map((n) => (
|
||||
|
|
@ -485,11 +490,11 @@ export function NotificationBell({ className }: { className?: string }) {
|
|||
<TabsContent value="news" className="m-0 p-3">
|
||||
{newsletterLoading && newsletterIssues.length === 0 && (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading the latest drops…
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> {t('socialUi.bell.loadingDrops')}
|
||||
</div>
|
||||
)}
|
||||
{!newsletterLoading && newsletterIssues.length === 0 && (
|
||||
<EmptyState label="SkateSpots newsletter drops will land here. Subscribe on the Newsletter page." />
|
||||
<EmptyState label={t('socialUi.bell.emptyNews')} />
|
||||
)}
|
||||
{!isSubscribed && newsletterIssues.length > 0 && (
|
||||
<Link
|
||||
|
|
@ -498,7 +503,7 @@ export function NotificationBell({ className }: { className?: string }) {
|
|||
className="mb-2.5 flex items-center gap-2 rounded-xl border border-[#ccff00]/30 bg-[#ccff00]/[0.06] p-3 text-xs font-bold text-[#ccff00] transition-colors hover:bg-[#ccff00]/10"
|
||||
>
|
||||
<Radio className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="flex-1">Subscribe to get new drops in your bell</span>
|
||||
<span className="flex-1">{t('socialUi.bell.subscribeBanner')}</span>
|
||||
<ArrowRight className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
</Link>
|
||||
)}
|
||||
|
|
@ -518,11 +523,11 @@ export function NotificationBell({ className }: { className?: string }) {
|
|||
<TabsContent value="crews" className="m-0 p-3">
|
||||
{crewLoading && crewTotal === 0 && (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Checking for updates…
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> {t('socialUi.bell.checkingUpdates')}
|
||||
</div>
|
||||
)}
|
||||
{!crewLoading && crewTotal === 0 && (
|
||||
<EmptyState label="Crew invites, collective requests and membership requests will show up here." />
|
||||
<EmptyState label={t('socialUi.bell.emptyCrews')} />
|
||||
)}
|
||||
<div className="space-y-2.5">
|
||||
{joinRequests.map(({ request, crew, endorsements }) => (
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
showOsNotification,
|
||||
type OsPermission,
|
||||
} from '@/lib/osNotify';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
/**
|
||||
* Notification settings — the opt-in surface for OS notifications.
|
||||
|
|
@ -29,6 +30,7 @@ export function NotificationSettingsCard() {
|
|||
const push = usePushNotifications();
|
||||
const background = useBackgroundDelivery();
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// When the category choices change, re-send them to whatever channel is on, so
|
||||
// a closed-app push respects the toggles (the server filters by category).
|
||||
|
|
@ -89,8 +91,8 @@ export function NotificationSettingsCard() {
|
|||
const sendTest = () => {
|
||||
void showOsNotification({
|
||||
id: `test:${Date.now()}`,
|
||||
title: 'SkateSpots notifications are on 🛹',
|
||||
body: 'This is how a DM, zap or crew invite will look.',
|
||||
title: t('settingsUi.notify.testTitle'),
|
||||
body: t('settingsUi.notify.testBody'),
|
||||
path: '/',
|
||||
});
|
||||
};
|
||||
|
|
@ -108,9 +110,9 @@ export function NotificationSettingsCard() {
|
|||
<Bell className="h-4 w-4 text-[#ccff00]" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-bold text-white">Enable notifications</p>
|
||||
<p className="text-sm font-bold text-white">{t('settingsUi.notify.enableTitle')}</p>
|
||||
<p className="mt-0.5 text-xs leading-tight text-muted-foreground">
|
||||
Buzz this device when something needs you.
|
||||
{t('settingsUi.notify.enableDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
|
|
@ -122,23 +124,21 @@ export function NotificationSettingsCard() {
|
|||
|
||||
{permission === 'loading' && (
|
||||
<p className="flex items-center gap-2 border-t border-white/5 px-4 py-3 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> Checking permission…
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t('settingsUi.notify.checkingPermission')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{unsupported && (
|
||||
<p className="flex items-start gap-2 border-t border-white/5 px-4 py-3 text-xs text-amber-400">
|
||||
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
||||
This device doesn't support notifications here. Rebuild the Android app to
|
||||
enable them on your phone.
|
||||
{t('settingsUi.notify.unsupported')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{denied && (
|
||||
<p className="flex items-start gap-2 border-t border-white/5 px-4 py-3 text-xs text-amber-400">
|
||||
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
||||
Notifications are blocked for this site. Allow them in your browser/OS
|
||||
settings, then try again.
|
||||
{t('settingsUi.notify.denied')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -150,9 +150,9 @@ export function NotificationSettingsCard() {
|
|||
<Radio className="h-4 w-4 text-[#ccff00]" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-bold text-white">Background notifications</p>
|
||||
<p className="text-sm font-bold text-white">{t('settingsUi.notify.backgroundTitle')}</p>
|
||||
<p className="mt-0.5 text-xs leading-tight text-muted-foreground">
|
||||
Get pinged for messages and trades even when SkateSpots is closed.
|
||||
{t('settingsUi.notify.backgroundDesc')}
|
||||
</p>
|
||||
</div>
|
||||
{push.state === 'working' ? (
|
||||
|
|
@ -172,7 +172,7 @@ export function NotificationSettingsCard() {
|
|||
{push.state === 'unsupported' && (
|
||||
<p className="flex items-start gap-2 border-t border-white/5 px-4 py-3 text-xs text-amber-400">
|
||||
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
||||
This browser doesn't support background push. On mobile, add SkateSpots to your home screen.
|
||||
{t('settingsUi.notify.pushUnsupported')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
|
@ -186,10 +186,7 @@ export function NotificationSettingsCard() {
|
|||
<p className="flex items-start gap-2 border-t border-white/5 px-4 py-3 text-xs leading-relaxed text-muted-foreground">
|
||||
<ShieldCheck className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#ccff00]" />
|
||||
<span>
|
||||
To reach you while closed, a SkateSpots notification server gets a ping from your
|
||||
relay when something arrives for your key and passes it to your phone's push
|
||||
service. It sees only <em>that</em> your key has something — never who sent it or
|
||||
what it says, and it can't decrypt your messages. Opt in per device.
|
||||
{t('settingsUi.notify.pushPrivacy')}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -198,40 +195,39 @@ export function NotificationSettingsCard() {
|
|||
{background.supported && (
|
||||
<div className="overflow-hidden rounded-2xl border border-white/8 bg-white/2">
|
||||
<div className="px-4 py-3.5">
|
||||
<p className="text-sm font-bold text-white">Background delivery (Android)</p>
|
||||
<p className="text-sm font-bold text-white">{t('settingsUi.notify.bgDeliveryTitle')}</p>
|
||||
<p className="mt-0.5 text-xs leading-tight text-muted-foreground">
|
||||
Check for new messages, offers and trades while the app is closed — no Google services.
|
||||
{t('settingsUi.notify.bgDeliveryDesc')}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant={background.mode === 'off' ? 'default' : 'outline'} onClick={() => void background.disable()}>
|
||||
Off
|
||||
{t('settingsUi.notify.off')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={background.mode === 'standard' ? 'default' : 'outline'}
|
||||
onClick={() => {
|
||||
void background.enable('standard').catch((e) => {
|
||||
toast({ title: 'Could not enable', description: e instanceof Error ? e.message : 'Try again.', variant: 'destructive' });
|
||||
toast({ title: t('settingsUi.notify.couldNotEnable'), description: e instanceof Error ? e.message : t('common.tryAgain'), variant: 'destructive' });
|
||||
});
|
||||
}}
|
||||
>
|
||||
Standard (~15 min)
|
||||
{t('settingsUi.notify.standard')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={background.mode === 'fast' ? 'default' : 'outline'}
|
||||
onClick={() => {
|
||||
void background.enable('fast').catch((e) => {
|
||||
toast({ title: 'Could not enable', description: e instanceof Error ? e.message : 'Try again.', variant: 'destructive' });
|
||||
toast({ title: t('settingsUi.notify.couldNotEnable'), description: e instanceof Error ? e.message : t('common.tryAgain'), variant: 'destructive' });
|
||||
});
|
||||
}}
|
||||
>
|
||||
Fast (~5 min)
|
||||
{t('settingsUi.notify.fast')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] leading-relaxed text-gray-600">
|
||||
Standard is best-effort — Android may defer it under Doze. Fast shows a persistent
|
||||
notification and uses more battery. Nothing is decrypted on our server.
|
||||
{t('settingsUi.notify.bgDeliveryNote')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -240,16 +236,16 @@ export function NotificationSettingsCard() {
|
|||
{/* Per-category toggles */}
|
||||
<div className="overflow-hidden rounded-2xl border border-white/8 bg-white/2">
|
||||
<p className="border-b border-white/5 px-4 py-2.5 text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">
|
||||
Notify me about
|
||||
{t('settingsUi.notify.notifyMeAbout')}
|
||||
</p>
|
||||
{NOTIFICATION_CATEGORIES.map(({ key, label, hint }) => (
|
||||
{NOTIFICATION_CATEGORIES.map(({ key }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-3 border-b border-white/5 px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-bold text-white">{label}</p>
|
||||
<p className="mt-0.5 text-xs leading-tight text-muted-foreground">{hint}</p>
|
||||
<p className="text-sm font-bold text-white">{t(`settingsUi.notify.cat_${key}_label`)}</p>
|
||||
<p className="mt-0.5 text-xs leading-tight text-muted-foreground">{t(`settingsUi.notify.cat_${key}_hint`)}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={prefs.enabled && granted && prefs[key]}
|
||||
|
|
@ -270,16 +266,14 @@ export function NotificationSettingsCard() {
|
|||
disabled={!prefs.enabled || !granted}
|
||||
className="w-full"
|
||||
>
|
||||
Send a test notification
|
||||
{t('settingsUi.notify.sendTest')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="flex items-start gap-2 border-t border-white/5 px-4 py-3 text-xs leading-relaxed text-muted-foreground">
|
||||
<ShieldCheck className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#ccff00]" />
|
||||
<span>
|
||||
Alerts are built on your device from data the app already has, and direct-message
|
||||
alerts never decrypt your messages — so Amber is never asked to open one. With{' '}
|
||||
<strong className="text-white">Background notifications</strong> off, a fully closed
|
||||
app can't be reached, so alerts arrive next time you open SkateSpots.
|
||||
{t('settingsUi.notify.footerBefore')}{' '}
|
||||
<strong className="text-white">{t('settingsUi.notify.backgroundTitle')}</strong> {t('settingsUi.notify.footerAfter')}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -20,12 +20,14 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|||
import { useRegionBackfill, type BackfillResult } from '@/hooks/useRegionBackfill';
|
||||
import { isAdminPubkey } from '@/lib/admins';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
export function RegionBackfillCard() {
|
||||
const { user } = useCurrentUser();
|
||||
const isAdmin = isAdminPubkey(user?.pubkey);
|
||||
const { candidates, isLoading, backfill, progress, isRunning } = useRegionBackfill(isAdmin);
|
||||
const [summary, setSummary] = useState<BackfillResult | null>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isAdmin) return null;
|
||||
|
||||
|
|
@ -38,23 +40,23 @@ export function RegionBackfillCard() {
|
|||
setSummary(result);
|
||||
if (result.updated > 0) {
|
||||
toast({
|
||||
title: `Tagged ${result.updated} location${result.updated === 1 ? '' : 's'}`,
|
||||
title: t('settingsUi.regionBackfill.taggedTitle', { count: result.updated, s: result.updated === 1 ? '' : 's' }),
|
||||
description:
|
||||
result.skipped > 0
|
||||
? `${result.skipped} skipped (no coordinates or no geocoder match).`
|
||||
: 'Region tags written to the community relay.',
|
||||
? t('settingsUi.regionBackfill.taggedSkipped', { count: result.skipped })
|
||||
: t('settingsUi.regionBackfill.taggedOk'),
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'Nothing to tag',
|
||||
description: 'No pins could be geocoded. Try again later.',
|
||||
title: t('settingsUi.regionBackfill.nothingTitle'),
|
||||
description: t('settingsUi.regionBackfill.nothingDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Backfill failed',
|
||||
description: err instanceof Error ? err.message : 'Try again.',
|
||||
title: t('settingsUi.regionBackfill.failedTitle'),
|
||||
description: err instanceof Error ? err.message : t('common.tryAgain'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -65,29 +67,27 @@ export function RegionBackfillCard() {
|
|||
<CardContent className="py-3.5 space-y-2.5">
|
||||
<p className="flex items-center gap-2 text-sm font-bold text-white">
|
||||
<MapPin className="h-4 w-4 text-[#ccff00]" />
|
||||
Backfill region tags
|
||||
{t('settingsUi.regionBackfill.title')}
|
||||
<span className="rounded bg-[#ccff00]/10 px-1.5 py-0.5 text-[9px] font-black uppercase tracking-widest text-[#ccff00]/70">
|
||||
Admin
|
||||
{t('settingsUi.regionBackfill.admin')}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pins made before geocoding existed have coordinates but no city/country tag, so the map
|
||||
draws them while Community Stats can only say “Uncategorised”. This re-derives the tags
|
||||
from each pin and republishes <strong>your own</strong> untagged spots & shops.
|
||||
{t('settingsUi.regionBackfill.descBefore')}{' '}
|
||||
<strong>{t('settingsUi.regionBackfill.descStrong')}</strong>{' '}
|
||||
{t('settingsUi.regionBackfill.descAfter')}
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-xs text-muted-foreground">Scanning your spots…</p>
|
||||
<p className="text-xs text-muted-foreground">{t('settingsUi.regionBackfill.scanning')}</p>
|
||||
) : count === 0 ? (
|
||||
<p className="text-xs text-[#ccff00]">
|
||||
Every spot & shop you own already has a region tag. 🎉 Any remaining
|
||||
“Uncategorised” pins belong to other mappers — only their own keys can tag them.
|
||||
{t('settingsUi.regionBackfill.allTagged')}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{count} of your spot{count === 1 ? '' : 's'}/shops have no region tag. Your signer may
|
||||
ask to approve each one.
|
||||
{t('settingsUi.regionBackfill.countLine', { count, s: count === 1 ? '' : 's' })}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
|
|
@ -101,12 +101,12 @@ export function RegionBackfillCard() {
|
|||
<MapPin className="h-4 w-4" />
|
||||
)}
|
||||
{isRunning && progress
|
||||
? `Tagging ${progress.done}/${progress.total}…`
|
||||
: 'Backfill region tags'}
|
||||
? t('settingsUi.regionBackfill.tagging', { done: progress.done, total: progress.total })
|
||||
: t('settingsUi.regionBackfill.run')}
|
||||
</Button>
|
||||
{summary && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{summary.updated} updated · {summary.skipped} skipped · {summary.failed} failed
|
||||
{t('settingsUi.regionBackfill.summary', { updated: summary.updated, skipped: summary.skipped, failed: summary.failed })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { enableRelatr, getRelatrConfig, disableRelatr } from '@/hooks/useRelatrS
|
|||
import { useNostr } from '@nostrify/react';
|
||||
import { Shield, ExternalLink, Search, CheckCircle2, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
/**
|
||||
* RelatrSettings component for configuring Relatr global trust score integration
|
||||
|
|
@ -19,6 +20,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
|
|||
*/
|
||||
export function RelatrSettings() {
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
const { nostr } = useNostr();
|
||||
const config = getRelatrConfig();
|
||||
|
||||
|
|
@ -30,8 +32,8 @@ export function RelatrSettings() {
|
|||
const handleAutoDiscover = async () => {
|
||||
setIsSearching(true);
|
||||
toast({
|
||||
title: 'Searching for Relatr service...',
|
||||
description: 'Querying Nostr for Relatr profiles',
|
||||
title: t('settingsUi.relatr.searchingTitle'),
|
||||
description: t('settingsUi.relatr.searchingDesc'),
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
@ -58,8 +60,8 @@ export function RelatrSettings() {
|
|||
) {
|
||||
setPubkey(profile.pubkey);
|
||||
toast({
|
||||
title: 'Found Relatr service!',
|
||||
description: `Service: ${metadata.name || 'Unknown'}`,
|
||||
title: t('settingsUi.relatr.foundTitle'),
|
||||
description: t('settingsUi.relatr.foundDesc', { name: metadata.name || t('settingsUi.relatr.unknown') }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -69,15 +71,15 @@ export function RelatrSettings() {
|
|||
}
|
||||
|
||||
toast({
|
||||
title: 'No Relatr service found',
|
||||
description: 'Try entering the pubkey manually or check https://www.relatr.xyz/',
|
||||
title: t('settingsUi.relatr.notFoundTitle'),
|
||||
description: t('settingsUi.relatr.notFoundDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[RelatrSettings] Auto-discovery failed:', error);
|
||||
toast({
|
||||
title: 'Search failed',
|
||||
description: 'Could not query Nostr for Relatr profiles',
|
||||
title: t('settingsUi.relatr.searchFailedTitle'),
|
||||
description: t('settingsUi.relatr.searchFailedDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -88,8 +90,8 @@ export function RelatrSettings() {
|
|||
const handleEnable = () => {
|
||||
if (!pubkey || pubkey.length !== 64) {
|
||||
toast({
|
||||
title: 'Invalid pubkey',
|
||||
description: 'Please enter a valid 64-character hex pubkey',
|
||||
title: t('settingsUi.relatr.invalidPubkeyTitle'),
|
||||
description: t('settingsUi.relatr.invalidPubkeyDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -98,8 +100,8 @@ export function RelatrSettings() {
|
|||
enableRelatr(pubkey, relay);
|
||||
|
||||
toast({
|
||||
title: 'Relatr enabled!',
|
||||
description: 'Global trust scores are now active.',
|
||||
title: t('settingsUi.relatr.enabledTitle'),
|
||||
description: t('settingsUi.relatr.enabledDesc'),
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -107,8 +109,8 @@ export function RelatrSettings() {
|
|||
disableRelatr();
|
||||
|
||||
toast({
|
||||
title: 'Relatr disabled',
|
||||
description: 'Trust scores are no longer being fetched.',
|
||||
title: t('settingsUi.relatr.disabledTitle'),
|
||||
description: t('settingsUi.relatr.disabledDesc'),
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -117,25 +119,25 @@ export function RelatrSettings() {
|
|||
<CardHeader>
|
||||
<CardTitle className="text-xl font-black uppercase text-[#ccff00] flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Relatr Trust Scores
|
||||
{t('settingsUi.relatr.title')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Global trust scores help identify reliable users and reduce spam
|
||||
{t('settingsUi.relatr.desc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Status - Always visible */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-bold text-gray-300">Status</Label>
|
||||
<Label className="text-sm font-bold text-gray-300">{t('settingsUi.relatr.status')}</Label>
|
||||
<Badge variant={config.enabled ? 'default' : 'outline'} className="gap-2">
|
||||
{config.enabled ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Active
|
||||
{t('settingsUi.relatr.active')}
|
||||
</>
|
||||
) : (
|
||||
'Disabled'
|
||||
t('settingsUi.relatr.disabled')
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
|
@ -146,11 +148,11 @@ export function RelatrSettings() {
|
|||
<AlertDescription className="text-sm text-gray-300">
|
||||
{config.enabled ? (
|
||||
<>
|
||||
<strong className="text-[#ccff00]">Relatr is active.</strong> Trust scores from ContextVM's global network analysis help identify reliable skaters and reduce spam.
|
||||
<strong className="text-[#ccff00]">{t('settingsUi.relatr.activeStrong')}</strong> {t('settingsUi.relatr.activeDesc')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong className="text-[#ccff00]">Relatr is disabled.</strong> Enable it to get trust scores from the global Nostr network.
|
||||
<strong className="text-[#ccff00]">{t('settingsUi.relatr.disabledStrong')}</strong> {t('settingsUi.relatr.disabledHint')}
|
||||
</>
|
||||
)}
|
||||
</AlertDescription>
|
||||
|
|
@ -163,7 +165,7 @@ export function RelatrSettings() {
|
|||
variant="outline"
|
||||
className="w-full gap-2 border-red-500/50 text-red-400 hover:bg-red-500/10"
|
||||
>
|
||||
Disable Relatr
|
||||
{t('settingsUi.relatr.disable')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
|
|
@ -172,7 +174,7 @@ export function RelatrSettings() {
|
|||
disabled={!pubkey || pubkey.length !== 64}
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
Enable Relatr
|
||||
{t('settingsUi.relatr.enable')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -185,7 +187,7 @@ export function RelatrSettings() {
|
|||
className="w-full gap-2 text-gray-400 hover:text-white"
|
||||
>
|
||||
{isAdvancedOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
Advanced Settings
|
||||
{t('settingsUi.relatr.advancedSettings')}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
|
|
@ -193,12 +195,12 @@ export function RelatrSettings() {
|
|||
{/* Service Pubkey Input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="relatr-pubkey" className="text-sm font-bold text-gray-300">
|
||||
Relatr Service Pubkey
|
||||
{t('settingsUi.relatr.servicePubkey')}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="relatr-pubkey"
|
||||
placeholder="Enter 64-character hex pubkey..."
|
||||
placeholder={t('settingsUi.relatr.pubkeyPlaceholder')}
|
||||
value={pubkey}
|
||||
onChange={(e) => setPubkey(e.target.value)}
|
||||
className="font-mono text-xs"
|
||||
|
|
@ -212,18 +214,18 @@ export function RelatrSettings() {
|
|||
className="gap-2 whitespace-nowrap"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
{isSearching ? 'Searching...' : 'Auto-Discover'}
|
||||
{isSearching ? t('settingsUi.relatr.searching') : t('settingsUi.relatr.autoDiscover')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Default: ContextVM's official Relatr service (npub1dvmc...)
|
||||
{t('settingsUi.relatr.pubkeyDefault')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Relay Input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="relatr-relay" className="text-sm font-bold text-gray-300">
|
||||
Relay (where Relatr publishes scores)
|
||||
{t('settingsUi.relatr.relayLabel')}
|
||||
</Label>
|
||||
<Input
|
||||
id="relatr-relay"
|
||||
|
|
@ -233,7 +235,7 @@ export function RelatrSettings() {
|
|||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
Default: wss://relay.contextvm.org (ContextVM's official relay)
|
||||
{t('settingsUi.relatr.relayDefault')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -244,12 +246,12 @@ export function RelatrSettings() {
|
|||
className="w-full gap-2"
|
||||
disabled={!pubkey || pubkey.length !== 64}
|
||||
>
|
||||
Apply Changes
|
||||
{t('settingsUi.relatr.applyChanges')}
|
||||
</Button>
|
||||
|
||||
{/* Documentation Links */}
|
||||
<div className="pt-4 border-t border-gray-700 space-y-2">
|
||||
<p className="text-sm font-bold text-gray-300">Learn More:</p>
|
||||
<p className="text-sm font-bold text-gray-300">{t('settingsUi.relatr.learnMore')}</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<a
|
||||
href="https://www.relatr.xyz/"
|
||||
|
|
@ -258,7 +260,7 @@ export function RelatrSettings() {
|
|||
className="text-sm text-[#ccff00] hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Relatr Website
|
||||
{t('settingsUi.relatr.website')}
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/ContextVM/relatr"
|
||||
|
|
@ -267,7 +269,7 @@ export function RelatrSettings() {
|
|||
className="text-sm text-[#ccff00] hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Relatr GitHub
|
||||
{t('settingsUi.relatr.github')}
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/nostr-protocol/nips/blob/master/85.md"
|
||||
|
|
@ -276,7 +278,7 @@ export function RelatrSettings() {
|
|||
className="text-sm text-[#ccff00] hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
NIP-85 Specification
|
||||
{t('settingsUi.relatr.nip85Spec')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { useToast } from '@/hooks/useToast';
|
|||
import { useRelayStatus } from '@/hooks/useRelayStatus';
|
||||
import { isLocalRelay } from '@/lib/relayProbe';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
interface Relay {
|
||||
url: string;
|
||||
|
|
@ -66,9 +67,10 @@ function formatRelayUrl(url: string): string {
|
|||
/** Live connection dot — green pulsing when connected, breathing while connecting, grey offline. */
|
||||
function RelayStatusDot({ url }: { url: string }) {
|
||||
const { status, reason } = useRelayStatus(url);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const label =
|
||||
status === 'connected' ? 'Connected' : status === 'connecting' ? 'Connecting…' : 'Offline';
|
||||
status === 'connected' ? t('settingsUi.relayList.connected') : status === 'connecting' ? t('settingsUi.relayList.connecting') : t('settingsUi.relayList.offline');
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
|
|
@ -182,6 +184,7 @@ function ToggleChip({
|
|||
|
||||
/** Removable URL pills. */
|
||||
function UrlChips({ urls, tone, onRemove }: { urls: string[]; tone: Tone; onRemove: (url: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
if (urls.length === 0) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
|
|
@ -195,7 +198,7 @@ function UrlChips({ urls, tone, onRemove }: { urls: string[]; tone: Tone; onRemo
|
|||
type="button"
|
||||
onClick={() => onRemove(url)}
|
||||
className="opacity-60 transition-opacity hover:opacity-100"
|
||||
aria-label={`Remove ${url}`}
|
||||
aria-label={t('settingsUi.relayList.removeUrl', { url })}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
|
|
@ -221,6 +224,7 @@ function AddUrlRow({
|
|||
tone?: Tone;
|
||||
primary?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
|
|
@ -244,7 +248,7 @@ function AddUrlRow({
|
|||
)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add
|
||||
{t('settingsUi.relayList.add')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -255,6 +259,7 @@ export function RelayListManager() {
|
|||
const { user } = useCurrentUser();
|
||||
const { mutate: publishEvent } = useNostrPublish();
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [relays, setRelays] = useState<Relay[]>(config.relayMetadata.relays);
|
||||
const [newRelayUrl, setNewRelayUrl] = useState('');
|
||||
|
|
@ -323,10 +328,10 @@ export function RelayListManager() {
|
|||
{ kind: 10002, content: '', tags },
|
||||
{
|
||||
onSuccess: () =>
|
||||
toast({ title: 'Relay list published', description: 'Synced to Nostr (kind 10002).' }),
|
||||
toast({ title: t('settingsUi.relayList.publishedTitle'), description: t('settingsUi.relayList.publishedDesc') }),
|
||||
onError: (error) => {
|
||||
console.error('Failed to publish relay list:', error);
|
||||
toast({ title: 'Failed to publish relay list', variant: 'destructive' });
|
||||
toast({ title: t('settingsUi.relayList.publishFailedTitle'), variant: 'destructive' });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -335,15 +340,15 @@ export function RelayListManager() {
|
|||
const handleAddRelay = () => {
|
||||
if (!isValidRelayUrl(newRelayUrl)) {
|
||||
toast({
|
||||
title: 'Invalid relay URL',
|
||||
description: 'Try something like wss://relay.example.com',
|
||||
title: t('settingsUi.relayList.invalidUrlTitle'),
|
||||
description: t('settingsUi.relayList.invalidUrlDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeRelayUrl(newRelayUrl);
|
||||
if (relays.some((r) => r.url === normalized)) {
|
||||
toast({ title: 'Relay already in your list', variant: 'destructive' });
|
||||
toast({ title: t('settingsUi.relayList.alreadyInList'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
const newRelays = [...relays, { url: normalized, read: true, write: true }];
|
||||
|
|
@ -363,8 +368,8 @@ export function RelayListManager() {
|
|||
if (!relay) return;
|
||||
if (relay.read && !relay.write) {
|
||||
toast({
|
||||
title: 'Keep read or write on',
|
||||
description: 'A relay needs at least one of read/write.',
|
||||
title: t('settingsUi.relayList.keepReadWriteTitle'),
|
||||
description: t('settingsUi.relayList.keepReadWriteDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -379,8 +384,8 @@ export function RelayListManager() {
|
|||
if (!relay) return;
|
||||
if (!relay.read && relay.write) {
|
||||
toast({
|
||||
title: 'Keep read or write on',
|
||||
description: 'A relay needs at least one of read/write.',
|
||||
title: t('settingsUi.relayList.keepReadWriteTitle'),
|
||||
description: t('settingsUi.relayList.keepReadWriteDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -400,13 +405,13 @@ export function RelayListManager() {
|
|||
|
||||
const addCategoryRelay = (key: 'localRelays' | 'blockedRelays', value: string, reset: () => void) => {
|
||||
if (!isValidRelayUrl(value)) {
|
||||
toast({ title: 'Invalid relay URL', variant: 'destructive' });
|
||||
toast({ title: t('settingsUi.relayList.invalidUrlTitle'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeRelayUrl(value);
|
||||
const existing = config.relayMetadata[key] ?? [];
|
||||
if (existing.includes(normalized)) {
|
||||
toast({ title: 'Relay already in this list', variant: 'destructive' });
|
||||
toast({ title: t('settingsUi.relayList.alreadyInThisList'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
updateCategory(key, [...existing, normalized]);
|
||||
|
|
@ -423,10 +428,10 @@ export function RelayListManager() {
|
|||
publishEvent(
|
||||
{ kind: 10050, content: '', tags: urls.map((url) => ['relay', url]) },
|
||||
{
|
||||
onSuccess: () => toast({ title: 'DM relays published', description: 'NIP-17 inbox updated.' }),
|
||||
onSuccess: () => toast({ title: t('settingsUi.relayList.dmPublishedTitle'), description: t('settingsUi.relayList.dmPublishedDesc') }),
|
||||
onError: (error) => {
|
||||
console.error('Failed to publish DM relay list:', error);
|
||||
toast({ title: 'Failed to publish DM relays', variant: 'destructive' });
|
||||
toast({ title: t('settingsUi.relayList.dmPublishFailedTitle'), variant: 'destructive' });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -442,12 +447,12 @@ export function RelayListManager() {
|
|||
|
||||
const addDmRelay = () => {
|
||||
if (!isValidRelayUrl(newDmUrl)) {
|
||||
toast({ title: 'Invalid relay URL', variant: 'destructive' });
|
||||
toast({ title: t('settingsUi.relayList.invalidUrlTitle'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeRelayUrl(newDmUrl);
|
||||
if (dmRelays.includes(normalized)) {
|
||||
toast({ title: 'Relay already in this list', variant: 'destructive' });
|
||||
toast({ title: t('settingsUi.relayList.alreadyInThisList'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
setDmRelays([...dmRelays, normalized]);
|
||||
|
|
@ -459,18 +464,18 @@ export function RelayListManager() {
|
|||
{/* NIP-65 */}
|
||||
<Section
|
||||
icon={Radio}
|
||||
title="Relays (NIP-65)"
|
||||
title={t('settingsUi.relayList.nip65Title')}
|
||||
count={relays.length}
|
||||
tone="lime"
|
||||
description={
|
||||
user
|
||||
? 'Tap Read / Write to toggle. Published to Nostr as your kind 10002 list.'
|
||||
: 'Tap Read / Write to toggle. Log in to publish your list to Nostr.'
|
||||
? t('settingsUi.relayList.nip65DescUser')
|
||||
: t('settingsUi.relayList.nip65DescLoggedOut')
|
||||
}
|
||||
>
|
||||
{relays.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-white/10 px-3 py-4 text-center text-xs text-muted-foreground">
|
||||
No relays yet — add one below.
|
||||
{t('settingsUi.relayList.noRelays')}
|
||||
</p>
|
||||
) : (
|
||||
relays.map((relay) => (
|
||||
|
|
@ -486,7 +491,7 @@ export function RelayListManager() {
|
|||
</span>
|
||||
{isLocalRelay(relay.url) && (
|
||||
<span className="flex-shrink-0 rounded-full border border-[#ccff00]/25 bg-[#ccff00]/12 px-1.5 py-0.5 text-[9px] font-black uppercase tracking-[0.15em] text-[#ccff00]">
|
||||
Local
|
||||
{t('settingsUi.relayList.local')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -495,15 +500,15 @@ export function RelayListManager() {
|
|||
active={relay.read}
|
||||
onClick={() => handleToggleRead(relay.url)}
|
||||
icon={ArrowDownToLine}
|
||||
label="Read"
|
||||
hint="Fetch content from this relay"
|
||||
label={t('settingsUi.relayList.read')}
|
||||
hint={t('settingsUi.relayList.readHint')}
|
||||
/>
|
||||
<ToggleChip
|
||||
active={relay.write}
|
||||
onClick={() => handleToggleWrite(relay.url)}
|
||||
icon={ArrowUpFromLine}
|
||||
label="Write"
|
||||
hint="Publish your events to this relay"
|
||||
label={t('settingsUi.relayList.write')}
|
||||
hint={t('settingsUi.relayList.writeHint')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -513,7 +518,7 @@ export function RelayListManager() {
|
|||
onClick={() => handleRemoveRelay(relay.url)}
|
||||
disabled={relays.length <= 1}
|
||||
className="size-7 flex-shrink-0 rounded-full text-muted-foreground hover:bg-white/5 hover:text-destructive"
|
||||
aria-label={`Remove ${relay.url}`}
|
||||
aria-label={t('settingsUi.relayList.removeUrl', { url: relay.url })}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -532,10 +537,10 @@ export function RelayListManager() {
|
|||
{/* DM inbox */}
|
||||
<Section
|
||||
icon={Inbox}
|
||||
title="DM inbox relays"
|
||||
title={t('settingsUi.relayList.dmTitle')}
|
||||
count={dmRelays.length}
|
||||
tone="cyan"
|
||||
description="Where you receive NIP-17 direct messages (kind 10050). Falls back to your write relays when empty."
|
||||
description={t('settingsUi.relayList.dmDesc')}
|
||||
>
|
||||
<UrlChips urls={dmRelays} tone="cyan" onRemove={(u) => setDmRelays(dmRelays.filter((x) => x !== u))} />
|
||||
<AddUrlRow
|
||||
|
|
@ -550,10 +555,10 @@ export function RelayListManager() {
|
|||
{/* Local */}
|
||||
<Section
|
||||
icon={HardDrive}
|
||||
title="Local relays"
|
||||
title={t('settingsUi.relayList.localTitle')}
|
||||
count={localRelays.length}
|
||||
tone="lime"
|
||||
description="App-only (e.g. a LAN node). Used for reads and writes; never published."
|
||||
description={t('settingsUi.relayList.localDesc')}
|
||||
>
|
||||
<UrlChips urls={localRelays} tone="lime" onRemove={(u) => removeCategoryRelay('localRelays', u)} />
|
||||
<AddUrlRow
|
||||
|
|
@ -568,10 +573,10 @@ export function RelayListManager() {
|
|||
{/* Blocked */}
|
||||
<Section
|
||||
icon={Ban}
|
||||
title="Blocked relays"
|
||||
title={t('settingsUi.relayList.blockedTitle')}
|
||||
count={blockedRelays.length}
|
||||
tone="red"
|
||||
description="Never connect to these — excluded from every read and write."
|
||||
description={t('settingsUi.relayList.blockedDesc')}
|
||||
>
|
||||
<UrlChips urls={blockedRelays} tone="red" onRemove={(u) => removeCategoryRelay('blockedRelays', u)} />
|
||||
<AddUrlRow
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
|
|||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import {
|
||||
useSendCollectiveJoinRequest,
|
||||
useMyCollectiveJoinRequest,
|
||||
|
|
@ -26,6 +27,7 @@ export function RequestToJoinCollective({
|
|||
accentText?: string;
|
||||
accentBorder?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { toast } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
|
@ -47,13 +49,13 @@ export function RequestToJoinCollective({
|
|||
setOpen(false);
|
||||
setNote('');
|
||||
toast({
|
||||
title: 'Request sent 🤝',
|
||||
description: `${profile.title} will see it in their notifications and can add you.`,
|
||||
title: t('socialUi.requestCollective.sentTitle'),
|
||||
description: t('socialUi.requestCollective.sentDesc', { name: profile.title }),
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Could not send request',
|
||||
description: err instanceof Error ? err.message : 'Please try again.',
|
||||
title: t('socialUi.requestCollective.couldNotSend'),
|
||||
description: err instanceof Error ? err.message : t('common.tryAgain'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -64,19 +66,20 @@ export function RequestToJoinCollective({
|
|||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Users className={`h-5 w-5 ${accentText}`} />
|
||||
<h3 className="text-lg text-white uppercase font-black">Join this collective</h3>
|
||||
<h3 className="text-lg text-white uppercase font-black">{t('socialUi.requestCollective.title')}</h3>
|
||||
</div>
|
||||
|
||||
{alreadyRequested ? (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2.5 text-sm text-emerald-300">
|
||||
<Check className="h-4 w-4" />
|
||||
Request sent — waiting on {profile.title}.
|
||||
{t('socialUi.requestCollective.waiting', { name: profile.title })}
|
||||
</div>
|
||||
) : !open ? (
|
||||
<>
|
||||
<p className="text-sm text-gray-400 mb-3">
|
||||
Ask the crew behind <span className="font-bold text-white">{profile.title}</span> to add
|
||||
you to the roster. They’ll get a notification and can add you in one tap.
|
||||
{t('socialUi.requestCollective.askA')}{' '}
|
||||
<span className="font-bold text-white">{profile.title}</span>{' '}
|
||||
{t('socialUi.requestCollective.askB')}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setOpen(true)}
|
||||
|
|
@ -84,7 +87,7 @@ export function RequestToJoinCollective({
|
|||
className="w-full gap-2 bg-white text-black font-black hover:bg-gray-200"
|
||||
>
|
||||
<Hand className="h-4 w-4" />
|
||||
Request to join
|
||||
{t('socialUi.requestToJoin.trigger')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
|
|
@ -92,7 +95,7 @@ export function RequestToJoinCollective({
|
|||
<Textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value.slice(0, 500))}
|
||||
placeholder="Optional: say why you'd be a good fit…"
|
||||
placeholder={t('socialUi.requestCollective.placeholder')}
|
||||
rows={3}
|
||||
className="resize-none border-gray-700 bg-black/60 text-white placeholder:text-gray-600"
|
||||
/>
|
||||
|
|
@ -103,7 +106,7 @@ export function RequestToJoinCollective({
|
|||
className="flex-1 gap-2 bg-white text-black font-black hover:bg-gray-200"
|
||||
>
|
||||
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Hand className="h-4 w-4" />}
|
||||
Send request
|
||||
{t('socialUi.requestCollective.sendButton')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -115,7 +118,7 @@ export function RequestToJoinCollective({
|
|||
className="gap-1.5 border-gray-700 text-gray-400 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ 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: {
|
||||
|
|
@ -16,22 +17,22 @@ const OPTIONS: {
|
|||
}[] = [
|
||||
{
|
||||
status: 'accepted',
|
||||
label: "I'm Going",
|
||||
shortLabel: 'Going',
|
||||
label: 'placesUi.rsvp.going',
|
||||
shortLabel: 'placesUi.rsvp.goingShort',
|
||||
icon: Check,
|
||||
activeClass: 'bg-green-500 border-green-500 text-black',
|
||||
},
|
||||
{
|
||||
status: 'tentative',
|
||||
label: 'Interested',
|
||||
shortLabel: 'Maybe',
|
||||
label: 'placesUi.rsvp.interested',
|
||||
shortLabel: 'placesUi.rsvp.maybe',
|
||||
icon: HelpCircle,
|
||||
activeClass: 'bg-[#ccff00] border-[#ccff00] text-black',
|
||||
},
|
||||
{
|
||||
status: 'declined',
|
||||
label: "Can't make it",
|
||||
shortLabel: "Can't",
|
||||
label: 'placesUi.rsvp.cantMakeIt',
|
||||
shortLabel: 'placesUi.rsvp.cant',
|
||||
icon: X,
|
||||
activeClass: 'bg-red-500/80 border-red-500 text-white',
|
||||
},
|
||||
|
|
@ -43,6 +44,7 @@ const OPTIONS: {
|
|||
* 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);
|
||||
|
|
@ -55,8 +57,8 @@ export function RsvpButtons({ event, className }: { event: SkateEvent; className
|
|||
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">
|
||||
Are you going?{' '}
|
||||
<span className="text-muted-foreground font-normal">Log in to RSVP.</span>
|
||||
{t('placesUi.rsvp.areYouGoing')}{' '}
|
||||
<span className="text-muted-foreground font-normal">{t('placesUi.rsvp.loginToRsvp')}</span>
|
||||
</p>
|
||||
<LoginArea className="w-full justify-center" />
|
||||
</div>
|
||||
|
|
@ -73,15 +75,15 @@ export function RsvpButtons({ event, className }: { event: SkateEvent; className
|
|||
});
|
||||
const msg =
|
||||
status === 'accepted'
|
||||
? "You're going! 🛹"
|
||||
? t('placesUi.rsvp.toastGoing')
|
||||
: status === 'tentative'
|
||||
? 'Marked as interested.'
|
||||
: 'Marked as not going.';
|
||||
? t('placesUi.rsvp.toastInterested')
|
||||
: t('placesUi.rsvp.toastNotGoing');
|
||||
toast({ title: msg });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Could not RSVP',
|
||||
description: err instanceof Error ? err.message : 'Please try again.',
|
||||
title: t('placesUi.rsvp.toastError'),
|
||||
description: err instanceof Error ? err.message : t('placesUi.rsvp.tryAgain'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -91,7 +93,7 @@ export function RsvpButtons({ event, className }: { event: SkateEvent; className
|
|||
<div className={cn('space-y-2', className)}>
|
||||
{isHost && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You're hosting this — but you can still set your status.
|
||||
{t('placesUi.rsvp.hostNote')}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
|
|
@ -116,19 +118,19 @@ export function RsvpButtons({ event, className }: { event: SkateEvent; className
|
|||
) : (
|
||||
<Icon className="h-4 w-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
<span className="sm:hidden">{shortLabel}</span>
|
||||
<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> going
|
||||
<span className="font-bold text-green-400">{goingCount}</span> {t('placesUi.rsvp.goingCount')}
|
||||
{tentativeCount > 0 && (
|
||||
<>
|
||||
{' · '}
|
||||
<span className="font-bold text-[#ccff00]">{tentativeCount}</span> interested
|
||||
<span className="font-bold text-[#ccff00]">{tentativeCount}</span> {t('placesUi.rsvp.interestedCount')}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -8,12 +8,14 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
import { useNostrLogin } from '@nostrify/react/login';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
const sanitizeFilename = (filename: string) => filename.replace(/[^a-z0-9_.-]/gi, '_');
|
||||
|
||||
export function SecuritySettings() {
|
||||
const { user } = useCurrentUser();
|
||||
const { logins } = useNostrLogin();
|
||||
const { t } = useTranslation();
|
||||
const [confirmed, setConfirmed] = useLocalStorage('backup-confirmed', false);
|
||||
const [showWarning, setShowWarning] = useState(!confirmed);
|
||||
|
||||
|
|
@ -44,7 +46,7 @@ export function SecuritySettings() {
|
|||
// gentle reminder stops nagging a user who has genuinely saved their login.
|
||||
setConfirmed(true);
|
||||
setShowWarning(false);
|
||||
toast({ title: 'Copied!', description: 'Recovery code copied. Store it somewhere safe.' });
|
||||
toast({ title: t('common.copied'), description: t('settingsUi.security.recoveryCopied') });
|
||||
};
|
||||
|
||||
const handleDownloadRecovery = () => {
|
||||
|
|
@ -64,9 +66,9 @@ export function SecuritySettings() {
|
|||
// reminder stops for good.
|
||||
setConfirmed(true);
|
||||
setShowWarning(false);
|
||||
toast({ title: 'Saved!', description: 'Recovery code downloaded. Keep it somewhere safe.' });
|
||||
toast({ title: t('settingsUi.security.savedTitle'), description: t('settingsUi.security.recoveryDownloaded') });
|
||||
} catch {
|
||||
toast({ title: 'Download failed', description: 'Please copy it manually instead.', variant: 'destructive' });
|
||||
toast({ title: t('settingsUi.security.downloadFailedTitle'), description: t('settingsUi.security.downloadFailedDesc'), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -75,9 +77,9 @@ export function SecuritySettings() {
|
|||
<Card className="border-dashed">
|
||||
<CardContent className="py-12 text-center">
|
||||
<Shield className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
|
||||
<h3 className="text-lg font-medium mb-2">Login Required</h3>
|
||||
<h3 className="text-lg font-medium mb-2">{t('settingsUi.security.loginRequired')}</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Log in to access security settings.
|
||||
{t('settingsUi.security.loginRequiredDesc')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -88,8 +90,8 @@ export function SecuritySettings() {
|
|||
setConfirmed(true);
|
||||
setShowWarning(false);
|
||||
toast({
|
||||
title: 'Backup Confirmed! ✅',
|
||||
description: 'Great! Your keys are safe.',
|
||||
title: t('settingsUi.security.backupConfirmedTitle'),
|
||||
description: t('settingsUi.security.backupConfirmedDesc'),
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -101,19 +103,19 @@ export function SecuritySettings() {
|
|||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-amber-400">
|
||||
<KeyRound className="h-5 w-5" />
|
||||
Your Recovery Code
|
||||
{t('settingsUi.security.yourRecoveryCode')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Didn't save it when you signed up? You can grab it here anytime.
|
||||
{t('settingsUi.security.recoveryCodeDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{recoveryCode ? (
|
||||
<>
|
||||
<p className="text-sm text-gray-300">
|
||||
This code is the <strong className="text-white">only</strong> way to log back in on a
|
||||
new device or browser. Save it somewhere safe (a password manager, or written on paper).
|
||||
Anyone who has it controls your account — never share it.
|
||||
{t('settingsUi.security.recoveryCodeBefore')}{' '}
|
||||
<strong className="text-white">{t('settingsUi.security.only')}</strong>{' '}
|
||||
{t('settingsUi.security.recoveryCodeAfter')}
|
||||
</p>
|
||||
|
||||
{!revealed ? (
|
||||
|
|
@ -122,7 +124,7 @@ export function SecuritySettings() {
|
|||
className="w-full bg-amber-500 hover:bg-amber-600 text-black font-bold"
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Reveal my recovery code
|
||||
{t('settingsUi.security.revealRecovery')}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
|
|
@ -138,7 +140,7 @@ export function SecuritySettings() {
|
|||
onClick={handleCopyRecovery}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5 mr-1.5" />
|
||||
Copy
|
||||
{t('common.copy')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -147,7 +149,7 @@ export function SecuritySettings() {
|
|||
onClick={handleDownloadRecovery}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
Save
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -166,13 +168,13 @@ export function SecuritySettings() {
|
|||
<QRCodeCanvas value={recoveryCode} size={200} level="M" />
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 text-center max-w-xs">
|
||||
Scan with another Nostr app to log in. Keep this screen private.
|
||||
{t('settingsUi.security.qrHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-amber-300/80">⚠️ Never share this. Hide it when you're done.</p>
|
||||
<p className="text-xs text-amber-300/80">{t('settingsUi.security.neverShareHide')}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
@ -180,7 +182,7 @@ export function SecuritySettings() {
|
|||
onClick={() => { setRevealed(false); setShowQr(false); }}
|
||||
>
|
||||
<EyeOff className="h-3.5 w-3.5 mr-1.5" />
|
||||
Hide
|
||||
{t('settingsUi.security.hide')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -191,16 +193,16 @@ export function SecuritySettings() {
|
|||
<Shield className="h-4 w-4 text-blue-400" />
|
||||
<AlertDescription className="text-sm text-gray-300">
|
||||
{loginType === 'x-amber' && (
|
||||
<>You're logged in with <strong className="text-white">Amber</strong>. Your recovery code lives inside Amber — open the Amber app to back it up. SkateSpots never sees your key.</>
|
||||
<>{t('settingsUi.security.amberNoteBefore')} <strong className="text-white">Amber</strong>{t('settingsUi.security.amberNoteAfter')}</>
|
||||
)}
|
||||
{loginType === 'bunker' && (
|
||||
<>You're logged in with a <strong className="text-white">remote signer (bunker)</strong>. Your recovery code is held by your signer service, not SkateSpots. Back it up there.</>
|
||||
<>{t('settingsUi.security.bunkerNoteBefore')} <strong className="text-white">{t('settingsUi.security.remoteSigner')}</strong>{t('settingsUi.security.bunkerNoteAfter')}</>
|
||||
)}
|
||||
{(loginType as string) === 'extension' && (
|
||||
<>You're logged in with a <strong className="text-white">browser extension</strong>. Your recovery code lives in the extension — use its own backup/export option. SkateSpots never sees your key.</>
|
||||
<>{t('settingsUi.security.extensionNoteBefore')} <strong className="text-white">{t('settingsUi.security.browserExtension')}</strong>{t('settingsUi.security.extensionNoteAfter')}</>
|
||||
)}
|
||||
{!loginType && (
|
||||
<>Your recovery code is managed by your signer app, not SkateSpots. Back it up there.</>
|
||||
<>{t('settingsUi.security.otherNote')}</>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
|
@ -215,11 +217,10 @@ export function SecuritySettings() {
|
|||
<AlertDescription>
|
||||
<div className="space-y-3">
|
||||
<p className="font-bold text-amber-400">
|
||||
⚠️ Have you backed up your recovery phrase or secret key?
|
||||
{t('settingsUi.security.backupWarningTitle')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-300">
|
||||
Your keys are the ONLY way to access your account. If you lose them, you lose everything -
|
||||
there's no password reset!
|
||||
{t('settingsUi.security.backupWarningBody')}
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleConfirmBackup}
|
||||
|
|
@ -227,7 +228,7 @@ export function SecuritySettings() {
|
|||
className="bg-amber-500 hover:bg-amber-600 text-black font-bold"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
I've Backed Up My Keys
|
||||
{t('settingsUi.security.iveBackedUp')}
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
|
|
@ -238,27 +239,26 @@ export function SecuritySettings() {
|
|||
<Card className={confirmed ? "bg-green-950/20 border-2 border-green-500/30" : "bg-red-950/20 border-2 border-red-500/30"}>
|
||||
<CardHeader>
|
||||
<CardTitle className={confirmed ? "text-green-400" : "text-red-400"}>
|
||||
{confirmed ? '✅ Backup Confirmed' : '⚠️ Backup Not Confirmed'}
|
||||
{confirmed ? t('settingsUi.security.backupConfirmed') : t('settingsUi.security.backupNotConfirmed')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{confirmed
|
||||
? 'You\'ve confirmed that your keys are safely backed up.'
|
||||
: 'Make sure to backup your recovery phrase or secret key!'}
|
||||
? t('settingsUi.security.backupConfirmedStatusDesc')
|
||||
: t('settingsUi.security.backupNotConfirmedDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{!confirmed && (
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
Haven't saved your recovery code yet? No problem — grab it from the
|
||||
<strong className="text-amber-400"> Your Recovery Code</strong> section above, store it
|
||||
somewhere safe, then confirm below.
|
||||
{t('settingsUi.security.notSavedYetBefore')}
|
||||
<strong className="text-amber-400"> {t('settingsUi.security.yourRecoveryCode')}</strong> {t('settingsUi.security.notSavedYetAfter')}
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleConfirmBackup}
|
||||
variant="outline"
|
||||
className="border-green-500/50 text-green-400 hover:bg-green-500/20"
|
||||
>
|
||||
Yes, I've Backed Up My Keys
|
||||
{t('settingsUi.security.yesBackedUp')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
)}
|
||||
|
|
@ -269,10 +269,10 @@ export function SecuritySettings() {
|
|||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-[#ccff00]">
|
||||
<Shield className="h-5 w-5" />
|
||||
Key Security Best Practices
|
||||
{t('settingsUi.security.bestPracticesTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
How to keep your Nostr account safe
|
||||
{t('settingsUi.security.bestPracticesDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
|
@ -280,53 +280,53 @@ export function SecuritySettings() {
|
|||
<div className="flex items-start gap-3">
|
||||
<div className="text-2xl">✍️</div>
|
||||
<div>
|
||||
<p className="font-bold text-white">Write it down on paper</p>
|
||||
<p className="text-gray-400">Physical backup is the safest. Store in a secure location like a safe.</p>
|
||||
<p className="font-bold text-white">{t('settingsUi.security.bp1Title')}</p>
|
||||
<p className="text-gray-400">{t('settingsUi.security.bp1Desc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-2xl">💾</div>
|
||||
<div>
|
||||
<p className="font-bold text-white">Multiple backups</p>
|
||||
<p className="text-gray-400">Keep copies in different secure locations (home safe, bank deposit box, etc.)</p>
|
||||
<p className="font-bold text-white">{t('settingsUi.security.bp2Title')}</p>
|
||||
<p className="text-gray-400">{t('settingsUi.security.bp2Desc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-2xl">🔒</div>
|
||||
<div>
|
||||
<p className="font-bold text-white">Never share your keys</p>
|
||||
<p className="text-gray-400">Real Nostr apps NEVER ask for your nsec or recovery phrase after initial login</p>
|
||||
<p className="font-bold text-white">{t('settingsUi.security.bp3Title')}</p>
|
||||
<p className="text-gray-400">{t('settingsUi.security.bp3Desc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-2xl">🚫</div>
|
||||
<div>
|
||||
<p className="font-bold text-white">Don't store in cloud unencrypted</p>
|
||||
<p className="text-gray-400">Avoid Google Docs, email drafts, or plain text cloud storage</p>
|
||||
<p className="font-bold text-white">{t('settingsUi.security.bp4Title')}</p>
|
||||
<p className="text-gray-400">{t('settingsUi.security.bp4Desc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-2xl">🔐</div>
|
||||
<div>
|
||||
<p className="font-bold text-white">Consider using a browser extension</p>
|
||||
<p className="text-gray-400">Alby, nos2x, or Flamingo keep your keys secure and never expose them to websites</p>
|
||||
<p className="font-bold text-white">{t('settingsUi.security.bp5Title')}</p>
|
||||
<p className="text-gray-400">{t('settingsUi.security.bp5Desc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-[#ccff00]/20">
|
||||
<h4 className="font-bold text-[#ff0040] mb-2 uppercase text-sm">
|
||||
⚠️ Important Reminders
|
||||
{t('settingsUi.security.importantReminders')}
|
||||
</h4>
|
||||
<ul className="text-xs text-gray-400 space-y-1">
|
||||
<li>• If you lose your keys, you lose your account permanently</li>
|
||||
<li>• No company or person can reset your password - not even us</li>
|
||||
<li>• Anyone with your keys has full control of your account</li>
|
||||
<li>• Your keys control all your Nostr data across ALL apps</li>
|
||||
<li>• {t('settingsUi.security.rem1')}</li>
|
||||
<li>• {t('settingsUi.security.rem2')}</li>
|
||||
<li>• {t('settingsUi.security.rem3')}</li>
|
||||
<li>• {t('settingsUi.security.rem4')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -335,9 +335,9 @@ export function SecuritySettings() {
|
|||
{/* Browser Extension & Mobile App Recommendations */}
|
||||
<Card className="bg-black/90 border-2 border-purple-500/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-purple-400">🔌 Recommended Nostr Signers</CardTitle>
|
||||
<CardTitle className="text-purple-400">{t('settingsUi.security.recommendedSigners')}</CardTitle>
|
||||
<CardDescription>
|
||||
Use a Nostr signer for better security and convenience
|
||||
{t('settingsUi.security.recommendedSignersDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -356,7 +356,7 @@ export function SecuritySettings() {
|
|||
<span className="text-xs px-2 py-0.5 bg-[#ccff00]/20 text-[#ccff00] rounded-full">Android</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Secure Nostr signer for Android. Keeps your keys safe on your phone and signs events without exposing them.
|
||||
{t('settingsUi.security.amberDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<ExternalLink className="h-4 w-4 text-gray-500" />
|
||||
|
|
@ -372,10 +372,10 @@ export function SecuritySettings() {
|
|||
<div className="flex-1">
|
||||
<div className="font-bold text-white flex items-center gap-2">
|
||||
Alby
|
||||
<span className="text-xs px-2 py-0.5 bg-green-500/20 text-green-400 rounded-full">Recommended</span>
|
||||
<span className="text-xs px-2 py-0.5 bg-green-500/20 text-green-400 rounded-full">{t('settingsUi.security.recommended')}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Full-featured extension with Lightning wallet, WebLN, and NWC support.
|
||||
{t('settingsUi.security.albyDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<ExternalLink className="h-4 w-4 text-gray-500" />
|
||||
|
|
@ -392,7 +392,7 @@ export function SecuritySettings() {
|
|||
<div className="flex-1">
|
||||
<div className="font-bold text-white">Flamingo</div>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Simple, clean interface. Focuses on Nostr signing without wallet features.
|
||||
{t('settingsUi.security.flamingoDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<ExternalLink className="h-4 w-4 text-gray-500" />
|
||||
|
|
@ -409,7 +409,7 @@ export function SecuritySettings() {
|
|||
<div className="flex-1">
|
||||
<div className="font-bold text-white">nos2x</div>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Minimal, lightweight extension for advanced users.
|
||||
{t('settingsUi.security.nos2xDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<ExternalLink className="h-4 w-4 text-gray-500" />
|
||||
|
|
@ -422,15 +422,14 @@ export function SecuritySettings() {
|
|||
{/* Nostr Connect (Bunkers) */}
|
||||
<Card className="bg-black/90 border-2 border-blue-500/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-blue-400">☁️ Nostr Connect (Remote Signers)</CardTitle>
|
||||
<CardTitle className="text-blue-400">{t('settingsUi.security.nostrConnectTitle')}</CardTitle>
|
||||
<CardDescription>
|
||||
Use a remote signer to access your account from any device
|
||||
{t('settingsUi.security.nostrConnectDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
Nostr Connect (also called "bunkers") lets you store your keys on a secure server and sign in from any device.
|
||||
Your keys never leave the server, but you can use your account anywhere!
|
||||
{t('settingsUi.security.nostrConnectBody')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
|
|
@ -444,10 +443,10 @@ export function SecuritySettings() {
|
|||
<div className="flex-1">
|
||||
<div className="font-bold text-white flex items-center gap-2">
|
||||
nsec.app
|
||||
<span className="text-xs px-2 py-0.5 bg-blue-500/20 text-blue-400 rounded-full">Popular</span>
|
||||
<span className="text-xs px-2 py-0.5 bg-blue-500/20 text-blue-400 rounded-full">{t('common.popular')}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Free, secure remote signer. Create bunker:// URI and paste when logging in.
|
||||
{t('settingsUi.security.nsecAppDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<ExternalLink className="h-4 w-4 text-gray-500" />
|
||||
|
|
@ -464,7 +463,7 @@ export function SecuritySettings() {
|
|||
<div className="flex-1">
|
||||
<div className="font-bold text-white">Nostrum</div>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Another remote signer option with mobile support.
|
||||
{t('settingsUi.security.nostrumDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<ExternalLink className="h-4 w-4 text-gray-500" />
|
||||
|
|
@ -473,8 +472,7 @@ export function SecuritySettings() {
|
|||
</div>
|
||||
|
||||
<div className="mt-4 p-3 rounded-lg bg-blue-950/30 border border-blue-500/30 text-sm text-gray-300">
|
||||
<p><strong className="text-blue-400">💡 How to use:</strong> Create an account on one of these services,
|
||||
get your bunker:// connection string, then paste it in the "Nostr Connect" tab when logging in.</p>
|
||||
<p><strong className="text-blue-400">{t('settingsUi.security.howToUse')}</strong> {t('settingsUi.security.howToUseDesc')}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { Check, Copy, Share2, Loader2 } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { generateQRCode } from '@/lib/wallet/qrCodeGenerator';
|
||||
|
||||
interface ShareProfileDialogProps {
|
||||
|
|
@ -21,6 +22,7 @@ interface ShareProfileDialogProps {
|
|||
* sheet isn't available.
|
||||
*/
|
||||
export function ShareProfileDialog({ open, onOpenChange, npub, displayName }: ShareProfileDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [qr, setQr] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
|
@ -52,7 +54,7 @@ export function ShareProfileDialog({ open, onOpenChange, npub, displayName }: Sh
|
|||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast({ title: 'Copy failed', description: 'Could not copy the link.', variant: 'destructive' });
|
||||
toast({ title: t('walletUi.share.copyFailed'), description: t('walletUi.share.copyFailedDesc'), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -76,10 +78,10 @@ export function ShareProfileDialog({ open, onOpenChange, npub, displayName }: Sh
|
|||
<DialogHeader className="min-w-0">
|
||||
<DialogTitle className="flex items-center gap-2 uppercase font-black">
|
||||
<Share2 className="h-5 w-5 text-[#ff0040] flex-shrink-0" />
|
||||
Share profile
|
||||
{t('walletUi.share.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
Point a phone camera at the code to open {displayName}'s profile.
|
||||
{t('walletUi.share.desc', { name: displayName })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -87,7 +89,7 @@ export function ShareProfileDialog({ open, onOpenChange, npub, displayName }: Sh
|
|||
<div className="flex flex-col items-center gap-4 py-2 min-w-0 w-full">
|
||||
<div className="rounded-2xl bg-white p-3 shadow-lg">
|
||||
{qr ? (
|
||||
<img src={qr} alt="Profile QR code" className="w-48 h-48 sm:w-52 sm:h-52" />
|
||||
<img src={qr} alt={t('walletUi.share.qrAlt')} className="w-48 h-48 sm:w-52 sm:h-52" />
|
||||
) : (
|
||||
<div className="w-48 h-48 sm:w-52 sm:h-52 flex items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
|
||||
|
|
@ -112,12 +114,12 @@ export function ShareProfileDialog({ open, onOpenChange, npub, displayName }: Sh
|
|||
|
||||
<div className="grid grid-cols-1 gap-2 w-full">
|
||||
<Button onClick={handleCopy} variant="outline" className="w-full border-white/15 font-bold">
|
||||
{copied ? <><Check className="h-4 w-4 mr-2 text-[#ccff00]" />Copied!</> : <><Copy className="h-4 w-4 mr-2" />Copy link</>}
|
||||
{copied ? <><Check className="h-4 w-4 mr-2 text-[#ccff00]" />{t('common.copied')}</> : <><Copy className="h-4 w-4 mr-2" />{t('walletUi.share.copyLink')}</>}
|
||||
</Button>
|
||||
{canNativeShare && (
|
||||
<Button onClick={handleNativeShare} className="w-full bg-[#ff0040] hover:bg-[#ff0040]/90 font-black uppercase">
|
||||
<Share2 className="h-4 w-4 mr-2" />
|
||||
Share…
|
||||
{t('walletUi.share.shareEllipsis')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Card, CardContent } from '@/components/ui/card';
|
|||
|
||||
import { Zap, Check, Circle, Camera, FileText, MapPin, Phone, Clock } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
interface ShopBountyCardProps {
|
||||
// Current form state to calculate potential reward
|
||||
|
|
@ -33,52 +34,53 @@ export function ShopBountyCard({
|
|||
hasHours,
|
||||
photoCount,
|
||||
}: ShopBountyCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const checks: QualityCheckItem[] = [
|
||||
{
|
||||
id: 'name',
|
||||
label: 'Shop name',
|
||||
label: t('placesUi.bounty.shopName'),
|
||||
points: 15,
|
||||
completed: hasName,
|
||||
icon: MapPin,
|
||||
},
|
||||
{
|
||||
id: 'location',
|
||||
label: 'Address & coordinates',
|
||||
label: t('placesUi.bounty.addressCoords'),
|
||||
points: 20,
|
||||
completed: hasAddress && hasCoordinates,
|
||||
icon: MapPin,
|
||||
},
|
||||
{
|
||||
id: 'photo1',
|
||||
label: 'At least 1 photo',
|
||||
label: t('placesUi.bounty.onePhoto'),
|
||||
points: 15,
|
||||
completed: photoCount >= 1,
|
||||
icon: Camera,
|
||||
},
|
||||
{
|
||||
id: 'photo3',
|
||||
label: '3+ photos',
|
||||
label: t('placesUi.bounty.threePhotos'),
|
||||
points: 10,
|
||||
completed: photoCount >= 3,
|
||||
icon: Camera,
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
label: 'Description',
|
||||
label: t('placesUi.bounty.description'),
|
||||
points: 15,
|
||||
completed: hasDescription,
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
id: 'contact',
|
||||
label: 'Contact info',
|
||||
label: t('placesUi.bounty.contactInfo'),
|
||||
points: 10,
|
||||
completed: hasPhone || hasWebsite,
|
||||
icon: Phone,
|
||||
},
|
||||
{
|
||||
id: 'hours',
|
||||
label: 'Opening hours',
|
||||
label: t('placesUi.bounty.openingHours'),
|
||||
points: 15,
|
||||
completed: hasHours,
|
||||
icon: Clock,
|
||||
|
|
@ -105,10 +107,10 @@ export function ShopBountyCard({
|
|||
};
|
||||
|
||||
const getScoreLabel = () => {
|
||||
if (percentage >= 80) return 'Excellent!';
|
||||
if (percentage >= 50) return 'Good';
|
||||
if (percentage >= 30) return 'Basic';
|
||||
return 'Getting started';
|
||||
if (percentage >= 80) return t('placesUi.bounty.excellent');
|
||||
if (percentage >= 50) return t('placesUi.bounty.good');
|
||||
if (percentage >= 30) return t('placesUi.bounty.basic');
|
||||
return t('placesUi.bounty.gettingStarted');
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -117,7 +119,7 @@ export function ShopBountyCard({
|
|||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-[#ccff00]" />
|
||||
<span className="text-sm font-bold uppercase text-gray-300">Earning Potential</span>
|
||||
<span className="text-sm font-bold uppercase text-gray-300">{t('placesUi.bounty.earningPotential')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('text-sm font-bold', getScoreColor())}>
|
||||
|
|
@ -174,7 +176,7 @@ export function ShopBountyCard({
|
|||
{/* Tip */}
|
||||
<div className="mt-4 pt-3 border-t border-gray-800">
|
||||
<p className="text-[10px] text-gray-500">
|
||||
💡 Higher quality = More zaps from the community!
|
||||
{t('placesUi.bounty.tip')}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
validateSkateNip05Name,
|
||||
} from '@/lib/skateNip05';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
type FieldStatus = 'idle' | 'checking' | 'available' | 'taken' | 'invalid' | 'error' | 'claimed';
|
||||
|
||||
|
|
@ -44,6 +45,7 @@ export const SkateNip05Input: React.FC<SkateNip05InputProps> = ({
|
|||
className,
|
||||
}) => {
|
||||
const { checkAvailability, claim } = useSkateNip05();
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<FieldStatus>('idle');
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [claiming, setClaiming] = useState(false);
|
||||
|
|
@ -114,7 +116,7 @@ export const SkateNip05Input: React.FC<SkateNip05InputProps> = ({
|
|||
id={id}
|
||||
value={handle}
|
||||
onChange={(e) => onHandleChange(e.target.value)}
|
||||
placeholder="yourname"
|
||||
placeholder={t('settingsUi.skateNip05.placeholder')}
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
|
|
@ -132,19 +134,19 @@ export const SkateNip05Input: React.FC<SkateNip05InputProps> = ({
|
|||
<p className="flex items-start gap-1.5 text-xs text-green-600 dark:text-green-400">
|
||||
<BadgeCheck className="h-3.5 w-3.5 shrink-0 mt-px" />
|
||||
<span>
|
||||
<span className="font-semibold">{claimedHandle}@{SKATE_NIP05_DOMAIN}</span> is yours — save your profile to publish it.
|
||||
<span className="font-semibold">{claimedHandle}@{SKATE_NIP05_DOMAIN}</span> {t('settingsUi.skateNip05.claimedSuffix')}
|
||||
</span>
|
||||
</p>
|
||||
) : status === 'checking' ? (
|
||||
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Checking availability…
|
||||
{t('settingsUi.skateNip05.checking')}
|
||||
</p>
|
||||
) : status === 'available' ? (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
|
||||
<CheckCircle className="h-3.5 w-3.5" />
|
||||
{validation.name}@{SKATE_NIP05_DOMAIN} is available
|
||||
{t('settingsUi.skateNip05.available', { name: validation.name, domain: SKATE_NIP05_DOMAIN })}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -153,7 +155,7 @@ export const SkateNip05Input: React.FC<SkateNip05InputProps> = ({
|
|||
disabled={claiming || disabled}
|
||||
className="h-7 rounded-full bg-[#ccff00] px-3 text-xs font-bold text-black hover:bg-[#ccff00]/90"
|
||||
>
|
||||
{claiming ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Claim it'}
|
||||
{claiming ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : t('settingsUi.skateNip05.claim')}
|
||||
</Button>
|
||||
</div>
|
||||
) : status === 'invalid' || status === 'taken' || status === 'error' ? (
|
||||
|
|
@ -163,7 +165,7 @@ export const SkateNip05Input: React.FC<SkateNip05InputProps> = ({
|
|||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Letters, numbers, dashes. Free, and shown as a ✓ badge on your profile.
|
||||
{t('settingsUi.skateNip05.hint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { ArrowUpDown, Clock, TrendingUp, MapPin, Zap, Shield } from 'lucide-react';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
export type SortOption =
|
||||
| 'recent'
|
||||
|
|
@ -19,29 +20,29 @@ interface SortDropdownProps {
|
|||
|
||||
const SORT_OPTIONS = {
|
||||
recent: {
|
||||
label: 'Most Recent',
|
||||
labelKey: 'chromeUi.sort.recent',
|
||||
descKey: 'chromeUi.sort.recentDesc',
|
||||
icon: Clock,
|
||||
description: 'Newest first',
|
||||
},
|
||||
trust: {
|
||||
label: 'Highest Trust',
|
||||
labelKey: 'chromeUi.sort.trust',
|
||||
descKey: 'chromeUi.sort.trustDesc',
|
||||
icon: Shield,
|
||||
description: 'Most trusted creators',
|
||||
},
|
||||
proximity: {
|
||||
label: 'Closest to Me',
|
||||
labelKey: 'chromeUi.sort.proximity',
|
||||
descKey: 'chromeUi.sort.proximityDesc',
|
||||
icon: MapPin,
|
||||
description: 'Nearest locations',
|
||||
},
|
||||
popular: {
|
||||
label: 'Most Popular',
|
||||
labelKey: 'chromeUi.sort.popular',
|
||||
descKey: 'chromeUi.sort.popularDesc',
|
||||
icon: TrendingUp,
|
||||
description: 'Most comments/reactions',
|
||||
},
|
||||
zaps: {
|
||||
label: 'Most Zapped',
|
||||
labelKey: 'chromeUi.sort.zaps',
|
||||
descKey: 'chromeUi.sort.zapsDesc',
|
||||
icon: Zap,
|
||||
description: 'Highest value received',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -51,6 +52,7 @@ export function SortDropdown({
|
|||
className,
|
||||
showProximity = false,
|
||||
}: SortDropdownProps) {
|
||||
const { t } = useTranslation();
|
||||
const selectedOption = SORT_OPTIONS[value];
|
||||
const Icon = selectedOption.icon;
|
||||
|
||||
|
|
@ -74,7 +76,7 @@ export function SortDropdown({
|
|||
<SelectValue>
|
||||
<span className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4" />
|
||||
{selectedOption.label}
|
||||
{t(selectedOption.labelKey)}
|
||||
</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
|
@ -88,8 +90,8 @@ export function SortDropdown({
|
|||
<div className="flex items-center gap-2 w-full">
|
||||
<OptionIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{opt.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{opt.description}</div>
|
||||
<div className="font-medium">{t(opt.labelKey)}</div>
|
||||
<div className="text-xs text-muted-foreground">{t(opt.descKey)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import 'leaflet.markercluster';
|
|||
import ngeohash from 'ngeohash';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { SkateSpot, MarketplaceItem, SkateTour, SkateEvent, SkateCrew } from '@/lib/types';
|
||||
import { SPOT_TYPE_LABELS, formatPrice } from '@/lib/types';
|
||||
import { formatPrice } from '@/lib/types';
|
||||
import { spotTypeLabel } from '@/lib/spotTypeLabel';
|
||||
import type { SpotWeatherData } from '@/hooks/useSpotWeather';
|
||||
import type { BtcMapPlace } from '@/hooks/useBtcMapPlaces';
|
||||
import { isRecentlyVerified, isBoosted } from '@/hooks/useBtcMapPlaces';
|
||||
|
|
@ -644,7 +645,7 @@ function createPopupContent(
|
|||
spot.types.slice(0, 3).forEach((type) => {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold bg-gray-100 text-gray-800';
|
||||
badge.textContent = SPOT_TYPE_LABELS[type] || type;
|
||||
badge.textContent = spotTypeLabel(t, type);
|
||||
typesContainer.appendChild(badge);
|
||||
});
|
||||
container.appendChild(typesContainer);
|
||||
|
|
@ -702,7 +703,7 @@ function createEnhancedPopupContent(
|
|||
spot.types.slice(0, 3).forEach((type) => {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold bg-gray-800 text-gray-200 border-gray-600';
|
||||
badge.textContent = SPOT_TYPE_LABELS[type] || type;
|
||||
badge.textContent = spotTypeLabel(t, type);
|
||||
typesContainer.appendChild(badge);
|
||||
});
|
||||
spotSection.appendChild(typesContainer);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ import { ReportDialog } from '@/components/ReportDialog';
|
|||
import type { SkateTour, TourCategory } from '@/lib/types';
|
||||
import { TOUR_CATEGORIES, TOUR_SKILL_LEVELS } from '@/lib/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
interface TourCardProps {
|
||||
tour: SkateTour;
|
||||
|
|
@ -39,16 +42,16 @@ function _getCategoryIcon(category: TourCategory) {
|
|||
}
|
||||
}
|
||||
|
||||
function formatDuration(duration?: number, unit?: string): string {
|
||||
function formatDuration(duration: number | undefined, unit: string | undefined, t: Translate): string {
|
||||
if (!duration) return '';
|
||||
|
||||
switch (unit) {
|
||||
case 'hours':
|
||||
return `${duration} hr${duration > 1 ? 's' : ''}`;
|
||||
return t('placesUi.tourCard.durationHours', { count: duration });
|
||||
case 'days':
|
||||
return `${duration} day${duration > 1 ? 's' : ''}`;
|
||||
return t('placesUi.tourCard.durationDays', { count: duration });
|
||||
case 'weeks':
|
||||
return duration >= 4 ? `${Math.floor(duration / 4)} month${Math.floor(duration / 4) > 1 ? 's' : ''}` : `${duration} week${duration > 1 ? 's' : ''}`;
|
||||
return duration >= 4 ? t('placesUi.tourCard.durationMonths', { count: Math.floor(duration / 4) }) : t('placesUi.tourCard.durationWeeks', { count: duration });
|
||||
default:
|
||||
return `${duration}`;
|
||||
}
|
||||
|
|
@ -84,7 +87,8 @@ function formatDateRange(startDate?: number, endDate?: number, withTime = false)
|
|||
* date. Returns null when there's no date or the tour is well in the past.
|
||||
*/
|
||||
function getCountdown(
|
||||
tour: SkateTour
|
||||
tour: SkateTour,
|
||||
t: Translate,
|
||||
): { label: string; color: string; live?: boolean } | null {
|
||||
if (!tour.startDate) return null;
|
||||
|
||||
|
|
@ -95,13 +99,13 @@ function getCountdown(
|
|||
|
||||
// Currently running (has an end date and we're inside the window).
|
||||
if (start <= nowSecs && end && nowSecs <= end) {
|
||||
return { label: 'Happening now', color: '#ff0040', live: true };
|
||||
return { label: t('placesUi.tourCard.happeningNow'), color: '#ff0040', live: true };
|
||||
}
|
||||
|
||||
// Already started but single-session / no end — treat same-day as live, else past.
|
||||
if (start <= nowSecs) {
|
||||
if (nowSecs - start < 12 * 60 * 60) {
|
||||
return { label: 'Happening now', color: '#ff0040', live: true };
|
||||
return { label: t('placesUi.tourCard.happeningNow'), color: '#ff0040', live: true };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -110,35 +114,36 @@ function getCountdown(
|
|||
const days = Math.floor(diffSecs / 86400);
|
||||
const hours = Math.floor(diffSecs / 3600);
|
||||
|
||||
if (hours < 1) return { label: 'Starting soon', color: accent, live: true };
|
||||
if (hours < 24) return { label: `In ${hours}h`, color: accent };
|
||||
if (days === 1) return { label: 'Tomorrow', color: accent };
|
||||
if (days < 7) return { label: `In ${days} days`, color: accent };
|
||||
if (hours < 1) return { label: t('placesUi.tourCard.startingSoon'), color: accent, live: true };
|
||||
if (hours < 24) return { label: t('placesUi.tourCard.inHours', { count: hours }), color: accent };
|
||||
if (days === 1) return { label: t('placesUi.tourCard.tomorrow'), color: accent };
|
||||
if (days < 7) return { label: t('placesUi.tourCard.inDays', { count: days }), color: accent };
|
||||
if (days < 30) {
|
||||
const w = Math.round(days / 7);
|
||||
return { label: `In ${w} week${w > 1 ? 's' : ''}`, color: accent };
|
||||
return { label: t('placesUi.tourCard.inWeeks', { count: w }), color: accent };
|
||||
}
|
||||
const m = Math.round(days / 30);
|
||||
return { label: `In ${m} month${m > 1 ? 's' : ''}`, color: accent };
|
||||
return { label: t('placesUi.tourCard.inMonths', { count: m }), color: accent };
|
||||
}
|
||||
|
||||
function getTourStatus(tour: SkateTour): { label: string; color: string } | null {
|
||||
function getTourStatus(tour: SkateTour, t: Translate): { label: string; color: string } | null {
|
||||
if (tour.category !== 'destruction') return null;
|
||||
|
||||
const now = Date.now() / 1000;
|
||||
|
||||
if (tour.startDate && now < tour.startDate) {
|
||||
return { label: 'Registration Open', color: '#ccff00' };
|
||||
return { label: t('placesUi.tourCard.registrationOpen'), color: '#ccff00' };
|
||||
} else if (tour.startDate && tour.endDate && now >= tour.startDate && now <= tour.endDate) {
|
||||
return { label: 'Active', color: '#ff0040' };
|
||||
return { label: t('placesUi.tourCard.active'), color: '#ff0040' };
|
||||
} else if (tour.endDate && now > tour.endDate) {
|
||||
return { label: 'Completed', color: '#888' };
|
||||
return { label: t('placesUi.tourCard.completed'), color: '#888' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function TourCard({ tour, compact = false }: TourCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const author = useAuthor(tour.pubkey);
|
||||
const { user } = useCurrentUser();
|
||||
const metadata = author.data?.metadata;
|
||||
|
|
@ -147,13 +152,13 @@ export function TourCard({ tour, compact = false }: TourCardProps) {
|
|||
|
||||
const categoryInfo = TOUR_CATEGORIES[tour.category];
|
||||
const skillInfo = tour.skillLevel ? TOUR_SKILL_LEVELS[tour.skillLevel as keyof typeof TOUR_SKILL_LEVELS] : null;
|
||||
const tourStatus = getTourStatus(tour);
|
||||
const countdown = getCountdown(tour);
|
||||
const tourStatus = getTourStatus(tour, t);
|
||||
const countdown = getCountdown(tour, t);
|
||||
const isOwner = user?.pubkey === tour.pubkey;
|
||||
|
||||
// Get location text based on category
|
||||
const locationText = tour.category === 'destruction' || tour.category === 'international'
|
||||
? tour.countries?.join(', ') || tour.cities?.join(', ') || 'Worldwide'
|
||||
? tour.countries?.join(', ') || tour.cities?.join(', ') || t('placesUi.tourCard.worldwide')
|
||||
: tour.location || tour.cities?.join(', ');
|
||||
|
||||
if (compact) {
|
||||
|
|
@ -228,7 +233,7 @@ export function TourCard({ tour, compact = false }: TourCardProps) {
|
|||
{tour.duration && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" style={{ color: categoryInfo.color }} />
|
||||
{formatDuration(tour.duration, tour.durationUnit)}
|
||||
{formatDuration(tour.duration, tour.durationUnit, t)}
|
||||
</span>
|
||||
)}
|
||||
{locationText && (
|
||||
|
|
@ -329,7 +334,7 @@ export function TourCard({ tour, compact = false }: TourCardProps) {
|
|||
{tour.prizePool && (
|
||||
<Badge className="bg-orange-500/90 backdrop-blur-sm shadow-lg font-bold text-xs">
|
||||
<Trophy className="h-3 w-3 mr-1" />
|
||||
{tour.prizePool.toLocaleString()} sats
|
||||
{t('placesUi.tourCard.prizePool', { amount: tour.prizePool.toLocaleString() })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -381,19 +386,19 @@ export function TourCard({ tour, compact = false }: TourCardProps) {
|
|||
{tour.duration && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4" style={{ color: categoryInfo.color }} />
|
||||
{formatDuration(tour.duration, tour.durationUnit)}
|
||||
{formatDuration(tour.duration, tour.durationUnit, t)}
|
||||
</span>
|
||||
)}
|
||||
{tour.maxGroupSize && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4 text-[#ff6600]" />
|
||||
Max {tour.maxGroupSize}
|
||||
{t('placesUi.tourCard.maxGroup', { count: tour.maxGroupSize })}
|
||||
</span>
|
||||
)}
|
||||
{tour.maxTeams && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4 text-[#ff6600]" />
|
||||
{tour.maxTeams} teams max
|
||||
{t('placesUi.tourCard.teamsMax', { count: tour.maxTeams })}
|
||||
</span>
|
||||
)}
|
||||
{skillInfo && (
|
||||
|
|
@ -425,16 +430,16 @@ export function TourCard({ tour, compact = false }: TourCardProps) {
|
|||
{tour.category === 'destruction' && tour.entryFee ? (
|
||||
<Badge variant="outline" className="border-orange-500/50 text-orange-400">
|
||||
<Zap className="h-3 w-3 mr-1" />
|
||||
Entry: {tour.entryFee.toLocaleString()} sats
|
||||
{t('placesUi.tourCard.entry', { amount: tour.entryFee.toLocaleString() })}
|
||||
</Badge>
|
||||
) : tour.price ? (
|
||||
<Badge variant="outline" className="border-orange-500/50 text-orange-400">
|
||||
<Zap className="h-3 w-3 mr-1" />
|
||||
{tour.price.toLocaleString()} sats
|
||||
{t('placesUi.tourCard.price', { amount: tour.price.toLocaleString() })}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="border-green-500/50 text-green-400">
|
||||
Contact for price
|
||||
{t('placesUi.tourCard.contactForPrice')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -463,7 +468,7 @@ export function TourCard({ tour, compact = false }: TourCardProps) {
|
|||
className="text-red-500 focus:text-red-500"
|
||||
>
|
||||
<Flag className="h-4 w-4 mr-2" />
|
||||
Report
|
||||
{t('placesUi.eventCard.report')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
|||
import { Shield, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { useTrustScore, getTrustBadgeConfig } from '@/hooks/useTrustScore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
interface TrustBadgeProps {
|
||||
pubkey: string | undefined;
|
||||
|
|
@ -22,6 +23,7 @@ interface TrustBadgeProps {
|
|||
* - 0: Unknown/unverified
|
||||
*/
|
||||
export function TrustBadge({ pubkey, className, showLabel = true, size = 'sm' }: TrustBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: trustScore, isLoading } = useTrustScore(pubkey);
|
||||
const badge = getTrustBadgeConfig(trustScore);
|
||||
|
||||
|
|
@ -42,12 +44,12 @@ export function TrustBadge({ pubkey, className, showLabel = true, size = 'sm' }:
|
|||
};
|
||||
|
||||
const getTooltipText = (score: number | undefined) => {
|
||||
if (score === undefined) return 'Trust score unavailable';
|
||||
if (score === 100) return 'This is your content';
|
||||
if (score >= 90) return 'You follow this user';
|
||||
if (score >= 60) return 'Friend of a friend (2 hops)';
|
||||
if (score >= 30) return 'NIP-05 verified user';
|
||||
return 'Unknown user';
|
||||
if (score === undefined) return t('walletUi.trust.unavailable');
|
||||
if (score === 100) return t('walletUi.trust.yourContent');
|
||||
if (score >= 90) return t('walletUi.trust.following');
|
||||
if (score >= 60) return t('walletUi.trust.friendOfFriend');
|
||||
if (score >= 30) return t('walletUi.trust.nip05');
|
||||
return t('walletUi.trust.unknown');
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -68,7 +70,7 @@ export function TrustBadge({ pubkey, className, showLabel = true, size = 'sm' }:
|
|||
<div className="space-y-1">
|
||||
<p className="font-bold">{getTooltipText(trustScore)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Trust scores help identify reliable contributors in the skateboarding community.
|
||||
{t('walletUi.trust.help')}
|
||||
</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
interface TrustFilterProps {
|
||||
value: TrustFilterLevel;
|
||||
|
|
@ -22,35 +23,36 @@ interface TrustFilterProps {
|
|||
const TRUST_LEVELS = [
|
||||
{
|
||||
value: 'all' as const,
|
||||
label: 'All Users',
|
||||
description: 'Show content from everyone',
|
||||
labelKey: 'chromeUi.trust.all',
|
||||
descKey: 'chromeUi.trust.allDesc',
|
||||
icon: Users,
|
||||
color: 'text-gray-400',
|
||||
},
|
||||
{
|
||||
value: 'verified' as const,
|
||||
label: 'Verified',
|
||||
description: 'You follow them or they are verified (trust ≥90)',
|
||||
labelKey: 'chromeUi.trust.verified',
|
||||
descKey: 'chromeUi.trust.verifiedDesc',
|
||||
icon: CheckCircle2,
|
||||
color: 'text-green-500',
|
||||
},
|
||||
{
|
||||
value: 'trusted' as const,
|
||||
label: 'Trusted',
|
||||
description: 'Friends-of-friends or NIP-05 verified (trust ≥60)',
|
||||
labelKey: 'chromeUi.trust.trusted',
|
||||
descKey: 'chromeUi.trust.trustedDesc',
|
||||
icon: Shield,
|
||||
color: 'text-yellow-500',
|
||||
},
|
||||
{
|
||||
value: 'following' as const,
|
||||
label: 'Following Only',
|
||||
description: 'Only show content from people you follow',
|
||||
labelKey: 'chromeUi.trust.following',
|
||||
descKey: 'chromeUi.trust.followingDesc',
|
||||
icon: CheckCircle2,
|
||||
color: 'text-green-500',
|
||||
},
|
||||
];
|
||||
|
||||
export function TrustFilter({ value, onChange, className, compact = false }: TrustFilterProps) {
|
||||
const { t } = useTranslation();
|
||||
const currentLevel = TRUST_LEVELS.find((l) => l.value === value) || TRUST_LEVELS[0];
|
||||
const Icon = currentLevel.icon;
|
||||
|
||||
|
|
@ -64,16 +66,16 @@ export function TrustFilter({ value, onChange, className, compact = false }: Tru
|
|||
className={cn("gap-2", className)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
Trust Filter
|
||||
{t('chromeUi.trustFilter')}
|
||||
{value !== 'all' && (
|
||||
<Badge variant="secondary" className="ml-1 px-1.5 py-0 text-[10px]">
|
||||
{value === 'verified' ? '≥90' : value === 'trusted' ? '≥60' : 'Following'}
|
||||
{value === 'verified' ? '≥90' : value === 'trusted' ? '≥60' : t('chromeUi.trust.followingShort')}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-72">
|
||||
<DropdownMenuLabel>Filter by Trust Level</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>{t('chromeUi.filterByTrustLevel')}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{TRUST_LEVELS.map((level) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
|
|
@ -84,9 +86,9 @@ export function TrustFilter({ value, onChange, className, compact = false }: Tru
|
|||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<level.icon className={cn("h-4 w-4", level.color)} />
|
||||
<span className="font-bold">{level.label}</span>
|
||||
<span className="font-bold">{t(level.labelKey)}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground pl-6">{level.description}</p>
|
||||
<p className="text-xs text-muted-foreground pl-6">{t(level.descKey)}</p>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
|
|
@ -98,7 +100,7 @@ export function TrustFilter({ value, onChange, className, compact = false }: Tru
|
|||
<div className={cn("space-y-3", className)}>
|
||||
<label className="text-sm font-bold text-gray-300 uppercase tracking-wide flex items-center gap-2">
|
||||
<Shield className="h-4 w-4 text-[#ccff00]" />
|
||||
Trust Filter
|
||||
{t('chromeUi.trustFilter')}
|
||||
</label>
|
||||
<div className="grid gap-2">
|
||||
{TRUST_LEVELS.map((level) => {
|
||||
|
|
@ -118,8 +120,8 @@ export function TrustFilter({ value, onChange, className, compact = false }: Tru
|
|||
>
|
||||
<Icon className={cn("h-5 w-5 flex-shrink-0 mt-0.5", isActive ? level.color : "text-gray-500")} />
|
||||
<div className="flex-1">
|
||||
<div className="font-bold text-white mb-1">{level.label}</div>
|
||||
<p className="text-xs text-gray-400">{level.description}</p>
|
||||
<div className="font-bold text-white mb-1">{t(level.labelKey)}</div>
|
||||
<p className="text-xs text-gray-400">{t(level.descKey)}</p>
|
||||
</div>
|
||||
{isActive && (
|
||||
<CheckCircle2 className="h-5 w-5 text-[#ccff00] flex-shrink-0" />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useUserStatus, SKATING_STATUSES } from '@/hooks/useUserStatus';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
|
@ -16,6 +17,7 @@ export function UserStatusIndicator({
|
|||
showLabel = false,
|
||||
size = 'sm',
|
||||
}: UserStatusIndicatorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: status, isLoading } = useUserStatus(pubkey);
|
||||
|
||||
if (isLoading || !status) return null;
|
||||
|
|
@ -78,12 +80,12 @@ export function UserStatusIndicator({
|
|||
<p className="font-bold">{label}</p>
|
||||
{status.linkedSpot && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
📍 At a spot
|
||||
{t('socialUi.userStatus.atSpot')}
|
||||
</p>
|
||||
)}
|
||||
{status.expiration && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
⏱️ Auto-clears in {Math.round((status.expiration - Date.now() / 1000) / 60)} min
|
||||
{t('socialUi.userStatus.autoClears', { min: Math.round((status.expiration - Date.now() / 1000) / 60) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useVouches, useVouchFor } from '@/hooks/useVouches';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
|
|
@ -62,6 +63,7 @@ export function VouchCard({
|
|||
accentText = 'text-teal-400',
|
||||
accentBg = 'bg-teal-500 hover:bg-teal-600',
|
||||
}: VouchCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { data, isLoading } = useVouches(coord);
|
||||
const { mutateAsync: vouch, isPending } = useVouchFor();
|
||||
|
|
@ -81,15 +83,15 @@ export function VouchCard({
|
|||
try {
|
||||
await vouch({ coord, providerName, retract: data.viewerVouched });
|
||||
toast({
|
||||
title: data.viewerVouched ? 'Vouch removed' : 'Vouched!',
|
||||
title: data.viewerVouched ? t('socialUi.vouch.removedTitle') : t('socialUi.vouch.vouchedTitle'),
|
||||
description: data.viewerVouched
|
||||
? `You no longer vouch for ${providerName}.`
|
||||
: `You vouched for ${providerName}. The scene trusts its own.`,
|
||||
? t('socialUi.vouch.removedDesc', { name: providerName })
|
||||
: t('socialUi.vouch.vouchedDesc', { name: providerName }),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Could not vouch',
|
||||
description: error instanceof Error ? error.message : 'Please try again.',
|
||||
title: t('socialUi.vouch.couldNotVouch'),
|
||||
description: error instanceof Error ? error.message : t('common.tryAgain'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -100,31 +102,30 @@ export function VouchCard({
|
|||
<CardContent className="space-y-3 p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="flex items-center gap-2 text-xs font-black uppercase tracking-widest text-gray-400">
|
||||
<HeartHandshake className={cn('h-4 w-4', accentText)} /> Scene Vouches
|
||||
<HeartHandshake className={cn('h-4 w-4', accentText)} /> {t('socialUi.vouch.title')}
|
||||
</p>
|
||||
{tier === 'verified' && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-green-500/15 px-2 py-0.5 text-xs font-bold text-green-400">
|
||||
<ShieldCheck className="h-3 w-3" /> Scene-verified
|
||||
<ShieldCheck className="h-3 w-3" /> {t('socialUi.vouch.sceneVerified')}
|
||||
</span>
|
||||
)}
|
||||
{tier === 'vouched' && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/15 px-2 py-0.5 text-xs font-bold text-amber-400">
|
||||
<Users className="h-3 w-3" /> Vouched
|
||||
<Users className="h-3 w-3" /> {t('socialUi.vouch.vouched')}
|
||||
</span>
|
||||
)}
|
||||
{tier === 'new' && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-white/5 px-2 py-0.5 text-xs font-bold text-gray-500">
|
||||
New · unvouched
|
||||
{t('socialUi.vouch.newUnvouched')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-xs text-gray-500">Checking the scene…</p>
|
||||
<p className="text-xs text-gray-500">{t('socialUi.vouch.checking')}</p>
|
||||
) : data.count === 0 ? (
|
||||
<p className="text-xs leading-relaxed text-gray-500">
|
||||
No vouches yet. Vouches from skaters in your circles are the best signal that
|
||||
someone's the real deal — not a random who wandered in.
|
||||
{t('socialUi.vouch.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
|
|
@ -135,12 +136,14 @@ export function VouchCard({
|
|||
))}
|
||||
</div>
|
||||
<span className="text-sm font-bold text-gray-300">
|
||||
{data.count} vouch{data.count !== 1 ? 'es' : ''}
|
||||
{data.count === 1
|
||||
? t('socialUi.vouch.countOne', { count: data.count })
|
||||
: t('socialUi.vouch.countMany', { count: data.count })}
|
||||
</span>
|
||||
</div>
|
||||
{data.trustedVouchers > 0 && (
|
||||
<p className="text-xs text-green-500/80">
|
||||
🛡 {data.trustedVouchers} from skaters in your web of trust
|
||||
🛡 {t('socialUi.vouch.trusted', { count: data.trustedVouchers })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -162,12 +165,12 @@ export function VouchCard({
|
|||
) : (
|
||||
<HeartHandshake className="h-4 w-4" />
|
||||
)}
|
||||
{data.viewerVouched ? 'Remove vouch' : `Vouch for ${providerName.split(' ')[0]}`}
|
||||
{data.viewerVouched ? t('socialUi.vouch.remove') : t('socialUi.vouch.vouchFor', { name: providerName.split(' ')[0] })}
|
||||
</Button>
|
||||
)}
|
||||
{isOwner && (
|
||||
<p className="text-center text-[11px] text-gray-600">
|
||||
Skaters who know your work can vouch for you here.
|
||||
{t('socialUi.vouch.ownerNote')}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { useNWC } from '@/hooks/useNWCContext';
|
|||
import { useWallet } from '@/hooks/useWallet';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import type { NWCConnection, NWCInfo } from '@/hooks/useNWC';
|
||||
import type { WebLNProvider } from "@webbtc/webln-types";
|
||||
|
||||
|
|
@ -26,19 +27,21 @@ const AddWalletContent = forwardRef<HTMLDivElement, {
|
|||
setAlias: (value: string) => void;
|
||||
connectionUri: string;
|
||||
setConnectionUri: (value: string) => void;
|
||||
}>(({ alias, setAlias, connectionUri, setConnectionUri }, ref) => (
|
||||
}>(({ alias, setAlias, connectionUri, setConnectionUri }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-4 px-4" ref={ref}>
|
||||
<div>
|
||||
<Label htmlFor="alias">Wallet Name (optional)</Label>
|
||||
<Label htmlFor="alias">{t('walletUi.modal.walletName')}</Label>
|
||||
<Input
|
||||
id="alias"
|
||||
placeholder="My Lightning Wallet"
|
||||
placeholder={t('walletUi.modal.walletNamePlaceholder')}
|
||||
value={alias}
|
||||
onChange={(e) => setAlias(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="connection-uri">Connection URI</Label>
|
||||
<Label htmlFor="connection-uri">{t('walletUi.modal.connectionUri')}</Label>
|
||||
<Textarea
|
||||
id="connection-uri"
|
||||
placeholder="nostr+walletconnect://..."
|
||||
|
|
@ -52,18 +55,17 @@ const AddWalletContent = forwardRef<HTMLDivElement, {
|
|||
<div className="flex gap-2.5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
|
||||
<ShieldAlert className="h-4 w-4 flex-shrink-0 text-amber-500 mt-0.5" />
|
||||
<div className="space-y-1 text-xs text-amber-200/90">
|
||||
<p className="font-semibold text-amber-300">Set a spending limit first</p>
|
||||
<p className="font-semibold text-amber-300">{t('walletUi.modal.spendLimitTitle')}</p>
|
||||
<p>
|
||||
This string lets SkateSpots pay Lightning invoices from your wallet. In
|
||||
your wallet app, give this connection a small <strong>daily budget</strong>{' '}
|
||||
(e.g. a few thousand sats) and enable only <strong>pay_invoice</strong>.
|
||||
Never paste a connection with an unlimited budget. You can revoke it in
|
||||
your wallet any time.
|
||||
{t('walletUi.modal.spendLimitBody1')} <strong>{t('walletUi.modal.dailyBudget')}</strong>{' '}
|
||||
{t('walletUi.modal.spendLimitBody2')} <strong>pay_invoice</strong>.{' '}
|
||||
{t('walletUi.modal.spendLimitBody3')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
);
|
||||
});
|
||||
AddWalletContent.displayName = 'AddWalletContent';
|
||||
|
||||
// Recommended wallets for NWC
|
||||
|
|
@ -92,7 +94,9 @@ const WalletContent = forwardRef<HTMLDivElement, {
|
|||
handleSetActive,
|
||||
handleRemoveConnection,
|
||||
setAddDialogOpen
|
||||
}, ref) => (
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6 px-0 sm:px-4 pb-4" ref={ref}>
|
||||
{/* NWC First - Primary Option */}
|
||||
<div className="space-y-3">
|
||||
|
|
@ -100,7 +104,7 @@ const WalletContent = forwardRef<HTMLDivElement, {
|
|||
<h3 className="font-medium text-sm sm:text-base">Nostr Wallet Connect</h3>
|
||||
<Button size="sm" variant="outline" onClick={() => setAddDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Wallet
|
||||
{t('walletUi.modal.addWallet')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
@ -116,10 +120,10 @@ const WalletContent = forwardRef<HTMLDivElement, {
|
|||
<WalletMinimal className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{connection.alias || info?.alias || 'Lightning Wallet'}
|
||||
{connection.alias || info?.alias || t('walletUi.modal.lightningWallet')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
NWC Connection
|
||||
{t('walletUi.modal.nwcConnection')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -150,11 +154,11 @@ const WalletContent = forwardRef<HTMLDivElement, {
|
|||
<div className="text-center py-4 px-3 border border-dashed rounded-lg">
|
||||
<WalletMinimal className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Paste your NWC connection string to send zaps
|
||||
{t('walletUi.modal.pasteNwc')}
|
||||
</p>
|
||||
<Button size="sm" onClick={() => setAddDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Connect Wallet
|
||||
{t('walletUi.modal.connectWallet')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -168,14 +172,14 @@ const WalletContent = forwardRef<HTMLDivElement, {
|
|||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<Globe className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs sm:text-sm font-medium">Browser Extension (WebLN)</p>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">Auto-detected if installed</p>
|
||||
<p className="text-xs sm:text-sm font-medium">{t('walletUi.modal.browserExtension')}</p>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">{t('walletUi.modal.autoDetected')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 sm:gap-2 flex-shrink-0">
|
||||
{webln && <CheckCircle className="h-3 w-3 sm:h-4 sm:w-4 text-green-600" />}
|
||||
<Badge variant={webln ? "default" : "secondary"} className="text-[10px] sm:text-xs">
|
||||
{webln ? "Ready" : "Not Found"}
|
||||
{webln ? t('walletUi.modal.ready') : t('walletUi.modal.notFound')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -186,7 +190,7 @@ const WalletContent = forwardRef<HTMLDivElement, {
|
|||
<>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">Get NWC from:</h4>
|
||||
<h4 className="text-sm font-medium text-muted-foreground">{t('walletUi.modal.getNwcFrom')}</h4>
|
||||
<div className="grid gap-2">
|
||||
{RECOMMENDED_WALLETS.map((wallet) => (
|
||||
<a
|
||||
|
|
@ -210,10 +214,12 @@ const WalletContent = forwardRef<HTMLDivElement, {
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
);
|
||||
});
|
||||
WalletContent.displayName = 'WalletContent';
|
||||
|
||||
export function WalletModal({ children, className }: WalletModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||
const [connectionUri, setConnectionUri] = useState('');
|
||||
|
|
@ -238,8 +244,8 @@ export function WalletModal({ children, className }: WalletModalProps) {
|
|||
const handleAddConnection = async () => {
|
||||
if (!connectionUri.trim()) {
|
||||
toast({
|
||||
title: 'Connection URI required',
|
||||
description: 'Please enter a valid NWC connection URI.',
|
||||
title: t('walletUi.modal.connectionRequired'),
|
||||
description: t('walletUi.modal.connectionRequiredDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -265,8 +271,8 @@ export function WalletModal({ children, className }: WalletModalProps) {
|
|||
const handleSetActive = (connectionString: string) => {
|
||||
setActiveConnection(connectionString);
|
||||
toast({
|
||||
title: 'Active wallet changed',
|
||||
description: 'The selected wallet is now active for zaps.',
|
||||
title: t('walletUi.modal.activeChanged'),
|
||||
description: t('walletUi.modal.activeChangedDesc'),
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -285,9 +291,9 @@ export function WalletModal({ children, className }: WalletModalProps) {
|
|||
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
|
||||
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-[425px] p-4 sm:p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">Connect NWC Wallet</DialogTitle>
|
||||
<DialogTitle className="text-base sm:text-lg">{t('walletUi.modal.connectNwc')}</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
Enter your connection string from a compatible wallet.
|
||||
{t('walletUi.modal.connectNwcDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<AddWalletContent
|
||||
|
|
@ -302,7 +308,7 @@ export function WalletModal({ children, className }: WalletModalProps) {
|
|||
disabled={isConnecting || !connectionUri.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{isConnecting ? 'Connecting...' : 'Connect'}
|
||||
{isConnecting ? t('walletUi.modal.connecting') : t('walletUi.modal.connect')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
@ -317,7 +323,7 @@ export function WalletModal({ children, className }: WalletModalProps) {
|
|||
{children || (
|
||||
<Button variant="outline" size="sm" className={className}>
|
||||
<Wallet className="h-4 w-4 mr-2" />
|
||||
Wallet Settings
|
||||
{t('walletUi.modal.walletSettings')}
|
||||
</Button>
|
||||
)}
|
||||
</DrawerTrigger>
|
||||
|
|
@ -326,15 +332,15 @@ export function WalletModal({ children, className }: WalletModalProps) {
|
|||
<DrawerClose asChild>
|
||||
<Button variant="ghost" size="sm" className="absolute right-4 top-4">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
<span className="sr-only">{t('common.close')}</span>
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
<DrawerTitle className="flex items-center justify-center gap-2 pt-2">
|
||||
<Wallet className="h-5 w-5" />
|
||||
Lightning Wallet
|
||||
{t('walletUi.modal.lightningWallet')}
|
||||
</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Connect your lightning wallet to send zaps instantly.
|
||||
{t('walletUi.modal.connectDesc')}
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="overflow-y-auto">
|
||||
|
|
@ -346,9 +352,9 @@ export function WalletModal({ children, className }: WalletModalProps) {
|
|||
<Drawer open={addDialogOpen} onOpenChange={setAddDialogOpen}>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Connect NWC Wallet</DrawerTitle>
|
||||
<DrawerTitle>{t('walletUi.modal.connectNwc')}</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Enter your connection string from a compatible wallet.
|
||||
{t('walletUi.modal.connectNwcDesc')}
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<AddWalletContent
|
||||
|
|
@ -363,7 +369,7 @@ export function WalletModal({ children, className }: WalletModalProps) {
|
|||
disabled={isConnecting || !connectionUri.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{isConnecting ? 'Connecting...' : 'Connect'}
|
||||
{isConnecting ? t('walletUi.modal.connecting') : t('walletUi.modal.connect')}
|
||||
</Button>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
|
|
@ -379,7 +385,7 @@ export function WalletModal({ children, className }: WalletModalProps) {
|
|||
{children || (
|
||||
<Button variant="outline" size="sm" className={className}>
|
||||
<Wallet className="h-4 w-4 mr-2" />
|
||||
Wallet Settings
|
||||
{t('walletUi.modal.walletSettings')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
|
@ -387,10 +393,10 @@ export function WalletModal({ children, className }: WalletModalProps) {
|
|||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<Wallet className="h-5 w-5" />
|
||||
Lightning Wallet
|
||||
{t('walletUi.modal.lightningWallet')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
Connect your lightning wallet to send zaps instantly.
|
||||
{t('walletUi.modal.connectDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<WalletContent {...walletContentProps} />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { Cloud, CloudOff } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
interface WeatherToggleProps {
|
||||
enabled: boolean;
|
||||
|
|
@ -9,6 +10,7 @@ interface WeatherToggleProps {
|
|||
}
|
||||
|
||||
export function WeatherToggle({ enabled, onToggle, className }: WeatherToggleProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Button
|
||||
variant={enabled ? 'default' : 'outline'}
|
||||
|
|
@ -23,12 +25,12 @@ export function WeatherToggle({ enabled, onToggle, className }: WeatherTogglePro
|
|||
{enabled ? (
|
||||
<>
|
||||
<Cloud className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Weather On</span>
|
||||
<span className="hidden sm:inline">{t('chromeUi.weatherOn')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CloudOff className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Weather Off</span>
|
||||
<span className="hidden sm:inline">{t('chromeUi.weatherOff')}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,23 +1,24 @@
|
|||
import { useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useZapLeaderboard } from '@/hooks/useZapLeaderboard';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Award, Crown, Sparkles, Trophy, Zap } from 'lucide-react';
|
||||
|
||||
interface BadgeRule {
|
||||
id: string;
|
||||
label: string;
|
||||
hint: string;
|
||||
labelKey: string;
|
||||
hintKey: string;
|
||||
Icon: typeof Zap;
|
||||
earned: (count: number, sats: number, rank: number | null) => boolean;
|
||||
}
|
||||
|
||||
const BADGES: BadgeRule[] = [
|
||||
{ id: 'first', label: 'First Zap', hint: 'Send your first zap', Icon: Sparkles, earned: (c) => c >= 1 },
|
||||
{ id: 'regular', label: 'Regular', hint: 'Send 10 zaps', Icon: Zap, earned: (c) => c >= 10 },
|
||||
{ id: 'generous', label: 'Generous', hint: 'Send 10,000 sats', Icon: Award, earned: (_c, s) => s >= 10_000 },
|
||||
{ id: 'patron', label: 'Patron', hint: 'Send 100,000 sats', Icon: Crown, earned: (_c, s) => s >= 100_000 },
|
||||
{ id: 'top10', label: 'Top 10', hint: 'Reach the top 10 supporters', Icon: Trophy, earned: (_c, _s, r) => r !== null && r <= 10 },
|
||||
{ id: 'first', labelKey: 'walletUi.zapBadges.first', hintKey: 'walletUi.zapBadges.firstHint', Icon: Sparkles, earned: (c) => c >= 1 },
|
||||
{ id: 'regular', labelKey: 'walletUi.zapBadges.regular', hintKey: 'walletUi.zapBadges.regularHint', Icon: Zap, earned: (c) => c >= 10 },
|
||||
{ id: 'generous', labelKey: 'walletUi.zapBadges.generous', hintKey: 'walletUi.zapBadges.generousHint', Icon: Award, earned: (_c, s) => s >= 10_000 },
|
||||
{ id: 'patron', labelKey: 'walletUi.zapBadges.patron', hintKey: 'walletUi.zapBadges.patronHint', Icon: Crown, earned: (_c, s) => s >= 100_000 },
|
||||
{ id: 'top10', labelKey: 'walletUi.zapBadges.top10', hintKey: 'walletUi.zapBadges.top10Hint', Icon: Trophy, earned: (_c, _s, r) => r !== null && r <= 10 },
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -26,6 +27,7 @@ const BADGES: BadgeRule[] = [
|
|||
* published, so this adds no events and no spam to the network.
|
||||
*/
|
||||
export function ZapBadgesCard({ pubkey, className }: { pubkey: string; className?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const since = useMemo(() => Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60, []);
|
||||
const { data, isLoading } = useZapLeaderboard(since);
|
||||
|
||||
|
|
@ -47,15 +49,15 @@ export function ZapBadgesCard({ pubkey, className }: { pubkey: string; className
|
|||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg text-[#ccff00]">
|
||||
<Zap className="h-5 w-5" />
|
||||
Zap Badges
|
||||
{t('walletUi.zapBadges.title')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-gray-400">
|
||||
<span className="font-bold text-[#ccff00]">{stats.count.toLocaleString()}</span> zaps ·{' '}
|
||||
<span className="font-bold text-[#ccff00]">{stats.sats.toLocaleString()}</span> sats sent
|
||||
<span className="font-bold text-[#ccff00]">{stats.count.toLocaleString()}</span> {t('walletUi.zapBadges.zaps')} ·{' '}
|
||||
<span className="font-bold text-[#ccff00]">{stats.sats.toLocaleString()}</span> {t('walletUi.zapBadges.satsSent')}
|
||||
{stats.rank !== null && (
|
||||
<> · rank <span className="font-bold text-white">#{stats.rank}</span></>
|
||||
<> · {t('walletUi.zapBadges.rank')} <span className="font-bold text-white">#{stats.rank}</span></>
|
||||
)}
|
||||
</p>
|
||||
|
||||
|
|
@ -65,7 +67,7 @@ export function ZapBadgesCard({ pubkey, className }: { pubkey: string; className
|
|||
return (
|
||||
<div
|
||||
key={b.id}
|
||||
title={b.hint}
|
||||
title={t(b.hintKey)}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1 rounded-lg border-2 px-2 py-3 text-center',
|
||||
earned ? 'border-[#ccff00]/60 bg-[#ccff00]/10' : 'border-white/10 bg-black/40 opacity-45',
|
||||
|
|
@ -78,14 +80,14 @@ export function ZapBadgesCard({ pubkey, className }: { pubkey: string; className
|
|||
earned ? 'text-white' : 'text-gray-500',
|
||||
)}
|
||||
>
|
||||
{b.label}
|
||||
{t(b.labelKey)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-gray-500">Based on the last 30 days of zaps.</p>
|
||||
<p className="text-[10px] text-gray-500">{t('walletUi.zapBadges.footnote')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useZaps } from '@/hooks/useZaps';
|
|||
import { useWallet } from '@/hooks/useWallet';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { Zap } from 'lucide-react';
|
||||
import type { Event } from 'nostr-tools';
|
||||
|
||||
|
|
@ -19,6 +20,7 @@ export function ZapButton({
|
|||
showCount = true,
|
||||
zapData: externalZapData
|
||||
}: ZapButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: author } = useAuthor(target?.pubkey || '');
|
||||
const { webln, activeNWC } = useWallet();
|
||||
|
|
@ -54,7 +56,7 @@ export function ZapButton({
|
|||
return (
|
||||
<span
|
||||
className={`flex items-center gap-1 ${className}`}
|
||||
title={`${zapCount.toLocaleString()} zap${zapCount === 1 ? '' : 's'}`}
|
||||
title={t('walletUi.zap.count', { count: zapCount.toLocaleString() })}
|
||||
>
|
||||
<Zap className="h-4 w-4" />
|
||||
<span className="text-xs">{totalSats.toLocaleString()}</span>
|
||||
|
|
@ -72,7 +74,7 @@ export function ZapButton({
|
|||
) : showCount && totalSats > 0 ? (
|
||||
`${totalSats.toLocaleString()}`
|
||||
) : (
|
||||
'Zap'
|
||||
t('zaps.zapButton')
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { useWallet } from '@/hooks/useWallet';
|
|||
import { useCrewZap, splitAmount, type CrewZapResult } from '@/hooks/useCrewZap';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { Zap, Check, Copy, Loader2, X, Users, AlertCircle } from 'lucide-react';
|
||||
|
||||
|
||||
|
|
@ -25,6 +26,7 @@ interface ZapCrewDialogProps {
|
|||
}
|
||||
|
||||
function RecipientRow({ result }: { result: CrewZapResult }) {
|
||||
const { t } = useTranslation();
|
||||
const author = useAuthor(result.pubkey);
|
||||
const meta = author.data?.metadata;
|
||||
const name = meta?.name ?? genUserName(result.pubkey);
|
||||
|
|
@ -33,7 +35,7 @@ function RecipientRow({ result }: { result: CrewZapResult }) {
|
|||
const copyInvoice = async () => {
|
||||
if (!result.invoice) return;
|
||||
await navigator.clipboard.writeText(result.invoice);
|
||||
toast({ title: 'Invoice copied', description: 'Paste it into any Lightning wallet' });
|
||||
toast({ title: t('walletUi.zapCrew.invoiceCopied'), description: t('walletUi.zapCrew.invoiceCopiedDesc') });
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -52,28 +54,30 @@ function RecipientRow({ result }: { result: CrewZapResult }) {
|
|||
}
|
||||
|
||||
function StatusPill({ result, onCopy }: { result: CrewZapResult; onCopy: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
switch (result.status) {
|
||||
case 'success':
|
||||
return <span className="flex items-center gap-1 text-[10px] font-bold text-green-400"><Check className="h-3 w-3" /> Sent</span>;
|
||||
return <span className="flex items-center gap-1 text-[10px] font-bold text-green-400"><Check className="h-3 w-3" /> {t('walletUi.zapCrew.sent')}</span>;
|
||||
case 'resolving':
|
||||
case 'paying':
|
||||
return <span className="flex items-center gap-1 text-[10px] text-teal-400"><Loader2 className="h-3 w-3 animate-spin" /> {result.status === 'paying' ? 'Paying' : 'Resolving'}</span>;
|
||||
return <span className="flex items-center gap-1 text-[10px] text-teal-400"><Loader2 className="h-3 w-3 animate-spin" /> {result.status === 'paying' ? t('walletUi.zapCrew.paying') : t('walletUi.zapCrew.resolving')}</span>;
|
||||
case 'skipped':
|
||||
return <span className="text-[10px] text-gray-500" title={result.error}>Skipped</span>;
|
||||
return <span className="text-[10px] text-gray-500" title={result.error}>{t('walletUi.zapCrew.skipped')}</span>;
|
||||
case 'failed':
|
||||
return result.invoice ? (
|
||||
<button type="button" onClick={onCopy} className="flex items-center gap-1 text-[10px] font-bold text-amber-400 hover:text-amber-300">
|
||||
<Copy className="h-3 w-3" /> Copy invoice
|
||||
<Copy className="h-3 w-3" /> {t('walletUi.zapCrew.copyInvoice')}
|
||||
</button>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-[10px] text-red-400" title={result.error}><AlertCircle className="h-3 w-3" /> Failed</span>
|
||||
<span className="flex items-center gap-1 text-[10px] text-red-400" title={result.error}><AlertCircle className="h-3 w-3" /> {t('walletUi.zapCrew.failed')}</span>
|
||||
);
|
||||
default:
|
||||
return <span className="text-[10px] text-gray-600">Waiting</span>;
|
||||
return <span className="text-[10px] text-gray-600">{t('walletUi.zapCrew.waiting')}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
export function ZapCrewDialog({ crewName, recipients, children }: ZapCrewDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { user } = useCurrentUser();
|
||||
const { preferredMethod } = useWallet();
|
||||
|
|
@ -95,7 +99,7 @@ export function ZapCrewDialog({ crewName, recipients, children }: ZapCrewDialogP
|
|||
|
||||
const handleZap = () => {
|
||||
if (numAmount <= 0 || payable.length === 0) return;
|
||||
zapCrew(payable, numAmount, comment || `Zapped the ${crewName} crew! 🤘`);
|
||||
zapCrew(payable, numAmount, comment || t('walletUi.zapCrew.defaultComment', { crew: crewName }));
|
||||
};
|
||||
|
||||
const handleOpenChange = (o: boolean) => {
|
||||
|
|
@ -117,21 +121,21 @@ export function ZapCrewDialog({ crewName, recipients, children }: ZapCrewDialogP
|
|||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-teal-400" /> Zap the Crew
|
||||
<Users className="h-5 w-5 text-teal-400" /> {t('walletUi.zapCrew.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Split a zap evenly across {payable.length} crew {payable.length === 1 ? 'member' : 'members'} with a Lightning address.
|
||||
{t('walletUi.zapCrew.desc', { count: payable.length })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{payable.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
No other crew members to zap right now.
|
||||
{t('walletUi.zapCrew.noMembers')}
|
||||
</div>
|
||||
) : !started ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-xs uppercase tracking-wide text-muted-foreground">Total amount</Label>
|
||||
<Label className="text-xs uppercase tracking-wide text-muted-foreground">{t('walletUi.zapCrew.totalAmount')}</Label>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={String(amount)}
|
||||
|
|
@ -149,22 +153,22 @@ export function ZapCrewDialog({ crewName, recipients, children }: ZapCrewDialogP
|
|||
type="number"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="Custom total"
|
||||
placeholder={t('walletUi.zapCrew.customTotal')}
|
||||
className="mt-2 bg-gray-800 border-gray-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{numAmount > 0 && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
≈ <span className="font-bold text-teal-400">{perHead[0]?.toLocaleString()}</span> sats each
|
||||
{perHead.length > 1 && perHead[0] !== perHead[perHead.length - 1] && ' (rounded)'}
|
||||
≈ <span className="font-bold text-teal-400">{perHead[0]?.toLocaleString()}</span> {t('walletUi.zapCrew.each')}
|
||||
{perHead.length > 1 && perHead[0] !== perHead[perHead.length - 1] && ` ${t('walletUi.zapCrew.rounded')}`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder={`Zapped the ${crewName} crew! 🤘`}
|
||||
placeholder={t('walletUi.zapCrew.defaultComment', { crew: crewName })}
|
||||
rows={2}
|
||||
className="bg-gray-800 border-gray-700 resize-none text-sm"
|
||||
/>
|
||||
|
|
@ -172,13 +176,13 @@ export function ZapCrewDialog({ crewName, recipients, children }: ZapCrewDialogP
|
|||
{preferredMethod === 'manual' && (
|
||||
<p className="flex items-start gap-1.5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-2 text-[11px] text-amber-300">
|
||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
No wallet connected — you'll get an invoice per member to pay manually. Connect a wallet for one-tap splitting.
|
||||
{t('walletUi.zapCrew.noWallet')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button onClick={handleZap} disabled={numAmount <= 0} className="w-full bg-teal-500 hover:bg-teal-600 font-bold">
|
||||
<Zap className="h-4 w-4 mr-2" />
|
||||
Zap {numAmount.toLocaleString()} sats across {payable.length}
|
||||
{t('walletUi.zapCrew.zapAcross', { amount: numAmount.toLocaleString(), count: payable.length })}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -192,22 +196,22 @@ export function ZapCrewDialog({ crewName, recipients, children }: ZapCrewDialogP
|
|||
{allDone && (
|
||||
<div className="rounded-lg border border-gray-700 bg-black/40 p-3 text-center">
|
||||
<p className="text-sm font-bold text-white">
|
||||
{doneCount > 0 ? `⚡ Sent to ${doneCount}/${payable.length}` : 'No auto-payments completed'}
|
||||
{doneCount > 0 ? t('walletUi.zapCrew.sentTo', { done: doneCount, total: payable.length }) : t('walletUi.zapCrew.none')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{results.some((r) => r.status === 'failed' && r.invoice)
|
||||
? 'Copy any pending invoices to finish paying manually.'
|
||||
: 'Thanks for supporting the crew!'}
|
||||
? t('walletUi.zapCrew.copyPending')
|
||||
: t('walletUi.zapCrew.thanks')}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={() => handleOpenChange(false)} className="mt-3 border-gray-700">
|
||||
<X className="h-3.5 w-3.5 mr-1" /> Close
|
||||
<X className="h-3.5 w-3.5 mr-1" /> {t('common.close')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRunning && (
|
||||
<p className="flex items-center justify-center gap-1.5 text-xs text-teal-400">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> Paying the crew…
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t('walletUi.zapCrew.payingCrew')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import { useWallet } from '@/hooks/useWallet';
|
|||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { useCashu } from '@/hooks/wallet/useCashu';
|
||||
import { WalletModal } from '@/components/WalletModal';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import type { Event } from 'nostr-tools';
|
||||
// qrcode imported lazily in async function to avoid canvas crash at module load
|
||||
import type { WebLNProvider } from "@webbtc/webln-types";
|
||||
|
|
@ -75,16 +76,18 @@ interface ZapContentProps {
|
|||
// Wallet Setup Screen - shown when user has no wallet connected
|
||||
const WalletSetupContent = forwardRef<HTMLDivElement, {
|
||||
onContinue: () => void;
|
||||
}>(({ onContinue }, ref) => (
|
||||
}>(({ onContinue }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="px-4 py-6 space-y-6" ref={ref}>
|
||||
<div className="text-center space-y-3">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-orange-500/20 flex items-center justify-center">
|
||||
<Wallet className="h-8 w-8 text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg mb-2">Connect Your Wallet First</h3>
|
||||
<h3 className="font-bold text-lg mb-2">{t('walletUi.zap.connectFirst')}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
To send zaps (Bitcoin tips), you need to connect a Lightning wallet.
|
||||
{t('walletUi.zap.connectFirstDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -94,9 +97,9 @@ const WalletSetupContent = forwardRef<HTMLDivElement, {
|
|||
<div className="flex items-start gap-3">
|
||||
<div className="text-2xl">💡</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium mb-1">Quick Setup (30 seconds)</p>
|
||||
<p className="text-sm font-medium mb-1">{t('walletUi.zap.quickSetup')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Connect your existing Lightning wallet with Nostr Wallet Connect (NWC). No extra accounts needed.
|
||||
{t('walletUi.zap.quickSetupDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -107,7 +110,7 @@ const WalletSetupContent = forwardRef<HTMLDivElement, {
|
|||
<WalletModal>
|
||||
<Button className="w-full gap-2 bg-orange-500 hover:bg-orange-600" size="lg">
|
||||
<Wallet className="h-4 w-4" />
|
||||
Connect Wallet with NWC
|
||||
{t('walletUi.zap.connectNwc')}
|
||||
</Button>
|
||||
</WalletModal>
|
||||
|
||||
|
|
@ -119,7 +122,7 @@ const WalletSetupContent = forwardRef<HTMLDivElement, {
|
|||
>
|
||||
<Button variant="outline" className="w-full gap-2" size="lg">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Don't have a wallet? Learn how
|
||||
{t('walletUi.zap.noWallet')}
|
||||
</Button>
|
||||
</a>
|
||||
|
||||
|
|
@ -129,19 +132,20 @@ const WalletSetupContent = forwardRef<HTMLDivElement, {
|
|||
className="w-full text-muted-foreground hover:text-foreground"
|
||||
size="sm"
|
||||
>
|
||||
Skip for now (manual invoice)
|
||||
{t('walletUi.zap.skip')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="bg-yellow-950/30 border-yellow-500/30">
|
||||
<CardContent className="p-3">
|
||||
<p className="text-xs text-gray-300">
|
||||
<strong className="text-yellow-400">ℹ️ Note:</strong> You can still create an invoice without connecting a wallet, but you'll need to copy it manually to pay.
|
||||
<strong className="text-yellow-400">{t('walletUi.zap.note')}</strong> {t('walletUi.zap.noteDesc')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
));
|
||||
);
|
||||
});
|
||||
WalletSetupContent.displayName = 'WalletSetupContent';
|
||||
|
||||
// Moved ZapContent outside of ZapDialog to prevent re-renders causing focus loss
|
||||
|
|
@ -164,7 +168,9 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
|
|||
setPaymentMethod,
|
||||
cashuBalance,
|
||||
hasCashu,
|
||||
}, ref) => (
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{invoice ? (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
|
|
@ -183,7 +189,7 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
|
|||
{qrCodeUrl ? (
|
||||
<img
|
||||
src={qrCodeUrl}
|
||||
alt="Lightning Invoice QR Code"
|
||||
alt={t('walletUi.zap.qrAlt')}
|
||||
className="w-full h-auto aspect-square max-w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -195,7 +201,7 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
|
|||
|
||||
{/* Invoice input */}
|
||||
<div className="space-y-2 mt-4">
|
||||
<Label htmlFor="invoice">Lightning Invoice</Label>
|
||||
<Label htmlFor="invoice">{t('walletUi.zap.lightningInvoice')}</Label>
|
||||
<div className="flex gap-2 min-w-0">
|
||||
<Input
|
||||
id="invoice"
|
||||
|
|
@ -232,7 +238,7 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
|
|||
size="lg"
|
||||
>
|
||||
<Zap className="h-4 w-4 mr-2" />
|
||||
{isZapping ? "Processing..." : "Pay with WebLN"}
|
||||
{isZapping ? t('walletUi.zap.processing') : t('walletUi.zap.payWithWebln')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
|
@ -243,11 +249,11 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
|
|||
size="lg"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
Open in Lightning Wallet
|
||||
{t('walletUi.zap.openInWallet')}
|
||||
</Button>
|
||||
|
||||
<div className="text-xs sm:text-[.65rem] text-muted-foreground text-center">
|
||||
Scan the QR code or copy the invoice to pay with any Lightning wallet.
|
||||
{t('walletUi.zap.scanHint')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -272,13 +278,13 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
|
|||
<span className="text-xl">🥜</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-orange-400">Cashu Wallet</p>
|
||||
<p className="text-xs text-muted-foreground">Pay instantly with your built-in wallet</p>
|
||||
<p className="text-sm font-bold text-orange-400">{t('walletUi.cashu.title')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('walletUi.zap.cashuInstant')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-bold text-orange-400">{cashuBalance.toLocaleString()}</p>
|
||||
<p className="text-[10px] text-muted-foreground uppercase">sats available</p>
|
||||
<p className="text-[10px] text-muted-foreground uppercase">{t('walletUi.zap.satsAvailable')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -340,21 +346,21 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
|
|||
</ToggleGroup>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-px flex-1 bg-muted" />
|
||||
<span className="text-xs text-muted-foreground">OR</span>
|
||||
<span className="text-xs text-muted-foreground">{t('walletUi.zap.or')}</span>
|
||||
<div className="h-px flex-1 bg-muted" />
|
||||
</div>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id="custom-amount"
|
||||
type="number"
|
||||
placeholder="Custom amount"
|
||||
placeholder={t('walletUi.zap.customAmount')}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
className="w-full text-sm"
|
||||
/>
|
||||
<Textarea
|
||||
id="custom-comment"
|
||||
placeholder="Add a comment (optional)"
|
||||
placeholder={t('walletUi.zap.addComment')}
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
className="w-full resize-none text-sm"
|
||||
|
|
@ -378,8 +384,7 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
|
|||
<Card className="bg-yellow-950/30 border-yellow-500/30">
|
||||
<CardContent className="p-3">
|
||||
<p className="text-xs text-yellow-200">
|
||||
⚠️ Insufficient Cashu balance. You need {(numericAmount - cashuBalance).toLocaleString()} more sats.
|
||||
Switch to Lightning or top up your Cashu wallet.
|
||||
{t('walletUi.zap.insufficientBanner', { amount: (numericAmount - cashuBalance).toLocaleString() })}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -399,11 +404,11 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
|
|||
size="default"
|
||||
>
|
||||
{isZapping ? (
|
||||
'Creating invoice...'
|
||||
t('walletUi.zap.creatingInvoice')
|
||||
) : (
|
||||
<>
|
||||
{paymentMethod === 'cashu' ? '🥜' : <Zap className="h-4 w-4 mr-2" />}
|
||||
Zap {amount} sats {paymentMethod === 'cashu' ? 'with Cashu' : ''}
|
||||
{paymentMethod === 'cashu' ? t('walletUi.zap.zapAmountCashu', { amount }) : t('walletUi.zap.zapAmount', { amount })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
|
@ -411,10 +416,12 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
);
|
||||
});
|
||||
ZapContent.displayName = 'ZapContent';
|
||||
|
||||
export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { user } = useCurrentUser();
|
||||
const { data: author } = useAuthor(target.pubkey);
|
||||
|
|
@ -440,9 +447,9 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
|||
|
||||
useEffect(() => {
|
||||
if (target) {
|
||||
setComment('Zapped on SkateSpots! 🛹');
|
||||
setComment(t('walletUi.zap.defaultComment'));
|
||||
}
|
||||
}, [target]);
|
||||
}, [target, t]);
|
||||
|
||||
// Pick the most useful default payment method when the dialog opens: if the
|
||||
// user has Cashu funds but no connected Lightning wallet, default to Cashu so
|
||||
|
|
@ -500,8 +507,8 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
|||
await navigator.clipboard.writeText(invoice);
|
||||
setCopied(true);
|
||||
toast({
|
||||
title: 'Invoice copied',
|
||||
description: 'Lightning invoice copied to clipboard',
|
||||
title: t('walletUi.zap.invoiceCopied'),
|
||||
description: t('walletUi.zap.invoiceCopiedDesc'),
|
||||
});
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
|
@ -544,8 +551,8 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
|||
if (!Number.isFinite(finalAmount) || finalAmount <= 0) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Enter an amount',
|
||||
description: 'Choose a preset or type a valid number of sats.',
|
||||
title: t('walletUi.zap.enterAmount'),
|
||||
description: t('walletUi.zap.enterAmountDesc'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -559,16 +566,16 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
|||
if (!hasCashu || !coco) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Cashu wallet unavailable',
|
||||
description: 'Your Cashu wallet is not ready. Switch to Lightning or open your wallet first.',
|
||||
title: t('walletUi.zap.cashuUnavailable'),
|
||||
description: t('walletUi.zap.cashuUnavailableDesc'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (finalAmount > cashuBalance) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Insufficient Cashu balance',
|
||||
description: `You need ${(finalAmount - cashuBalance).toLocaleString()} more sats. Switch to Lightning or top up.`,
|
||||
title: t('walletUi.zap.insufficientCashu'),
|
||||
description: t('walletUi.zap.insufficientCashuDesc', { amount: (finalAmount - cashuBalance).toLocaleString() }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -585,8 +592,8 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
|||
await coco.quotes.payMeltQuote(activeMint.url, meltQuote.quote);
|
||||
|
||||
toast({
|
||||
title: 'Zap sent! 🥜',
|
||||
description: `Zapped ${finalAmount.toLocaleString()} sats from your Cashu wallet.`,
|
||||
title: t('walletUi.zap.zapSentCashu'),
|
||||
description: t('walletUi.zap.zapSentCashuDesc', { amount: finalAmount.toLocaleString() }),
|
||||
});
|
||||
setInvoice(null);
|
||||
setOpen(false);
|
||||
|
|
@ -594,8 +601,8 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
|||
console.error('Cashu payment error:', error);
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Cashu payment failed',
|
||||
description: error instanceof Error ? error.message : 'Failed to pay with your Cashu wallet.',
|
||||
title: t('walletUi.zap.cashuFailed'),
|
||||
description: error instanceof Error ? error.message : t('walletUi.zap.cashuFailedDesc'),
|
||||
});
|
||||
} finally {
|
||||
setIsZapping(false);
|
||||
|
|
@ -694,20 +701,20 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
|||
className="absolute right-4 top-4"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
<span className="sr-only">{t('common.close')}</span>
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
|
||||
<DrawerTitle className="text-lg break-words pt-2">
|
||||
{showWalletSetup ? 'Connect Wallet' : invoice ? 'Lightning Payment' : 'Send a Zap'}
|
||||
{showWalletSetup ? t('walletUi.zap.titleConnect') : invoice ? t('walletUi.zap.titlePayment') : t('walletUi.zap.titleZap')}
|
||||
</DrawerTitle>
|
||||
<DrawerDescription className="text-sm break-words text-center">
|
||||
{showWalletSetup ? (
|
||||
'Connect your Lightning wallet to send zaps'
|
||||
t('walletUi.zap.descConnect')
|
||||
) : invoice ? (
|
||||
'Pay with Bitcoin Lightning Network'
|
||||
t('walletUi.zap.descPayment')
|
||||
) : (
|
||||
'Zaps are small Bitcoin payments that support the creator of this item. If you enjoyed this, consider sending a zap!'
|
||||
t('walletUi.zap.descZap')
|
||||
)}
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
|
@ -733,16 +740,16 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
|||
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-[425px] max-h-[90vh] max-h-[90dvh] overflow-hidden p-4 sm:p-6" data-testid="zap-modal">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg break-words">
|
||||
{showWalletSetup ? 'Connect Wallet' : invoice ? 'Lightning Payment' : 'Send a Zap'}
|
||||
{showWalletSetup ? t('walletUi.zap.titleConnect') : invoice ? t('walletUi.zap.titlePayment') : t('walletUi.zap.titleZap')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm text-center break-words">
|
||||
{showWalletSetup ? (
|
||||
'Connect your Lightning wallet to send zaps'
|
||||
t('walletUi.zap.descConnect')
|
||||
) : invoice ? (
|
||||
'Pay with Bitcoin Lightning Network'
|
||||
t('walletUi.zap.descPayment')
|
||||
) : (
|
||||
<>
|
||||
Zaps are small Bitcoin payments that support creators. Consider sending a zap!
|
||||
{t('walletUi.zap.descZapShort')}
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar.tsx'
|
|||
import { WalletModal } from '@/components/WalletModal';
|
||||
import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
interface AccountSwitcherProps {
|
||||
onAddAccountClick: () => void;
|
||||
|
|
@ -20,6 +21,7 @@ interface AccountSwitcherProps {
|
|||
|
||||
export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) {
|
||||
const { currentUser, otherUsers, setLogin, removeLogin } = useLoggedInAccounts();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!currentUser) return null;
|
||||
|
||||
|
|
@ -55,7 +57,7 @@ export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) {
|
|||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className='w-56 p-2 animate-scale-in'>
|
||||
<div className='font-medium text-sm px-2 py-1.5'>Switch Account</div>
|
||||
<div className='font-medium text-sm px-2 py-1.5'>{t('settingsUi.accountSwitcher.switchAccount')}</div>
|
||||
{otherUsers.map((user) => (
|
||||
<DropdownMenuItem
|
||||
key={user.id}
|
||||
|
|
@ -84,7 +86,7 @@ export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) {
|
|||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<Wallet className='w-4 h-4' />
|
||||
<span>Wallet Settings</span>
|
||||
<span>{t('settingsUi.accountSwitcher.walletSettings')}</span>
|
||||
</DropdownMenuItem>
|
||||
</WalletModal>
|
||||
<DropdownMenuItem
|
||||
|
|
@ -92,14 +94,14 @@ export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) {
|
|||
className='flex items-center gap-2 cursor-pointer p-2 rounded-md'
|
||||
>
|
||||
<UserPlus className='w-4 h-4' />
|
||||
<span>Add another account</span>
|
||||
<span>{t('settingsUi.accountSwitcher.addAccount')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => removeLogin(currentUser.id)}
|
||||
className='flex items-center gap-2 cursor-pointer p-2 rounded-md text-red-500'
|
||||
>
|
||||
<LogOut className='w-4 h-4' />
|
||||
<span>Log out</span>
|
||||
<span>{t('settingsUi.accountSwitcher.logout')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useLoginActions } from '@/hooks/useLoginActions';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import {
|
||||
isAmberCallback,
|
||||
getAmberCallbackResult,
|
||||
|
|
@ -32,6 +33,7 @@ import {
|
|||
export function AmberCallbackHandler() {
|
||||
const login = useLoginActions();
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
const hasProcessed = useRef(false);
|
||||
const [debugInfo, setDebugInfo] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -57,7 +59,7 @@ export function AmberCallbackHandler() {
|
|||
hasProcessed.current = true;
|
||||
|
||||
// Show debug info temporarily
|
||||
setDebugInfo('Processing Amber login...');
|
||||
setDebugInfo(t('settingsUi.amberCallback.processing'));
|
||||
|
||||
// Process async to allow IndexedDB lookup
|
||||
processAmberCallback();
|
||||
|
|
@ -65,7 +67,7 @@ export function AmberCallbackHandler() {
|
|||
async function processAmberCallback() {
|
||||
const result = getAmberCallbackResult();
|
||||
|
||||
setDebugInfo(`Got result: ${result ? result.substring(0, 16) + '...' : 'null'}`);
|
||||
setDebugInfo(t('settingsUi.amberCallback.gotResult', { result: result ? result.substring(0, 16) + '...' : 'null' }));
|
||||
|
||||
// Clean up URL immediately to prevent re-processing on refresh
|
||||
cleanAmberCallbackUrl();
|
||||
|
|
@ -73,10 +75,10 @@ export function AmberCallbackHandler() {
|
|||
if (!result) {
|
||||
// User cancelled or Amber failed
|
||||
clearAmberPendingOperation();
|
||||
setDebugInfo('No result from Amber');
|
||||
setDebugInfo(t('settingsUi.amberCallback.noResultDebug'));
|
||||
toast({
|
||||
title: 'Amber',
|
||||
description: 'No result received from Amber. Operation may have been cancelled.',
|
||||
description: t('settingsUi.amberCallback.noResultToast'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -96,23 +98,23 @@ export function AmberCallbackHandler() {
|
|||
// Check if result looks like a pubkey (login callback)
|
||||
if (pubkeyRegex.test(result)) {
|
||||
console.log('[AmberCallback] Result looks like pubkey, treating as login callback');
|
||||
setDebugInfo('Valid pubkey! Logging in...');
|
||||
setDebugInfo(t('settingsUi.amberCallback.validPubkey'));
|
||||
try {
|
||||
login.amber(result.toLowerCase());
|
||||
setDebugInfo('Login successful!');
|
||||
setDebugInfo(t('settingsUi.amberCallback.loginSuccessful'));
|
||||
toast({
|
||||
title: 'Logged in',
|
||||
description: 'Successfully logged in with Amber!',
|
||||
title: t('settingsUi.amberCallback.loggedInTitle'),
|
||||
description: t('settingsUi.amberCallback.amberLoginDesc'),
|
||||
});
|
||||
// Clear debug after success
|
||||
setTimeout(() => setDebugInfo(null), 2000);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error('[AmberCallback] Amber login failed:', error);
|
||||
setDebugInfo(`Login error: ${error}`);
|
||||
setDebugInfo(t('settingsUi.amberCallback.loginErrorDebug', { error: String(error) }));
|
||||
toast({
|
||||
title: 'Login failed',
|
||||
description: 'Failed to complete Amber login. Please try again.',
|
||||
title: t('settingsUi.amberCallback.loginFailedTitle'),
|
||||
description: t('settingsUi.amberCallback.loginFailedDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -120,10 +122,10 @@ export function AmberCallbackHandler() {
|
|||
}
|
||||
|
||||
// Not a pubkey - can't determine what operation this was for
|
||||
setDebugInfo(`Invalid result format: ${result.substring(0, 20)}...`);
|
||||
setDebugInfo(t('settingsUi.amberCallback.invalidResultDebug', { result: result.substring(0, 20) }));
|
||||
toast({
|
||||
title: 'Amber',
|
||||
description: 'Session data was lost. Please try again.',
|
||||
description: t('settingsUi.amberCallback.sessionLostToast'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -140,15 +142,15 @@ export function AmberCallbackHandler() {
|
|||
clearAmberPendingOperation();
|
||||
console.log('[AmberCallback] Login successful!');
|
||||
toast({
|
||||
title: 'Logged in',
|
||||
description: 'Successfully logged in with Amber!',
|
||||
title: t('settingsUi.amberCallback.loggedInTitle'),
|
||||
description: t('settingsUi.amberCallback.amberLoginDesc'),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[AmberCallback] Amber login failed:', error);
|
||||
clearAmberPendingOperation();
|
||||
toast({
|
||||
title: 'Login failed',
|
||||
description: 'Failed to complete Amber login. Please try again.',
|
||||
title: t('settingsUi.amberCallback.loginFailedTitle'),
|
||||
description: t('settingsUi.amberCallback.loginFailedDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -156,8 +158,8 @@ export function AmberCallbackHandler() {
|
|||
console.warn('[AmberCallback] Invalid pubkey format:', result);
|
||||
clearAmberPendingOperation();
|
||||
toast({
|
||||
title: 'Login failed',
|
||||
description: 'Invalid pubkey received from Amber.',
|
||||
title: t('settingsUi.amberCallback.loginFailedTitle'),
|
||||
description: t('settingsUi.amberCallback.invalidPubkeyDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -180,8 +182,8 @@ export function AmberCallbackHandler() {
|
|||
sessionStorage.setItem('amber_signed_event', JSON.stringify(signedEvent));
|
||||
clearAmberPendingOperation();
|
||||
toast({
|
||||
title: 'Event signed',
|
||||
description: 'Your event has been signed by Amber.',
|
||||
title: t('settingsUi.amberCallback.eventSignedTitle'),
|
||||
description: t('settingsUi.amberCallback.eventSignedDesc'),
|
||||
});
|
||||
// Reload the page to re-submit the event
|
||||
// This is a workaround since we can't resolve the original promise
|
||||
|
|
@ -190,8 +192,8 @@ export function AmberCallbackHandler() {
|
|||
console.error('Failed to reconstruct signed event from Amber result');
|
||||
clearAmberPendingOperation();
|
||||
toast({
|
||||
title: 'Signing failed',
|
||||
description: 'Failed to process signed event from Amber.',
|
||||
title: t('settingsUi.amberCallback.signingFailedTitle'),
|
||||
description: t('settingsUi.amberCallback.signingFailedDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -206,8 +208,8 @@ export function AmberCallbackHandler() {
|
|||
sessionStorage.setItem('amber_crypto_result', result);
|
||||
clearAmberPendingOperation();
|
||||
toast({
|
||||
title: 'Operation complete',
|
||||
description: `${pendingOp.type.replace('_', ' ')} completed.`,
|
||||
title: t('settingsUi.amberCallback.operationCompleteTitle'),
|
||||
description: t('settingsUi.amberCallback.operationCompleteDesc', { op: pendingOp.type.replace('_', ' ') }),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
|
@ -218,7 +220,7 @@ export function AmberCallbackHandler() {
|
|||
}
|
||||
}
|
||||
}
|
||||
}, [login, toast]);
|
||||
}, [login, toast, t]);
|
||||
|
||||
// Show debug overlay when processing Amber callback
|
||||
if (debugInfo) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import SignupDialog from './SignupDialog';
|
|||
import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts';
|
||||
import { AccountSwitcher } from './AccountSwitcher';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
export interface LoginAreaProps {
|
||||
className?: string;
|
||||
|
|
@ -18,6 +19,7 @@ export interface LoginAreaProps {
|
|||
|
||||
export function LoginArea({ className, compact }: LoginAreaProps) {
|
||||
const { currentUser } = useLoggedInAccounts();
|
||||
const { t } = useTranslation();
|
||||
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
|
||||
const [signupDialogOpen, setSignupDialogOpen] = useState(false);
|
||||
|
||||
|
|
@ -37,7 +39,7 @@ export function LoginArea({ className, compact }: LoginAreaProps) {
|
|||
onClick={() => setSignupDialogOpen(true)}
|
||||
size="icon"
|
||||
className='h-9 w-9 rounded-full bg-[#ccff00] text-black hover:bg-[#ccff00]/90'
|
||||
title="Sign Up"
|
||||
title={t('settingsUi.loginArea.signUp')}
|
||||
>
|
||||
<UserPlus className='w-4 h-4' />
|
||||
</Button>
|
||||
|
|
@ -46,7 +48,7 @@ export function LoginArea({ className, compact }: LoginAreaProps) {
|
|||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9 rounded-full border border-gray-600 hover:border-[#ff0040]"
|
||||
title="Log In"
|
||||
title={t('settingsUi.loginArea.logIn')}
|
||||
>
|
||||
<User className='w-4 h-4' />
|
||||
</Button>
|
||||
|
|
@ -59,7 +61,7 @@ export function LoginArea({ className, compact }: LoginAreaProps) {
|
|||
className='flex items-center gap-2 px-5 py-2.5 rounded-full bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold transition-all flex-1 sm:flex-none'
|
||||
>
|
||||
<UserPlus className='w-4 h-4' />
|
||||
<span>Sign Up</span>
|
||||
<span>{t('settingsUi.loginArea.signUp')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setLoginDialogOpen(true)}
|
||||
|
|
@ -67,7 +69,7 @@ export function LoginArea({ className, compact }: LoginAreaProps) {
|
|||
className="flex items-center gap-2 px-5 py-2.5 rounded-full font-medium transition-all border-2 border-gray-600 hover:border-[#ff0040] flex-1 sm:flex-none"
|
||||
>
|
||||
<User className='w-4 h-4' />
|
||||
<span className='truncate'>Log In</span>
|
||||
<span className='truncate'>{t('settingsUi.loginArea.logIn')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { useNostr } from '@nostrify/react';
|
|||
import { QRCodeCanvas } from '@/components/ui/qrcode';
|
||||
import { startNostrConnect, nostrConnectBunkerUri, type NostrConnectHandle } from '@/lib/nostrConnect';
|
||||
import { decryptNcryptsec } from '@/lib/ncryptsec';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
|
||||
interface LoginDialogProps {
|
||||
|
|
@ -31,6 +32,7 @@ interface LoginDialogProps {
|
|||
}
|
||||
|
||||
export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onSignup }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [bunkerUri, setBunkerUri] = useState('');
|
||||
const [bunkerError, setBunkerError] = useState('');
|
||||
|
|
@ -84,12 +86,12 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
const pubkey = await getAmberPublicKeyViaPlugin();
|
||||
completeAmberLogin(pubkey);
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as Error)?.message ?? 'Amber login failed';
|
||||
const msg = (e as Error)?.message ?? t('settingsUi.loginDialog.amberLoginFailed');
|
||||
// Distinguish: user cancelled vs Amber not installed vs other error
|
||||
if (msg.toLowerCase().includes('cancel') || msg.toLowerCase().includes('denied')) {
|
||||
setAmberError('Login cancelled. Tap "Open Amber" to try again.');
|
||||
setAmberError(t('settingsUi.loginDialog.amberCancelled'));
|
||||
} else if (msg.toLowerCase().includes('not found') || msg.toLowerCase().includes('no activity') || msg.toLowerCase().includes('resolve')) {
|
||||
setAmberError('Amber not found. Make sure Amber is installed from the ZapStore.');
|
||||
setAmberError(t('settingsUi.loginDialog.amberNotFound'));
|
||||
} else {
|
||||
setAmberError(msg);
|
||||
}
|
||||
|
|
@ -167,10 +169,10 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
try {
|
||||
login.amber(pubkey);
|
||||
setAmberStep('done');
|
||||
toast({ title: '🛹 Logged in!', description: 'Connected via Amber!' });
|
||||
toast({ title: t('settingsUi.loginDialog.loggedInTitle'), description: t('settingsUi.loginDialog.connectedAmber') });
|
||||
setTimeout(() => { onLogin(); onClose(); }, 800);
|
||||
} catch (e: unknown) {
|
||||
setAmberError((e as Error)?.message ?? 'Login failed');
|
||||
setAmberError((e as Error)?.message ?? t('settingsUi.loginDialog.loginFailed'));
|
||||
setAmberStep('error');
|
||||
}
|
||||
};
|
||||
|
|
@ -195,7 +197,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
}
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
setPubkeyError('Paste your hex pubkey (64 chars) or npub from Amber');
|
||||
setPubkeyError(t('settingsUi.loginDialog.pubkeyError'));
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -216,13 +218,13 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
.then(async ({ signerPubkey }) => {
|
||||
const uri = nostrConnectBunkerUri(signerPubkey, session.relays, session.secret);
|
||||
await login.bunker(uri);
|
||||
toast({ title: '🛹 Logged in!', description: 'Paired with your signer.' });
|
||||
toast({ title: t('settingsUi.loginDialog.loggedInTitle'), description: t('settingsUi.loginDialog.pairedSigner') });
|
||||
onLogin();
|
||||
onClose();
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
setConnectState('error');
|
||||
setConnectError((e as Error)?.message ?? 'Could not connect to your signer.');
|
||||
setConnectError((e as Error)?.message ?? t('settingsUi.loginDialog.connectFailed'));
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -240,11 +242,11 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
try {
|
||||
const nsec = decryptNcryptsec(ncryptsecInput, ncryptsecPassword);
|
||||
login.nsec(nsec);
|
||||
toast({ title: '🛹 Logged in!', description: 'Opened your encrypted key.' });
|
||||
toast({ title: t('settingsUi.loginDialog.loggedInTitle'), description: t('settingsUi.loginDialog.openedEncrypted') });
|
||||
onLogin();
|
||||
onClose();
|
||||
} catch (e: unknown) {
|
||||
setNsecError((e as Error)?.message ?? 'Could not open that key.');
|
||||
setNsecError((e as Error)?.message ?? t('settingsUi.loginDialog.openKeyFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -284,33 +286,33 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
|
||||
const handleBunkerLogin = async () => {
|
||||
const uri = bunkerUri.trim();
|
||||
if (!uri.startsWith('bunker://')) { setBunkerError('Must start with bunker://'); return; }
|
||||
if (!uri.startsWith('bunker://')) { setBunkerError(t('settingsUi.loginDialog.bunkerPrefix')); return; }
|
||||
setIsLoading(true);
|
||||
setBunkerError('');
|
||||
try {
|
||||
await login.bunker(uri);
|
||||
toast({ title: '🛹 Logged in!', description: 'Connected via bunker!' });
|
||||
toast({ title: t('settingsUi.loginDialog.loggedInTitle'), description: t('settingsUi.loginDialog.connectedBunker') });
|
||||
onLogin(); onClose();
|
||||
} catch (e: unknown) {
|
||||
setBunkerError(`Failed: ${(e as Error)?.message ?? 'Check your bunker URI'}`);
|
||||
setBunkerError(t('settingsUi.loginDialog.bunkerFailed', { error: (e as Error)?.message ?? t('settingsUi.loginDialog.checkBunkerUri') }));
|
||||
} finally { setIsLoading(false); }
|
||||
};
|
||||
|
||||
const handleNsecLogin = () => {
|
||||
const val = nsecInput.trim();
|
||||
if (!val.startsWith('nsec1')) { setNsecError('Must start with nsec1'); return; }
|
||||
if (!val.startsWith('nsec1')) { setNsecError(t('settingsUi.loginDialog.nsecPrefix')); return; }
|
||||
try {
|
||||
login.nsec(val);
|
||||
toast({ title: '🛹 Logged in!' });
|
||||
toast({ title: t('settingsUi.loginDialog.loggedInTitle') });
|
||||
onLogin(); onClose();
|
||||
} catch (e: unknown) {
|
||||
setNsecError(`Invalid nsec: ${(e as Error)?.message ?? 'Check your key'}`);
|
||||
setNsecError(t('settingsUi.loginDialog.invalidNsec', { error: (e as Error)?.message ?? t('settingsUi.loginDialog.checkKey') }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleExtensionLogin = async () => {
|
||||
try { await login.extension(); onLogin(); onClose(); }
|
||||
catch { toast({ title: 'No extension found', description: 'Install Alby or another NIP-07 extension.', variant: 'destructive' }); }
|
||||
catch { toast({ title: t('settingsUi.loginDialog.noExtensionTitle'), description: t('settingsUi.loginDialog.noExtensionDesc'), variant: 'destructive' }); }
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -318,10 +320,10 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
<DialogContent className={cn("w-[calc(100%-1rem)] sm:max-w-md max-h-[90vh] max-h-[90dvh] p-0 overflow-hidden rounded-2xl bg-black border-2 border-[#ff0040]/50")}>
|
||||
<DialogHeader className={cn('px-4 sm:px-6 pt-4 sm:pt-6 pb-1')}>
|
||||
<DialogTitle className="text-center font-black uppercase tracking-wide text-xl">
|
||||
Welcome to SkateSpots
|
||||
{t('settingsUi.loginDialog.welcomeTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-center text-sm text-gray-400">
|
||||
Sign up or log in to continue
|
||||
{t('settingsUi.loginDialog.welcomeSubtitle')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -332,18 +334,18 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
<div className='relative z-10 text-center space-y-3'>
|
||||
<div className='flex justify-center items-center gap-2'>
|
||||
<Sparkles className='w-5 h-5 text-[#ccff00]' />
|
||||
<span className='font-bold text-[#ccff00] uppercase tracking-wide'>New here?</span>
|
||||
<span className='font-bold text-[#ccff00] uppercase tracking-wide'>{t('settingsUi.loginDialog.newHere')}</span>
|
||||
</div>
|
||||
<p className='text-xs text-gray-300'>Create a free account. No email required.</p>
|
||||
<p className='text-xs text-gray-300'>{t('settingsUi.loginDialog.createFree')}</p>
|
||||
<Button onClick={() => { onClose(); onSignup?.(); }} className='w-full rounded-full py-3 font-bold bg-[#ccff00] text-black hover:bg-[#ccff00]/90 uppercase'>
|
||||
<UserPlus className='w-4 h-4 mr-2' />Create Account
|
||||
<UserPlus className='w-4 h-4 mr-2' />{t('settingsUi.loginDialog.createAccount')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='relative flex items-center'>
|
||||
<div className='flex-1 border-t border-gray-800' />
|
||||
<span className='px-3 text-xs text-gray-500'>Or log in</span>
|
||||
<span className='px-3 text-xs text-gray-500'>{t('settingsUi.loginDialog.orLogIn')}</span>
|
||||
<div className='flex-1 border-t border-gray-800' />
|
||||
</div>
|
||||
|
||||
|
|
@ -370,7 +372,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
{amberStep === 'done' && (
|
||||
<div className='text-center p-5 rounded-xl bg-black/50 border-2 border-green-500/30 space-y-3'>
|
||||
<Check className='w-10 h-10 mx-auto text-green-500' />
|
||||
<p className='text-sm text-green-400 font-bold'>Logged in!</p>
|
||||
<p className='text-sm text-green-400 font-bold'>{t('settingsUi.loginDialog.loggedIn')}</p>
|
||||
<Loader2 className="w-4 h-4 mx-auto animate-spin text-green-500" />
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -382,17 +384,16 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
<Smartphone className='w-7 h-7 text-[#ccff00]' />
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-sm text-white font-bold'>Login with Amber</p>
|
||||
<p className='text-sm text-white font-bold'>{t('settingsUi.loginDialog.loginWithAmber')}</p>
|
||||
<p className='text-xs text-gray-400 mt-1'>
|
||||
Your key stays inside Amber. One tap — approve in Amber and you're
|
||||
back here, signed in. Nothing to copy.
|
||||
{t('settingsUi.loginDialog.amberIdleDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className='w-full rounded-full py-3 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase'
|
||||
onClick={handleAmberLogin}
|
||||
>
|
||||
<Smartphone className="w-4 h-4 mr-2" />Continue with Amber
|
||||
<Smartphone className="w-4 h-4 mr-2" />{t('settingsUi.loginDialog.continueWithAmber')}
|
||||
</Button>
|
||||
<a
|
||||
href="https://github.com/greenart7c3/Amber"
|
||||
|
|
@ -400,7 +401,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
rel="noopener noreferrer"
|
||||
className="block text-[11px] text-gray-600 hover:text-[#ccff00]"
|
||||
>
|
||||
Don't have Amber? Get it →
|
||||
{t('settingsUi.loginDialog.noAmberGetIt')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -413,10 +414,9 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
<Loader2 className='w-7 h-7 text-[#ccff00] animate-spin' />
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-sm text-white font-bold'>Waiting for Amber…</p>
|
||||
<p className='text-sm text-white font-bold'>{t('settingsUi.loginDialog.waitingAmberTitle')}</p>
|
||||
<p className='text-xs text-gray-400 mt-1'>
|
||||
Approve the request in Amber. You'll be signed in automatically
|
||||
the moment you come back.
|
||||
{t('settingsUi.loginDialog.waitingAmberDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
|
|
@ -424,13 +424,13 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
className='w-full rounded-full py-2.5 border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10 font-bold'
|
||||
onClick={handleAmberLogin}
|
||||
>
|
||||
<Smartphone className="w-4 h-4 mr-2" />Open Amber again
|
||||
<Smartphone className="w-4 h-4 mr-2" />{t('settingsUi.loginDialog.openAmberAgain')}
|
||||
</Button>
|
||||
<button
|
||||
className='w-full text-[11px] text-gray-600 hover:text-gray-400 py-1'
|
||||
onClick={() => setShowManualPaste(true)}
|
||||
>
|
||||
Having trouble? Enter your key manually
|
||||
{t('settingsUi.loginDialog.havingTrouble')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -441,10 +441,10 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
<div className='p-5 rounded-xl bg-black/50 border-2 border-[#ccff00]/30 space-y-4'>
|
||||
<div className='text-center space-y-1'>
|
||||
<Smartphone className='w-8 h-8 mx-auto text-[#ccff00]' />
|
||||
<p className='text-sm text-white font-bold'>Enter your public key</p>
|
||||
<p className='text-sm text-white font-bold'>{t('settingsUi.loginDialog.enterPublicKey')}</p>
|
||||
<p className='text-xs text-gray-400'>
|
||||
Only if the automatic flow didn't work. In Amber: tap your profile →
|
||||
{' '}<strong className='text-white'>Copy public key</strong>, then paste it here.
|
||||
{t('settingsUi.loginDialog.enterPublicKeyDescBefore')}{' '}
|
||||
<strong className='text-white'>{t('settingsUi.loginDialog.copyPublicKey')}</strong>, {t('settingsUi.loginDialog.enterPublicKeyDescAfter')}
|
||||
</p>
|
||||
</div>
|
||||
{amberStep === 'error' && amberError && (
|
||||
|
|
@ -465,11 +465,11 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
disabled={!pubkeyInput.trim()}
|
||||
onClick={handlePubkeySubmit}
|
||||
>
|
||||
<Check className='w-4 h-4 mr-2' />Connect
|
||||
<Check className='w-4 h-4 mr-2' />{t('settingsUi.loginDialog.connect')}
|
||||
</Button>
|
||||
<Button variant='ghost' className='w-full text-xs text-gray-600 h-auto py-1'
|
||||
onClick={() => { setAmberStep('idle'); setShowManualPaste(false); setPubkeyInput(''); setPubkeyError(''); setAmberError(''); }}>
|
||||
← Back
|
||||
{t('settingsUi.loginDialog.back')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -484,9 +484,9 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
<div className='p-5 rounded-xl bg-black/50 border-2 border-blue-500/30 space-y-4'>
|
||||
<div className='text-center'>
|
||||
<QrCode className='w-10 h-10 mx-auto mb-2 text-blue-400' />
|
||||
<p className='text-sm text-white font-bold'>Sign in with your phone</p>
|
||||
<p className='text-sm text-white font-bold'>{t('settingsUi.loginDialog.signInWithPhone')}</p>
|
||||
<p className='text-xs text-gray-400 mt-1'>
|
||||
Scan with a signer app (Amber, nsec.app). Your key never leaves your phone.
|
||||
{t('settingsUi.loginDialog.signInWithPhoneDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -495,7 +495,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
className='w-full rounded-full py-3 bg-blue-500 hover:bg-blue-600 text-white font-bold uppercase'
|
||||
onClick={handleNostrConnect}
|
||||
>
|
||||
<QrCode className='w-4 h-4 mr-2' />Show pairing code
|
||||
<QrCode className='w-4 h-4 mr-2' />{t('settingsUi.loginDialog.showPairingCode')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
|
@ -505,7 +505,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
<QRCodeCanvas value={connectSession.uri} size={196} level='M' />
|
||||
</div>
|
||||
<p className='flex items-center justify-center gap-2 text-center text-xs text-gray-400'>
|
||||
<Loader2 className='w-3.5 h-3.5 animate-spin' /> Waiting for your signer…
|
||||
<Loader2 className='w-3.5 h-3.5 animate-spin' /> {t('settingsUi.loginDialog.waitingSigner')}
|
||||
</p>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
|
|
@ -519,15 +519,15 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
}}
|
||||
>
|
||||
{copiedUri
|
||||
? <><Check className='w-3.5 h-3.5 mr-1' />Copied</>
|
||||
: <><Copy className='w-3.5 h-3.5 mr-1' />Copy link</>}
|
||||
? <><Check className='w-3.5 h-3.5 mr-1' />{t('common.copied')}</>
|
||||
: <><Copy className='w-3.5 h-3.5 mr-1' />{t('settingsUi.loginDialog.copyLink')}</>}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='flex-1 rounded-full text-xs border-white/15 text-gray-400 hover:bg-white/5'
|
||||
onClick={cancelNostrConnect}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -540,7 +540,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
className='w-full rounded-full py-2.5 bg-blue-500 hover:bg-blue-600 text-white font-bold uppercase'
|
||||
onClick={handleNostrConnect}
|
||||
>
|
||||
Try again
|
||||
{t('common.tryAgain')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -548,26 +548,26 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
|
||||
<div className='relative flex items-center'>
|
||||
<div className='flex-1 border-t border-gray-800' />
|
||||
<span className='px-3 text-xs text-gray-500'>or paste a bunker URL</span>
|
||||
<span className='px-3 text-xs text-gray-500'>{t('settingsUi.loginDialog.orPasteBunker')}</span>
|
||||
<div className='flex-1 border-t border-gray-800' />
|
||||
</div>
|
||||
|
||||
<div className='p-5 rounded-xl bg-black/50 border-2 border-blue-500/30 space-y-4'>
|
||||
<div className='text-center'>
|
||||
<Cloud className='w-10 h-10 mx-auto mb-2 text-blue-400' />
|
||||
<p className='text-sm text-white font-bold'>Bunker (NIP-46)</p>
|
||||
<p className='text-xs text-gray-400 mt-1'>Paste your <code className='text-blue-300'>bunker://</code> URI.</p>
|
||||
<p className='text-sm text-white font-bold'>{t('settingsUi.loginDialog.bunkerTitle')}</p>
|
||||
<p className='text-xs text-gray-400 mt-1'>{t('settingsUi.loginDialog.bunkerPasteBefore')} <code className='text-blue-300'>bunker://</code> {t('settingsUi.loginDialog.bunkerPasteAfter')}</p>
|
||||
</div>
|
||||
<div className='text-xs text-gray-300 bg-black/40 rounded-lg p-3 space-y-1 border border-gray-800'>
|
||||
<p><strong className='text-blue-300'>Amber:</strong> Settings → NIP-46 Bunker → Create → Copy</p>
|
||||
<p><strong className='text-blue-300'>nsec.app:</strong> Connect app → Copy bunker URI</p>
|
||||
<p><strong className='text-blue-300'>Amber:</strong> {t('settingsUi.loginDialog.amberInstructions')}</p>
|
||||
<p><strong className='text-blue-300'>nsec.app:</strong> {t('settingsUi.loginDialog.nsecAppInstructions')}</p>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Input placeholder='bunker://...' value={bunkerUri} onChange={e => { setBunkerUri(e.target.value); setBunkerError(''); }} className='bg-black/60 border-blue-500/30 text-white placeholder:text-gray-600 text-xs font-mono' autoComplete='off' autoCorrect='off' spellCheck={false} />
|
||||
{bunkerError && <p className="text-xs text-red-400">{bunkerError}</p>}
|
||||
</div>
|
||||
<Button className='w-full rounded-full py-3 bg-blue-500 hover:bg-blue-600 text-white font-bold uppercase' disabled={isLoading || !bunkerUri.trim()} onClick={handleBunkerLogin}>
|
||||
{isLoading ? <Loader2 className='w-4 h-4 animate-spin mr-2' /> : <Cloud className='w-4 h-4 mr-2' />}Connect
|
||||
{isLoading ? <Loader2 className='w-4 h-4 animate-spin mr-2' /> : <Cloud className='w-4 h-4 mr-2' />}{t('settingsUi.loginDialog.connect')}
|
||||
</Button>
|
||||
<div className='flex gap-2 justify-center'>
|
||||
<a href="https://nsec.app" target="_blank" rel="noopener noreferrer" className="text-xs px-3 py-1 bg-blue-500/20 text-blue-400 rounded-full hover:bg-blue-500/30">nsec.app →</a>
|
||||
|
|
@ -581,11 +581,11 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
<div className='p-5 rounded-xl bg-black/50 border-2 border-yellow-500/30 space-y-4'>
|
||||
<div className='text-center'>
|
||||
<KeyRound className='w-10 h-10 mx-auto mb-2 text-yellow-400' />
|
||||
<p className='text-sm text-white font-bold'>Secret Key</p>
|
||||
<p className='text-sm text-white font-bold'>{t('settingsUi.loginDialog.secretKeyTitle')}</p>
|
||||
<p className='text-xs text-gray-400 mt-1'>
|
||||
{nsecMode === 'raw'
|
||||
? 'Paste your Nostr secret key directly.'
|
||||
: 'Open a password-protected key backup.'}
|
||||
? t('settingsUi.loginDialog.secretKeyRawDesc')
|
||||
: t('settingsUi.loginDialog.secretKeyEncDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -601,7 +601,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
nsecMode === m ? 'bg-yellow-500 text-black' : 'text-gray-400 hover:text-white',
|
||||
)}
|
||||
>
|
||||
{m === 'raw' ? 'Raw nsec' : 'Encrypted (NIP-49)'}
|
||||
{m === 'raw' ? t('settingsUi.loginDialog.rawNsec') : t('settingsUi.loginDialog.encryptedNip49')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -609,22 +609,21 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
{nsecMode === 'raw' ? (
|
||||
<>
|
||||
<div className='p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30'>
|
||||
<p className='text-xs text-yellow-300'>⚠️ Only use this on a trusted device. Never share your nsec.</p>
|
||||
<p className='text-xs text-yellow-300'>{t('settingsUi.loginDialog.nsecWarning')}</p>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Input type="password" placeholder='nsec1...' value={nsecInput} onChange={e => { setNsecInput(e.target.value); setNsecError(''); }} className='bg-black/60 border-yellow-500/30 text-white placeholder:text-gray-600 text-xs font-mono' autoComplete='off' autoCorrect='off' spellCheck={false} />
|
||||
{nsecError && <p className="text-xs text-red-400">{nsecError}</p>}
|
||||
</div>
|
||||
<Button className='w-full rounded-full py-3 bg-yellow-500 hover:bg-yellow-600 text-black font-bold uppercase' disabled={!nsecInput.trim()} onClick={handleNsecLogin}>
|
||||
<KeyRound className='w-4 h-4 mr-2' />Login
|
||||
<KeyRound className='w-4 h-4 mr-2' />{t('settingsUi.loginDialog.login')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className='p-3 rounded-lg bg-green-500/10 border border-green-500/30'>
|
||||
<p className='text-xs text-green-300'>
|
||||
Safer: the key stays encrypted until you enter your password, so the
|
||||
backup is safe to keep in cloud storage or a password manager.
|
||||
{t('settingsUi.loginDialog.encSaferDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
|
|
@ -637,7 +636,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
/>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder='Password'
|
||||
placeholder={t('settingsUi.loginDialog.password')}
|
||||
value={ncryptsecPassword}
|
||||
onChange={e => { setNcryptsecPassword(e.target.value); setNsecError(''); }}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && ncryptsecInput.trim() && ncryptsecPassword) handleNcryptsecLogin(); }}
|
||||
|
|
@ -651,7 +650,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
disabled={!ncryptsecInput.trim() || !ncryptsecPassword}
|
||||
onClick={handleNcryptsecLogin}
|
||||
>
|
||||
<Unlock className='w-4 h-4 mr-2' />Open encrypted key
|
||||
<Unlock className='w-4 h-4 mr-2' />{t('settingsUi.loginDialog.openEncryptedKey')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -663,11 +662,11 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
<div className='text-center p-5 rounded-xl bg-black/50 border-2 border-[#ff0040]/30 space-y-4'>
|
||||
<Shield className='w-10 h-10 mx-auto text-[#ff0040]' />
|
||||
<div>
|
||||
<p className='text-sm text-white font-bold'>Browser Extension (NIP-07)</p>
|
||||
<p className='text-xs text-gray-400 mt-1'>Best for desktop. Works with Alby, nos2x, Flamingo.</p>
|
||||
<p className='text-sm text-white font-bold'>{t('settingsUi.loginDialog.browserExtensionTitle')}</p>
|
||||
<p className='text-xs text-gray-400 mt-1'>{t('settingsUi.loginDialog.browserExtensionDesc')}</p>
|
||||
</div>
|
||||
<Button className='w-full rounded-full py-3 bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold uppercase' onClick={handleExtensionLogin}>
|
||||
Login with Extension
|
||||
{t('settingsUi.loginDialog.loginWithExtension')}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { useLoginActions } from '@/hooks/useLoginActions';
|
|||
import { nip19 } from 'nostr-tools';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { generateMnemonicPhrase, privateKeyFromMnemonic, mnemonicToWords } from '@/lib/nip06';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
// qrcode imported lazily in async function
|
||||
|
||||
interface MnemonicSignupDialogProps {
|
||||
|
|
@ -22,6 +23,7 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
|
|||
onClose,
|
||||
onSwitchToNsec
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState<'generate' | 'verify' | 'complete'>('generate');
|
||||
const [mnemonic, setMnemonic] = useState('');
|
||||
const [words, setWords] = useState<string[]>([]);
|
||||
|
|
@ -46,8 +48,8 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
|
|||
setVerifyIndices(indices.sort((a, b) => a - b));
|
||||
|
||||
toast({
|
||||
title: 'Recovery Phrase Generated! 🎉',
|
||||
description: 'Write down these 12 words in order. They\'re the key to your account!',
|
||||
title: t('settingsUi.mnemonic.generatedTitle'),
|
||||
description: t('settingsUi.mnemonic.generatedDesc'),
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -66,13 +68,13 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
|
|||
document.body.removeChild(a);
|
||||
|
||||
toast({
|
||||
title: 'Recovery Phrase Saved!',
|
||||
description: 'Keep this file in a secure location.',
|
||||
title: t('settingsUi.mnemonic.savedTitle'),
|
||||
description: t('settingsUi.mnemonic.savedDesc'),
|
||||
});
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Download failed',
|
||||
description: 'Could not download the file. Please copy manually.',
|
||||
title: t('settingsUi.mnemonic.downloadFailedTitle'),
|
||||
description: t('settingsUi.mnemonic.downloadFailedDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -82,8 +84,8 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
|
|||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(mnemonic);
|
||||
toast({
|
||||
title: 'Copied to clipboard!',
|
||||
description: 'Your recovery phrase has been copied.',
|
||||
title: t('settingsUi.mnemonic.copiedTitle'),
|
||||
description: t('settingsUi.mnemonic.copiedDesc'),
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -102,7 +104,7 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
|
|||
win.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
<title>Recovery Phrase QR Code</title>
|
||||
<title>${t('settingsUi.mnemonic.qrWindowTitle')}</title>
|
||||
<style>
|
||||
body {
|
||||
display: flex;
|
||||
|
|
@ -121,11 +123,11 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
|
|||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🔐 Your Recovery Phrase QR Code</h1>
|
||||
<img src="${qrDataUrl}" alt="Recovery Phrase QR Code" />
|
||||
<p>⚠️ Keep this QR code private and secure!</p>
|
||||
<h1>🔐 ${t('settingsUi.mnemonic.qrHeading')}</h1>
|
||||
<img src="${qrDataUrl}" alt="${t('settingsUi.mnemonic.qrAlt')}" />
|
||||
<p>⚠️ ${t('settingsUi.mnemonic.qrWarning')}</p>
|
||||
<button onclick="window.print()" style="margin-top: 1rem; padding: 0.5rem 1rem; font-size: 16px; cursor: pointer;">
|
||||
Print QR Code
|
||||
${t('settingsUi.mnemonic.qrPrint')}
|
||||
</button>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -134,8 +136,8 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
|
|||
}
|
||||
} catch {
|
||||
toast({
|
||||
title: 'QR Code failed',
|
||||
description: 'Could not generate QR code.',
|
||||
title: t('settingsUi.mnemonic.qrFailedTitle'),
|
||||
description: t('settingsUi.mnemonic.qrFailedDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -162,8 +164,8 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
|
|||
setStep('complete');
|
||||
|
||||
toast({
|
||||
title: 'Account Created! 🎉',
|
||||
description: 'You\'re all set! Your recovery phrase is secure.',
|
||||
title: t('settingsUi.mnemonic.accountCreatedTitle'),
|
||||
description: t('settingsUi.mnemonic.accountCreatedDesc'),
|
||||
});
|
||||
|
||||
// Mark signup as completed
|
||||
|
|
@ -174,15 +176,15 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
|
|||
}, 2000);
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Login Failed',
|
||||
description: 'Failed to create account. Please try again.',
|
||||
title: t('settingsUi.mnemonic.loginFailedTitle'),
|
||||
description: t('settingsUi.mnemonic.loginFailedDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
title: 'Verification Failed',
|
||||
description: 'The words don\'t match. Please check and try again.',
|
||||
title: t('settingsUi.mnemonic.verifyFailedTitle'),
|
||||
description: t('settingsUi.mnemonic.verifyFailedDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -202,8 +204,8 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
|
|||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto bg-gradient-to-br from-gray-900 to-black border-2 border-[#ccff00]/30">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-black uppercase text-center">
|
||||
<span className="neon-orange">Create Account with</span>{' '}
|
||||
<span className="neon-lime">Recovery Phrase</span>
|
||||
<span className="neon-orange">{t('settingsUi.mnemonic.titlePrefix')}</span>{' '}
|
||||
<span className="neon-lime">{t('settingsUi.mnemonic.titleSuffix')}</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -215,25 +217,24 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
|
|||
<CardContent className="p-8 text-center space-y-4">
|
||||
<div className="text-6xl mb-4">🔑</div>
|
||||
<h3 className="text-xl font-bold text-[#ccff00]">
|
||||
12-Word Recovery Phrase
|
||||
{t('settingsUi.mnemonic.twelveWordTitle')}
|
||||
</h3>
|
||||
<p className="text-gray-400 text-sm max-w-md mx-auto">
|
||||
Your recovery phrase is like a master password. Write it down on paper and keep it safe.
|
||||
You'll need it to access your account from other devices or if you clear your browser data.
|
||||
{t('settingsUi.mnemonic.twelveWordDesc')}
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
className="w-full max-w-xs bg-gradient-to-r from-[#ff0040] to-[#ff6600] hover:from-[#ff0040]/90 hover:to-[#ff6600]/90 font-bold uppercase"
|
||||
size="lg"
|
||||
>
|
||||
Generate Recovery Phrase
|
||||
{t('settingsUi.mnemonic.generate')}
|
||||
</Button>
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={onSwitchToNsec}
|
||||
className="text-xs text-gray-500 hover:text-gray-300 underline"
|
||||
>
|
||||
Use advanced nsec key instead
|
||||
{t('settingsUi.mnemonic.useNsecInstead')}
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -244,7 +245,7 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
|
|||
<Card className="bg-black/50 border-[#ccff00]/30">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-bold text-[#ccff00] uppercase">Your Recovery Phrase</h3>
|
||||
<h3 className="font-bold text-[#ccff00] uppercase">{t('settingsUi.mnemonic.yourPhrase')}</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -290,7 +291,7 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
|
|||
className="border-[#ccff00]/50 text-[#ccff00] hover:bg-[#ccff00]/10"
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy
|
||||
{t('common.copy')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
|
|
@ -299,7 +300,7 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
|
|||
className="border-[#ccff00]/50 text-[#ccff00] hover:bg-[#ccff00]/10"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
{t('settingsUi.mnemonic.download')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleQR}
|
||||
|
|
@ -307,7 +308,7 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
|
|||
size="sm"
|
||||
className="border-[#ccff00]/50 text-[#ccff00] hover:bg-[#ccff00]/10"
|
||||
>
|
||||
QR Code
|
||||
{t('settingsUi.mnemonic.qrCode')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -317,14 +318,14 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
|
|||
<Card className="bg-[#ff0040]/10 border-2 border-[#ff0040]/50">
|
||||
<CardContent className="p-6">
|
||||
<h3 className="font-bold text-[#ff0040] mb-3 uppercase flex items-center gap-2">
|
||||
<span>⚠️</span> Critical Security Warning
|
||||
<span>⚠️</span> {t('settingsUi.mnemonic.criticalWarning')}
|
||||
</h3>
|
||||
<ul className="space-y-2 text-sm text-gray-300">
|
||||
<li>✍️ <strong>Write it down</strong> on paper and store it safely</li>
|
||||
<li>🔒 <strong>Never share</strong> your recovery phrase with anyone</li>
|
||||
<li>💾 <strong>Keep multiple backups</strong> in different secure locations</li>
|
||||
<li>❌ <strong>Anyone with this phrase</strong> controls your account forever</li>
|
||||
<li>🚫 <strong>If you lose it</strong>, you lose your account permanently</li>
|
||||
<li>✍️ <strong>{t('settingsUi.mnemonic.warnWriteStrong')}</strong> {t('settingsUi.mnemonic.warnWrite')}</li>
|
||||
<li>🔒 <strong>{t('settingsUi.mnemonic.warnNeverShareStrong')}</strong> {t('settingsUi.mnemonic.warnNeverShare')}</li>
|
||||
<li>💾 <strong>{t('settingsUi.mnemonic.warnBackupsStrong')}</strong> {t('settingsUi.mnemonic.warnBackups')}</li>
|
||||
<li>❌ <strong>{t('settingsUi.mnemonic.warnAnyoneStrong')}</strong> {t('settingsUi.mnemonic.warnAnyone')}</li>
|
||||
<li>🚫 <strong>{t('settingsUi.mnemonic.warnLoseStrong')}</strong> {t('settingsUi.mnemonic.warnLose')}</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -335,7 +336,7 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
|
|||
className="w-full bg-gradient-to-r from-[#ccff00] to-[#00ff00] hover:from-[#ccff00]/90 hover:to-[#00ff00]/90 text-black font-bold uppercase"
|
||||
size="lg"
|
||||
>
|
||||
I've Saved It Securely
|
||||
{t('settingsUi.mnemonic.savedSecurely')}
|
||||
<ArrowRight className="ml-2 h-5 w-5" />
|
||||
</Button>
|
||||
</>
|
||||
|
|
@ -349,23 +350,23 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
|
|||
<Card className="bg-black/50 border-[#ccff00]/30">
|
||||
<CardContent className="p-6">
|
||||
<h3 className="font-bold text-[#ccff00] mb-4 uppercase text-center">
|
||||
Verify Your Recovery Phrase
|
||||
{t('settingsUi.mnemonic.verifyTitle')}
|
||||
</h3>
|
||||
<p className="text-gray-400 text-sm mb-6 text-center">
|
||||
To make sure you've saved it correctly, please enter these words:
|
||||
{t('settingsUi.mnemonic.verifyDesc')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-4 max-w-md mx-auto">
|
||||
{verifyIndices.map((idx) => (
|
||||
<div key={idx} className="space-y-2">
|
||||
<label className="text-sm text-gray-300 font-mono">
|
||||
Word #{idx + 1}
|
||||
{t('settingsUi.mnemonic.wordLabel', { n: idx + 1 })}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={verifyInputs[idx] || ''}
|
||||
onChange={(e) => setVerifyInputs({ ...verifyInputs, [idx]: e.target.value })}
|
||||
placeholder={`Enter word #${idx + 1}`}
|
||||
placeholder={t('settingsUi.mnemonic.wordPlaceholder', { n: idx + 1 })}
|
||||
className="w-full px-4 py-3 rounded-lg bg-gray-900 border-2 border-[#ccff00]/20 text-white placeholder-gray-600 focus:border-[#ccff00]/50 focus:outline-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -380,14 +381,14 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
|
|||
variant="outline"
|
||||
className="flex-1 border-gray-600 text-gray-400 hover:bg-gray-800"
|
||||
>
|
||||
Go Back
|
||||
{t('settingsUi.mnemonic.goBack')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleVerify}
|
||||
disabled={!verifyIndices.every((idx) => verifyInputs[idx]?.trim())}
|
||||
className="flex-1 bg-gradient-to-r from-[#ccff00] to-[#00ff00] hover:from-[#ccff00]/90 hover:to-[#00ff00]/90 text-black font-bold uppercase"
|
||||
>
|
||||
Verify & Create Account
|
||||
{t('settingsUi.mnemonic.verifyCreate')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -400,10 +401,10 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
|
|||
<CardContent className="p-8 text-center space-y-4">
|
||||
<div className="text-6xl mb-2">✅</div>
|
||||
<h3 className="text-2xl font-black uppercase text-[#ccff00]">
|
||||
Account Created!
|
||||
{t('settingsUi.mnemonic.completeTitle')}
|
||||
</h3>
|
||||
<p className="text-gray-300">
|
||||
Your Nostr account is ready to go!
|
||||
{t('settingsUi.mnemonic.completeDesc')}
|
||||
</p>
|
||||
<CheckCircle className="h-12 w-12 mx-auto text-[#00ff00]" />
|
||||
</CardContent>
|
||||
|
|
@ -417,25 +418,24 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
|
|||
<Shield className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-blue-400">Recommended: Set Up a Signer App</h4>
|
||||
<p className="text-xs text-gray-400">Keep your keys extra secure</p>
|
||||
<h4 className="font-bold text-blue-400">{t('settingsUi.mnemonic.signerTitle')}</h4>
|
||||
<p className="text-xs text-gray-400">{t('settingsUi.mnemonic.signerSubtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300">
|
||||
For better security, use a dedicated signer app instead of pasting your recovery phrase everywhere.
|
||||
Signer apps keep your keys safe while letting you use any Nostr app.
|
||||
{t('settingsUi.mnemonic.signerDesc')}
|
||||
</p>
|
||||
<Link to="/signers" onClick={onClose}>
|
||||
<Button className="w-full bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 border border-blue-500/50">
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
Learn About Signer Apps
|
||||
{t('settingsUi.mnemonic.signerCta')}
|
||||
</Button>
|
||||
</Link>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full text-xs text-gray-500 hover:text-gray-400 py-2"
|
||||
>
|
||||
Skip for now
|
||||
{t('settingsUi.mnemonic.skip')}
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { SkateNip05Input } from '@/components/SkateNip05Input';
|
|||
import { useSkateNip05 } from '@/hooks/useSkateNip05';
|
||||
import { deriveSkateNip05Handle, validateSkateNip05Name } from '@/lib/skateNip05';
|
||||
import { encryptNsec } from '@/lib/ncryptsec';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
interface SignupDialogProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -41,12 +42,13 @@ const sanitizeFilename = (filename: string) => {
|
|||
const STEP_ORDER = ['welcome', 'profile', 'done'] as const;
|
||||
type SignupStep = typeof STEP_ORDER[number];
|
||||
const STEP_LABELS: Record<SignupStep, string> = {
|
||||
welcome: 'Choose',
|
||||
profile: 'Profile',
|
||||
done: 'Save key',
|
||||
welcome: 'settingsUi.signupDialog.stepChoose',
|
||||
profile: 'settingsUi.signupDialog.stepProfile',
|
||||
done: 'settingsUi.signupDialog.stepSaveKey',
|
||||
};
|
||||
|
||||
const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete, onUseAmber }) => {
|
||||
const { t } = useTranslation();
|
||||
const isAndroid = typeof navigator !== 'undefined' && /Android/i.test(navigator.userAgent);
|
||||
const [step, setStep] = useState<SignupStep>('welcome');
|
||||
const [nsec, setNsec] = useState('');
|
||||
|
|
@ -92,8 +94,8 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
return true;
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: "Couldn't create your account. Please try again.",
|
||||
title: t('settingsUi.signupDialog.somethingWrong'),
|
||||
description: t('settingsUi.signupDialog.createFailed'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
|
|
@ -127,13 +129,13 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
setBackupConfirmed(true);
|
||||
|
||||
toast({
|
||||
title: 'Secret Key Saved!',
|
||||
description: 'Your key has been safely stored.',
|
||||
title: t('settingsUi.signupDialog.keySavedTitle'),
|
||||
description: t('settingsUi.signupDialog.keySavedDesc'),
|
||||
});
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Download failed',
|
||||
description: 'Could not download the key file. Please copy it manually.',
|
||||
title: t('settingsUi.signupDialog.downloadFailedTitle'),
|
||||
description: t('settingsUi.signupDialog.downloadFailedDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -161,11 +163,11 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
setBackupAck(true);
|
||||
setBackupConfirmed(true);
|
||||
toast({
|
||||
title: 'Encrypted backup saved 🔐',
|
||||
description: 'Keep the file AND the password — you need both to log back in.',
|
||||
title: t('settingsUi.signupDialog.encSavedTitle'),
|
||||
description: t('settingsUi.signupDialog.encSavedDesc'),
|
||||
});
|
||||
} catch (e) {
|
||||
setEncError((e as Error)?.message ?? 'Could not encrypt that key.');
|
||||
setEncError((e as Error)?.message ?? t('settingsUi.signupDialog.encFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -181,8 +183,8 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast({
|
||||
title: 'Invalid file type',
|
||||
description: 'Please select an image file for your avatar.',
|
||||
title: t('settingsUi.signupDialog.invalidFileTitle'),
|
||||
description: t('settingsUi.signupDialog.invalidFileDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -191,8 +193,8 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
// Validate file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast({
|
||||
title: 'File too large',
|
||||
description: 'Avatar image must be smaller than 5MB.',
|
||||
title: t('settingsUi.signupDialog.fileTooLargeTitle'),
|
||||
description: t('settingsUi.signupDialog.fileTooLargeDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -205,14 +207,14 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
if (url) {
|
||||
setProfileData(prev => ({ ...prev, picture: url }));
|
||||
toast({
|
||||
title: 'Avatar uploaded!',
|
||||
description: 'Your avatar has been uploaded successfully.',
|
||||
title: t('settingsUi.signupDialog.avatarUploadedTitle'),
|
||||
description: t('settingsUi.signupDialog.avatarUploadedDesc'),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Upload failed',
|
||||
description: 'Failed to upload avatar. Please try again.',
|
||||
title: t('settingsUi.signupDialog.uploadFailedTitle'),
|
||||
description: t('settingsUi.signupDialog.uploadFailedDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -224,8 +226,8 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
const trimmedName = profileData.name.trim();
|
||||
if (!skipProfile && !trimmedName) {
|
||||
toast({
|
||||
title: 'Display name required',
|
||||
description: 'Pick a display name so other skaters know who you are. You can change it anytime.',
|
||||
title: t('settingsUi.signupDialog.nameRequiredTitle'),
|
||||
description: t('settingsUi.signupDialog.nameRequiredDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -248,8 +250,8 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
setClaimedNip05(claimResult.nip05);
|
||||
} else {
|
||||
toast({
|
||||
title: "Couldn't reserve that name",
|
||||
description: `${claimResult.error} You can claim one later in Settings.`,
|
||||
title: t('settingsUi.signupDialog.reserveFailedTitle'),
|
||||
description: t('settingsUi.signupDialog.reserveFailedDesc', { error: claimResult.error }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -269,8 +271,8 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
});
|
||||
|
||||
toast({
|
||||
title: 'Profile Created!',
|
||||
description: 'Your profile has been set up.',
|
||||
title: t('settingsUi.signupDialog.profileCreatedTitle'),
|
||||
description: t('settingsUi.signupDialog.profileCreatedDesc'),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -278,8 +280,8 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
setStep('done');
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Profile Setup Failed',
|
||||
description: 'Your account was created but profile setup failed. You can update it later.',
|
||||
title: t('settingsUi.signupDialog.profileFailedTitle'),
|
||||
description: t('settingsUi.signupDialog.profileFailedDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
|
|
@ -296,8 +298,8 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
}, 600);
|
||||
} else {
|
||||
toast({
|
||||
title: 'Welcome!',
|
||||
description: 'Your account is ready.',
|
||||
title: t('settingsUi.signupDialog.welcomeToastTitle'),
|
||||
description: t('settingsUi.signupDialog.welcomeToastDesc'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -305,17 +307,17 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
const getTitle = () => {
|
||||
if (step === 'welcome') return (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
Join SkateSpots
|
||||
{t('settingsUi.signupDialog.titleWelcome')}
|
||||
</span>
|
||||
);
|
||||
if (step === 'profile') return (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
Set Up Your Profile
|
||||
{t('settingsUi.signupDialog.titleProfile')}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
Welcome!
|
||||
{t('settingsUi.signupDialog.titleDone')}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
|
@ -392,7 +394,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
i === idx ? 'text-white' : 'text-gray-600'
|
||||
)}
|
||||
>
|
||||
{STEP_LABELS[s]}
|
||||
{t(STEP_LABELS[s])}
|
||||
</span>
|
||||
{i < STEP_ORDER.length - 1 && (
|
||||
<span className={cn('h-px w-3 sm:w-5', i < idx ? 'bg-[#00ff00]/50' : 'bg-white/10')} />
|
||||
|
|
@ -413,11 +415,10 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
🛹
|
||||
</div>
|
||||
<h3 className='text-xl font-black uppercase text-[#ccff00]'>
|
||||
Create your free account
|
||||
{t('settingsUi.signupDialog.createFreeAccount')}
|
||||
</h3>
|
||||
<p className='text-sm text-gray-300'>
|
||||
No email, no phone, no password. Your account is a Nostr key — choose
|
||||
where it lives.
|
||||
{t('settingsUi.signupDialog.createFreeAccountDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -427,17 +428,16 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
{onUseAmber && isAndroid && (
|
||||
<div className='rounded-2xl border-2 border-[#ccff00]/50 bg-black/40 p-4 text-left space-y-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='rounded-full bg-[#ccff00] px-2 py-0.5 text-[10px] font-black uppercase text-black'>Recommended</span>
|
||||
<span className='rounded-full bg-[#ccff00] px-2 py-0.5 text-[10px] font-black uppercase text-black'>{t('settingsUi.signupDialog.recommended')}</span>
|
||||
</div>
|
||||
<div className='flex items-start gap-3'>
|
||||
<div className='mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[#ccff00]/15'>
|
||||
<Smartphone className='h-5 w-5 text-[#ccff00]' />
|
||||
</div>
|
||||
<div>
|
||||
<p className='font-bold text-white'>Sign up with Amber</p>
|
||||
<p className='font-bold text-white'>{t('settingsUi.signupDialog.signUpWithAmber')}</p>
|
||||
<p className='text-xs text-gray-400'>
|
||||
Your key is created and stored inside the Amber app — SkateSpots
|
||||
never touches it. Most secure. One tap to approve.
|
||||
{t('settingsUi.signupDialog.signUpWithAmberDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -446,7 +446,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
onClick={() => { onClose(); onUseAmber(); }}
|
||||
>
|
||||
<Smartphone className='mr-2 h-4 w-4' />
|
||||
Continue with Amber
|
||||
{t('settingsUi.signupDialog.continueWithAmber')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -454,7 +454,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
{onUseAmber && isAndroid && (
|
||||
<div className='relative flex items-center'>
|
||||
<div className='flex-1 border-t border-gray-800' />
|
||||
<span className='px-3 text-xs text-gray-500'>or the quick way</span>
|
||||
<span className='px-3 text-xs text-gray-500'>{t('settingsUi.signupDialog.orQuickWay')}</span>
|
||||
<div className='flex-1 border-t border-gray-800' />
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -466,10 +466,9 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
<Key className='h-5 w-5 text-gray-300' />
|
||||
</div>
|
||||
<div>
|
||||
<p className='font-bold text-white'>Quick account on this device</p>
|
||||
<p className='font-bold text-white'>{t('settingsUi.signupDialog.quickAccount')}</p>
|
||||
<p className='text-xs text-gray-400'>
|
||||
We generate a key that lives on this device. Fastest start — we'll
|
||||
help you save a backup right after.
|
||||
{t('settingsUi.signupDialog.quickAccountDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -479,14 +478,14 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
onClick={handleCreateAccount}
|
||||
>
|
||||
<Sparkles className='mr-2 h-4 w-4' />
|
||||
Create quick account
|
||||
{t('settingsUi.signupDialog.createQuickAccount')}
|
||||
</Button>
|
||||
{/* Reassurance: effortless + safe, no jargon. Deliberately does
|
||||
NOT promise "nothing to remember" — step 3 asks them to save
|
||||
the key, and the copy must not contradict that. */}
|
||||
<p className='flex items-center justify-center gap-1.5 text-[11px] text-gray-500'>
|
||||
<CheckCircle className='h-3 w-3 text-[#00ff00]/70' />
|
||||
Takes 5 seconds · lives on this device · we'll help you back it up next
|
||||
{t('settingsUi.signupDialog.quickReassurance')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -494,11 +493,11 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
leads; elsewhere point at signer apps/extensions honestly. */}
|
||||
<p className='text-xs text-gray-500'>
|
||||
{onUseAmber && isAndroid ? (
|
||||
<>Prefer another signer?{' '}</>
|
||||
<>{t('settingsUi.signupDialog.preferAnotherSigner')}{' '}</>
|
||||
) : (
|
||||
<>On iOS or desktop? A signer app or browser extension is the most secure option —{' '}</>
|
||||
<>{t('settingsUi.signupDialog.iosDesktopNudge')}{' '}</>
|
||||
)}
|
||||
<Link to='/signers' onClick={onClose} className='text-[#ccff00] underline'>See all options</Link>
|
||||
<Link to='/signers' onClick={onClose} className='text-[#ccff00] underline'>{t('settingsUi.signupDialog.seeAllOptions')}</Link>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -528,11 +527,11 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
|
||||
<div className='relative z-10 space-y-2'>
|
||||
<p className='text-base font-semibold'>
|
||||
You're in! 🛹 Pick a display name so skaters know who you are
|
||||
{t('settingsUi.signupDialog.youreInPickName')}
|
||||
</p>
|
||||
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
You can change it anytime later. Bio and avatar are optional.
|
||||
{t('settingsUi.signupDialog.changeAnytime')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -543,7 +542,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
<div className='flex items-center justify-center gap-3'>
|
||||
<div className='w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin' />
|
||||
<span className='text-sm font-medium text-blue-700 dark:text-blue-300'>
|
||||
Publishing your profile...
|
||||
{t('settingsUi.signupDialog.publishingProfile')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -553,13 +552,13 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
<div className={`space-y-4 text-left ${isPublishing ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
<div className='space-y-2'>
|
||||
<label htmlFor='profile-name' className='text-sm font-medium'>
|
||||
Display Name <span className='text-red-500'>*</span>
|
||||
{t('settingsUi.signupDialog.displayName')} <span className='text-red-500'>*</span>
|
||||
</label>
|
||||
<Input
|
||||
id='profile-name'
|
||||
value={profileData.name}
|
||||
onChange={(e) => setProfileData(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder='e.g. Tony, gnarlyGrind, or your crew name'
|
||||
placeholder={t('settingsUi.signupDialog.displayNamePlaceholder')}
|
||||
className='rounded-lg'
|
||||
maxLength={64}
|
||||
required
|
||||
|
|
@ -567,13 +566,13 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
disabled={isPublishing}
|
||||
/>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
Required — how you'll show up across SkateSpots. Change it anytime.
|
||||
{t('settingsUi.signupDialog.displayNameHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<label htmlFor='skate-nip05' className='text-sm font-medium'>
|
||||
Your SkateSpots name
|
||||
{t('settingsUi.signupDialog.skateName')}
|
||||
</label>
|
||||
<SkateNip05Input
|
||||
id='skate-nip05'
|
||||
|
|
@ -588,19 +587,19 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
disabled={isPublishing}
|
||||
/>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
Free and optional — a verified handle that shows a ✓ on your profile.
|
||||
{t('settingsUi.signupDialog.skateNameHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<label htmlFor='profile-about' className='text-sm font-medium'>
|
||||
Bio
|
||||
{t('settingsUi.signupDialog.bio')}
|
||||
</label>
|
||||
<Textarea
|
||||
id='profile-about'
|
||||
value={profileData.about}
|
||||
onChange={(e) => setProfileData(prev => ({ ...prev, about: e.target.value }))}
|
||||
placeholder='Tell others about yourself...'
|
||||
placeholder={t('settingsUi.signupDialog.bioPlaceholder')}
|
||||
className='rounded-lg resize-none'
|
||||
rows={3}
|
||||
disabled={isPublishing}
|
||||
|
|
@ -609,7 +608,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
|
||||
<div className='space-y-2'>
|
||||
<label htmlFor='profile-picture' className='text-sm font-medium'>
|
||||
Avatar
|
||||
{t('settingsUi.signupDialog.avatar')}
|
||||
</label>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
|
|
@ -634,7 +633,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
onClick={() => avatarFileInputRef.current?.click()}
|
||||
disabled={isUploading || isPublishing}
|
||||
className='rounded-lg shrink-0'
|
||||
title='Upload avatar image'
|
||||
title={t('settingsUi.signupDialog.uploadAvatar')}
|
||||
>
|
||||
{isUploading ? (
|
||||
<div className='w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin' />
|
||||
|
|
@ -656,19 +655,19 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
{isPublishing ? (
|
||||
<>
|
||||
<div className='w-4 h-4 mr-2 border-2 border-current border-t-transparent rounded-full animate-spin' />
|
||||
Saving...
|
||||
{t('settingsUi.signupDialog.saving')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<User className='w-4 h-4 mr-2' />
|
||||
Save & Start Skating
|
||||
{t('settingsUi.signupDialog.saveAndStart')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{!profileData.name.trim() && !isPublishing && (
|
||||
<p className='text-center text-xs text-muted-foreground'>
|
||||
Add a display name to continue.
|
||||
{t('settingsUi.signupDialog.addNameToContinue')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
|
@ -677,7 +676,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
show an approval prompt. Explaining it first avoids the "why
|
||||
is it asking me to sign?" moment mid-signup. */}
|
||||
<p className='text-center text-[11px] text-muted-foreground/80'>
|
||||
Saving publishes your profile to Nostr — your signer may ask you to approve it once.
|
||||
{t('settingsUi.signupDialog.savingPublishesNote')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -692,8 +691,8 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
<div className='flex items-center gap-3 rounded-xl bg-[#00ff00]/10 border border-[#00ff00]/30 px-4 py-3'>
|
||||
<CheckCircle className='h-6 w-6 shrink-0 text-[#00ff00]' />
|
||||
<div>
|
||||
<p className='font-black uppercase text-[#ccff00] leading-tight'>You're in! 🛹</p>
|
||||
<p className='text-xs text-gray-400'>One quick thing before you drop in…</p>
|
||||
<p className='font-black uppercase text-[#ccff00] leading-tight'>{t('settingsUi.signupDialog.youreIn')}</p>
|
||||
<p className='text-xs text-gray-400'>{t('settingsUi.signupDialog.oneQuickThing')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -706,10 +705,11 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
<Key className='h-5 w-5 text-amber-400' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<h4 className='font-black uppercase text-amber-300 leading-tight'>Save your login key</h4>
|
||||
<h4 className='font-black uppercase text-amber-300 leading-tight'>{t('settingsUi.signupDialog.saveLoginKey')}</h4>
|
||||
<p className='text-xs text-amber-100/80 leading-snug mt-1'>
|
||||
This is the <span className='font-bold text-amber-200'>only</span> way back into your account —
|
||||
no email, no password reset. Save it now and you can log in on any device, anywhere you skate.
|
||||
{t('settingsUi.signupDialog.saveLoginKeyBefore')}{' '}
|
||||
<span className='font-bold text-amber-200'>{t('settingsUi.signupDialog.only')}</span>{' '}
|
||||
{t('settingsUi.signupDialog.saveLoginKeyAfter')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -720,7 +720,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
onClick={() => setShowNsec(true)}
|
||||
>
|
||||
<Shield className='h-4 w-4 mr-2' />
|
||||
Reveal & save my key
|
||||
{t('settingsUi.signupDialog.revealSaveKey')}
|
||||
</Button>
|
||||
) : (
|
||||
<div className='space-y-2'>
|
||||
|
|
@ -735,11 +735,11 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
onClick={() => {
|
||||
navigator.clipboard.writeText(nsec);
|
||||
setBackupConfirmed(true);
|
||||
toast({ title: 'Copied!', description: 'Recovery code copied. Keep it somewhere safe.' });
|
||||
toast({ title: t('common.copied'), description: t('settingsUi.signupDialog.recoveryCopied') });
|
||||
}}
|
||||
>
|
||||
<Key className='h-3.5 w-3.5 mr-1.5' />
|
||||
Copy
|
||||
{t('common.copy')}
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
|
|
@ -752,15 +752,15 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
onClick={downloadKey}
|
||||
>
|
||||
{keySecured === 'downloaded' ? (
|
||||
<><CheckCircle className='h-3.5 w-3.5 mr-1.5' /> Saved</>
|
||||
<><CheckCircle className='h-3.5 w-3.5 mr-1.5' /> {t('settingsUi.signupDialog.saved')}</>
|
||||
) : (
|
||||
<><Download className='h-3.5 w-3.5 mr-1.5' /> Download</>
|
||||
<><Download className='h-3.5 w-3.5 mr-1.5' /> {t('settingsUi.signupDialog.download')}</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className='flex items-start gap-1.5 text-[11px] text-amber-300/80 leading-snug'>
|
||||
<Shield className='h-3.5 w-3.5 shrink-0 mt-px' />
|
||||
Treat it like the keys to your board — anyone with this code controls your account. Never share it.
|
||||
{t('settingsUi.signupDialog.treatLikeBoardKeys')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -774,21 +774,22 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
className='flex w-full items-center justify-between text-left'
|
||||
>
|
||||
<span className='text-[11px] font-black uppercase tracking-wide text-amber-300'>
|
||||
{showEncBackup ? 'Hide encrypted backup' : 'Save an encrypted backup'}
|
||||
{showEncBackup ? t('settingsUi.signupDialog.hideEncBackup') : t('settingsUi.signupDialog.saveEncBackup')}
|
||||
</span>
|
||||
<ChevronDown className={cn('h-3.5 w-3.5 shrink-0 text-amber-300 transition-transform', showEncBackup && 'rotate-180')} />
|
||||
</button>
|
||||
{showEncBackup && (
|
||||
<div className='space-y-2'>
|
||||
<p className='text-[11px] leading-snug text-amber-100/70'>
|
||||
A password-protected copy — safe to keep in cloud storage or a password
|
||||
manager. You'll need the file <span className='font-bold text-amber-200'>and</span> the password.
|
||||
{t('settingsUi.signupDialog.encBackupBefore')}{' '}
|
||||
<span className='font-bold text-amber-200'>{t('settingsUi.signupDialog.and')}</span>{' '}
|
||||
{t('settingsUi.signupDialog.encBackupAfter')}
|
||||
</p>
|
||||
<Input
|
||||
type='password'
|
||||
value={encPassword}
|
||||
onChange={(e) => { setEncPassword(e.target.value); setEncError(''); }}
|
||||
placeholder='Choose a password'
|
||||
placeholder={t('settingsUi.signupDialog.choosePassword')}
|
||||
className='rounded-lg'
|
||||
autoComplete='new-password'
|
||||
/>
|
||||
|
|
@ -800,7 +801,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
onClick={downloadEncryptedKey}
|
||||
>
|
||||
<Download className='h-3.5 w-3.5 mr-1.5' />
|
||||
Save encrypted backup
|
||||
{t('settingsUi.signupDialog.saveEncBackupCta')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -824,10 +825,10 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
if (on) setBackupConfirmed(true);
|
||||
}}
|
||||
className='mt-0.5'
|
||||
aria-label="I've saved my recovery code"
|
||||
aria-label={t('settingsUi.signupDialog.savedCodeAria')}
|
||||
/>
|
||||
<span className='text-xs text-gray-300 leading-snug'>
|
||||
I've saved my recovery code somewhere safe — a password manager, or written down offline.
|
||||
{t('settingsUi.signupDialog.savedCodeLabel')}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
|
|
@ -842,7 +843,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
onClick={handleFinishComplete}
|
||||
>
|
||||
<LogIn className='h-4 w-4 mr-2' />
|
||||
Let's skate
|
||||
{t('settingsUi.signupDialog.letsSkate')}
|
||||
</Button>
|
||||
|
||||
{!backupAck && (
|
||||
|
|
@ -850,13 +851,13 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
|||
onClick={handleFinishComplete}
|
||||
className='w-full text-xs text-gray-500 hover:text-gray-400 py-1'
|
||||
>
|
||||
I'll save it later from Settings
|
||||
{t('settingsUi.signupDialog.saveLater')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Link to='/signers' onClick={handleFinishComplete} className='block'>
|
||||
<button className='w-full text-xs text-gray-500 hover:text-gray-400 py-1'>
|
||||
Advanced: use a signer app instead
|
||||
{t('settingsUi.signupDialog.advancedSigner')}
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Key, FileText, ArrowRight } from 'lucide-react';
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
interface SignupMethodDialogProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -17,16 +18,17 @@ const SignupMethodDialog: React.FC<SignupMethodDialogProps> = ({
|
|||
onSelectMnemonic,
|
||||
onSelectNsec
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-3xl bg-gradient-to-br from-gray-900 to-black border-2 border-[#ccff00]/30">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-3xl font-black uppercase text-center mb-2">
|
||||
<span className="neon-orange">Choose Your</span>{' '}
|
||||
<span className="neon-lime">Signup Method</span>
|
||||
<span className="neon-orange">{t('settingsUi.signupMethod.titlePrefix')}</span>{' '}
|
||||
<span className="neon-lime">{t('settingsUi.signupMethod.titleSuffix')}</span>
|
||||
</DialogTitle>
|
||||
<p className="text-center text-gray-400 text-sm">
|
||||
Both methods are secure. Choose what works best for you!
|
||||
{t('settingsUi.signupMethod.subtitle')}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -38,42 +40,42 @@ const SignupMethodDialog: React.FC<SignupMethodDialogProps> = ({
|
|||
<div className="flex items-center justify-between">
|
||||
<FileText className="h-12 w-12 text-[#ccff00]" />
|
||||
<span className="text-xs font-bold uppercase px-2 py-1 bg-[#ccff00]/20 text-[#ccff00] rounded-full">
|
||||
Recommended
|
||||
{t('settingsUi.signupMethod.recommended')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-2xl font-black uppercase text-[#ccff00] mb-2">
|
||||
Recovery Phrase
|
||||
{t('settingsUi.signupMethod.mnemonicTitle')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400 mb-3">
|
||||
12 easy-to-remember words. Industry standard used by Bitcoin, Ethereum, and most crypto wallets.
|
||||
{t('settingsUi.signupMethod.mnemonicDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-[#00ff00] text-lg">✓</span>
|
||||
<span className="text-xs text-gray-300">Easy to write down on paper</span>
|
||||
<span className="text-xs text-gray-300">{t('settingsUi.signupMethod.mnemonicF1')}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-[#00ff00] text-lg">✓</span>
|
||||
<span className="text-xs text-gray-300">Works with other Nostr apps (NIP-06)</span>
|
||||
<span className="text-xs text-gray-300">{t('settingsUi.signupMethod.mnemonicF2')}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-[#00ff00] text-lg">✓</span>
|
||||
<span className="text-xs text-gray-300">Can generate multiple accounts</span>
|
||||
<span className="text-xs text-gray-300">{t('settingsUi.signupMethod.mnemonicF3')}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-[#00ff00] text-lg">✓</span>
|
||||
<span className="text-xs text-gray-300">Familiar to crypto users</span>
|
||||
<span className="text-xs text-gray-300">{t('settingsUi.signupMethod.mnemonicF4')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full bg-gradient-to-r from-[#ccff00] to-[#00ff00] hover:from-[#ccff00]/90 hover:to-[#00ff00]/90 text-black font-bold uppercase group-hover:scale-105 transition-transform"
|
||||
>
|
||||
Use Recovery Phrase
|
||||
{t('settingsUi.signupMethod.useMnemonic')}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
|
@ -86,42 +88,42 @@ const SignupMethodDialog: React.FC<SignupMethodDialogProps> = ({
|
|||
<div className="flex items-center justify-between">
|
||||
<Key className="h-12 w-12 text-[#ff0040]" />
|
||||
<span className="text-xs font-bold uppercase px-2 py-1 bg-[#ff0040]/20 text-[#ff0040] rounded-full">
|
||||
Advanced
|
||||
{t('settingsUi.signupMethod.advanced')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-2xl font-black uppercase text-[#ff0040] mb-2">
|
||||
Secret Key (nsec)
|
||||
{t('settingsUi.signupMethod.nsecTitle')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400 mb-3">
|
||||
Raw cryptographic key. More technical but gives you direct access to your Nostr identity.
|
||||
{t('settingsUi.signupMethod.nsecDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-[#ff6600] text-lg">✓</span>
|
||||
<span className="text-xs text-gray-300">Direct cryptographic control</span>
|
||||
<span className="text-xs text-gray-300">{t('settingsUi.signupMethod.nsecF1')}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-[#ff6600] text-lg">✓</span>
|
||||
<span className="text-xs text-gray-300">Compatible with all Nostr apps</span>
|
||||
<span className="text-xs text-gray-300">{t('settingsUi.signupMethod.nsecF2')}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-[#ff6600] text-lg">✓</span>
|
||||
<span className="text-xs text-gray-300">Single string to backup</span>
|
||||
<span className="text-xs text-gray-300">{t('settingsUi.signupMethod.nsecF3')}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-[#ff6600] text-lg">✓</span>
|
||||
<span className="text-xs text-gray-300">No derivation layer</span>
|
||||
<span className="text-xs text-gray-300">{t('settingsUi.signupMethod.nsecF4')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full bg-gradient-to-r from-[#ff0040] to-[#ff6600] hover:from-[#ff0040]/90 hover:to-[#ff6600]/90 text-white font-bold uppercase group-hover:scale-105 transition-transform"
|
||||
>
|
||||
Use Secret Key
|
||||
{t('settingsUi.signupMethod.useNsec')}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
|
@ -130,7 +132,7 @@ const SignupMethodDialog: React.FC<SignupMethodDialogProps> = ({
|
|||
|
||||
<div className="text-center mt-4">
|
||||
<p className="text-xs text-gray-500">
|
||||
🔒 Both methods are equally secure. Choose based on your preference!
|
||||
{t('settingsUi.signupMethod.footer')}
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Lock, ShieldCheck, Server, ExternalLink, Settings } from 'lucide-react'
|
|||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl';
|
||||
import { resolveCordnEndpoint } from '@/lib/cordn/endpoint';
|
||||
|
|
@ -30,6 +31,7 @@ export function CrewCoordinatorCard({
|
|||
crew: SkateCrew;
|
||||
isFounder: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { coordinator } = crew;
|
||||
|
||||
// Which relay the messages actually travel over — a `wss://` coordinator URL
|
||||
|
|
@ -47,36 +49,35 @@ export function CrewCoordinatorCard({
|
|||
<Card className="border-2 border-teal-500/25 bg-gradient-to-br from-teal-500/[0.06] to-black/90">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
|
||||
<Lock className="h-5 w-5 text-teal-400" /> Private Crew Chat
|
||||
<Lock className="h-5 w-5 text-teal-400" /> {t('socialUi.crewCoordinator.privateChat')}
|
||||
<span className="ml-auto inline-flex items-center gap-1 rounded-full bg-teal-500/20 px-2 py-0.5 text-[10px] font-black uppercase tracking-wide text-teal-300">
|
||||
<ShieldCheck className="h-3 w-3" /> On
|
||||
<ShieldCheck className="h-3 w-3" /> {t('socialUi.crewCoordinator.on')}
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm leading-relaxed text-gray-400">
|
||||
The <span className="font-semibold text-gray-200">Chat</span> tab is{' '}
|
||||
<span className="font-bold text-teal-300">end-to-end encrypted</span> for the founder and all
|
||||
listed members — nothing to set up. Messages are sealed to each member's key (NIP-17), so only
|
||||
your crew can read them.
|
||||
{t('socialUi.crewCoordinator.defaultA')}{' '}
|
||||
<span className="font-semibold text-gray-200">{t('socialUi.crewCoordinator.chatWord')}</span> {t('socialUi.crewCoordinator.defaultB')}{' '}
|
||||
<span className="font-bold text-teal-300">{t('socialUi.crewCoordinator.e2ee')}</span> {t('socialUi.crewCoordinator.defaultC')}
|
||||
</p>
|
||||
{isFounder && (
|
||||
<p className="text-[11px] leading-relaxed text-gray-600">
|
||||
Advanced: run your own{' '}
|
||||
{t('socialUi.crewCoordinator.advancedA')}{' '}
|
||||
<a
|
||||
href="https://github.com/Cordn-msg/cordn"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-400 underline underline-offset-2 hover:text-teal-300"
|
||||
>
|
||||
Cordn coordinator
|
||||
{t('socialUi.crewCoordinator.cordnCoordinator')}
|
||||
</a>
|
||||
?{' '}
|
||||
<Link
|
||||
to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}
|
||||
className="inline-flex items-center gap-1 font-semibold text-gray-400 hover:text-teal-300"
|
||||
>
|
||||
<Settings className="h-3 w-3" /> Route chat through it
|
||||
<Settings className="h-3 w-3" /> {t('socialUi.crewCoordinator.routeThrough')}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -89,9 +90,9 @@ export function CrewCoordinatorCard({
|
|||
<Card className="border-2 border-emerald-500/30 bg-gradient-to-br from-emerald-500/[0.08] to-black/90">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
|
||||
<Lock className="h-5 w-5 text-emerald-400" /> Private Crew Chat
|
||||
<Lock className="h-5 w-5 text-emerald-400" /> {t('socialUi.crewCoordinator.privateChat')}
|
||||
<span className="ml-auto inline-flex items-center gap-1 rounded-full bg-emerald-500/20 px-2 py-0.5 text-[10px] font-black uppercase tracking-wide text-emerald-300">
|
||||
<Server className="h-3 w-3" /> Coordinator
|
||||
<Server className="h-3 w-3" /> {t('socialUi.crewCoordinator.coordinator')}
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
|
@ -102,23 +103,22 @@ export function CrewCoordinatorCard({
|
|||
|
||||
{endpoint && (
|
||||
<p className="text-[11px] leading-relaxed text-gray-500">
|
||||
Delivered over{' '}
|
||||
{t('socialUi.crewCoordinator.deliveredOver')}{' '}
|
||||
<span className="font-mono text-gray-400">{urlDomain(endpoint.relayUrl) || endpoint.relayUrl}</span>
|
||||
{overPublicRelay ? ' — the shared ContextVM relay.' : " — your crew's own relay."}
|
||||
{overPublicRelay ? ` ${t('socialUi.crewCoordinator.sharedRelay')}` : ` ${t('socialUi.crewCoordinator.ownRelay')}`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border border-emerald-500/15 bg-emerald-500/[0.04] p-3">
|
||||
<p className="flex items-center gap-1.5 text-xs font-bold text-emerald-300">
|
||||
<Lock className="h-3.5 w-3.5" /> Private chat is live
|
||||
<Lock className="h-3.5 w-3.5" /> {t('socialUi.crewCoordinator.chatLive')}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] leading-relaxed text-gray-500">
|
||||
The <span className="font-semibold text-gray-300">Chat</span> tab routes through this
|
||||
coordinator: messages are end-to-end encrypted (NIP-44, sealed to each member), so it only
|
||||
ever handles sealed traffic.{' '}
|
||||
{t('socialUi.crewCoordinator.routesA')}{' '}
|
||||
<span className="font-semibold text-gray-300">{t('socialUi.crewCoordinator.chatWord')}</span> {t('socialUi.crewCoordinator.routesB')}{' '}
|
||||
{overPublicRelay
|
||||
? 'Heads up: it is addressed over the shared ContextVM relay, so the ciphertext and its metadata (which crew, and when) are publicly visible — just not readable. Point the crew at a private wss:// relay to keep even that private.'
|
||||
: "Nothing crew-specific leaves your crew's own relay."}
|
||||
? t('socialUi.crewCoordinator.publicWarning')
|
||||
: t('socialUi.crewCoordinator.ownRelaySafe')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -127,7 +127,7 @@ export function CrewCoordinatorCard({
|
|||
to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-bold text-emerald-400 hover:text-emerald-300"
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" /> Manage coordinator
|
||||
<Settings className="h-3.5 w-3.5" /> {t('socialUi.crewCoordinator.manage')}
|
||||
</Link>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
@ -136,6 +136,7 @@ export function CrewCoordinatorCard({
|
|||
}
|
||||
|
||||
function CoordinatorEndpoint({ coordinator }: { coordinator: CrewCoordinator }) {
|
||||
const { t } = useTranslation();
|
||||
const safe = sanitizeUrl(coordinator.url);
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-emerald-500/20 bg-black/40 p-3">
|
||||
|
|
@ -144,7 +145,7 @@ function CoordinatorEndpoint({ coordinator }: { coordinator: CrewCoordinator })
|
|||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-emerald-400/80">
|
||||
Coordinator
|
||||
{t('socialUi.crewCoordinator.coordinator')}
|
||||
</p>
|
||||
<p className="truncate text-sm font-bold text-white">{urlDomain(coordinator.url)}</p>
|
||||
</div>
|
||||
|
|
@ -154,7 +155,7 @@ function CoordinatorEndpoint({ coordinator }: { coordinator: CrewCoordinator })
|
|||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-white/10 text-gray-400 transition-colors hover:border-emerald-500/40 hover:text-emerald-300"
|
||||
title={`Open ${urlDomain(coordinator.url)}`}
|
||||
title={t('socialUi.crewCoordinator.open', { domain: urlDomain(coordinator.url) })}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
|
|
@ -164,6 +165,7 @@ function CoordinatorEndpoint({ coordinator }: { coordinator: CrewCoordinator })
|
|||
}
|
||||
|
||||
function CoordinatorOperator({ pubkey }: { pubkey: string }) {
|
||||
const { t } = useTranslation();
|
||||
const author = useAuthor(pubkey);
|
||||
const name = author.data?.metadata?.name ?? genUserName(pubkey);
|
||||
const pic = author.data?.metadata?.picture;
|
||||
|
|
@ -179,7 +181,7 @@ function CoordinatorOperator({ pubkey }: { pubkey: string }) {
|
|||
<AvatarFallback className="text-[10px]">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">Coordinator run by</p>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">{t('socialUi.crewCoordinator.runBy')}</p>
|
||||
<p className="truncate text-sm font-bold text-gray-200">{name}</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|||
import { CrewActivityPulse } from '@/components/CrewBadge';
|
||||
import { CrewNip05Badge } from '@/components/CrewNip05Badge';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { crewAccent, crewBadge, type CrewActivity } from '@/lib/crewHelpers';
|
||||
import { CREW_FOCUS_LABELS, CREW_VIBE_LABELS, type CrewFocus, type SkateCrew } from '@/lib/types';
|
||||
|
|
@ -63,6 +64,7 @@ export function CrewHero({
|
|||
confirmedCount?: number;
|
||||
actions?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const accent = crewAccent(crew);
|
||||
const founder = useAuthor(crew.pubkey);
|
||||
const founderName = founder.data?.metadata?.name ?? genUserName(crew.pubkey);
|
||||
|
|
@ -188,14 +190,14 @@ export function CrewHero({
|
|||
)}
|
||||
{crew.founded && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Calendar className="h-4 w-4 shrink-0" /> Est. {crew.founded}
|
||||
<Calendar className="h-4 w-4 shrink-0" /> {t('socialUi.hero.established', { year: crew.founded })}
|
||||
</span>
|
||||
)}
|
||||
{/* Team leader inline */}
|
||||
<Link
|
||||
to={`/${npub}`}
|
||||
className="group flex items-center gap-1.5 transition-colors hover:text-white"
|
||||
title={`Team leader: ${founderName}`}
|
||||
title={t('socialUi.hero.teamLeader', { name: founderName })}
|
||||
>
|
||||
<Avatar className="h-5 w-5 border border-white/20">
|
||||
<AvatarImage src={founderPic} alt={founderName} />
|
||||
|
|
@ -203,7 +205,7 @@ export function CrewHero({
|
|||
</Avatar>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ShieldCheck className="h-3.5 w-3.5" style={{ color: accent.hex }} />
|
||||
Led by <span className="font-bold text-gray-200 group-hover:text-white">{founderName}</span>
|
||||
{t('socialUi.hero.ledBy')} <span className="font-bold text-gray-200 group-hover:text-white">{founderName}</span>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Crown, ShieldCheck, MessageSquare, Check } from 'lucide-react';
|
|||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { crewAccent } from '@/lib/crewHelpers';
|
||||
|
|
@ -27,6 +28,7 @@ function LeaderCard({
|
|||
confirmed?: boolean;
|
||||
skateRoles?: CrewRole[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const author = useAuthor(pubkey);
|
||||
const meta = author.data?.metadata;
|
||||
const name = meta?.name ?? genUserName(pubkey);
|
||||
|
|
@ -37,7 +39,7 @@ function LeaderCard({
|
|||
|
||||
const isLeader = role === 'leader';
|
||||
const RoleIcon = isLeader ? Crown : ShieldCheck;
|
||||
const roleLabel = isLeader ? 'Team Leader' : 'Captain';
|
||||
const roleLabel = isLeader ? t('socialUi.leadership.teamLeader') : t('socialUi.leadership.captain');
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -86,7 +88,7 @@ function LeaderCard({
|
|||
</Link>
|
||||
{confirmed && !isLeader && (
|
||||
<p className="flex items-center gap-1 text-[11px] font-bold text-green-500">
|
||||
<Check className="h-3 w-3" /> Verified member
|
||||
<Check className="h-3 w-3" /> {t('socialUi.leadership.verifiedMember')}
|
||||
</p>
|
||||
)}
|
||||
{skateRoles && skateRoles.length > 0 && (
|
||||
|
|
@ -108,7 +110,7 @@ function LeaderCard({
|
|||
<Link
|
||||
to={`/messages/${npub}`}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-white/10 text-gray-400 transition-colors hover:border-white/20 hover:text-white"
|
||||
title={`Message ${name}`}
|
||||
title={t('socialUi.leadership.messageName', { name })}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
</Link>
|
||||
|
|
@ -139,6 +141,7 @@ export function CrewLeadership({
|
|||
/** Skate roles per member (pubkey → roles) for role chips. */
|
||||
rolesByPubkey?: Map<string, CrewRole[]>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const accent = crewAccent(crew);
|
||||
// Admins are always a subset of members; founder is never listed as an admin card.
|
||||
const captains = crew.admins.filter((pk) => pk !== crew.pubkey);
|
||||
|
|
@ -148,7 +151,7 @@ export function CrewLeadership({
|
|||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
|
||||
<Crown className="h-5 w-5" style={{ color: accent.hex }} />
|
||||
Leadership
|
||||
{t('socialUi.leadership.title')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2.5">
|
||||
|
|
@ -162,7 +165,9 @@ export function CrewLeadership({
|
|||
{captains.length > 0 && (
|
||||
<>
|
||||
<p className="px-1 pt-1 text-[11px] font-black uppercase tracking-widest text-gray-500">
|
||||
{captains.length} Captain{captains.length === 1 ? '' : 's'}
|
||||
{captains.length === 1
|
||||
? t('socialUi.leadership.captainsOne', { count: captains.length })
|
||||
: t('socialUi.leadership.captainsMany', { count: captains.length })}
|
||||
</p>
|
||||
{captains.map((pk) => (
|
||||
<LeaderCard
|
||||
|
|
@ -178,7 +183,7 @@ export function CrewLeadership({
|
|||
)}
|
||||
|
||||
<p className="pt-1 text-[11px] leading-relaxed text-gray-500">
|
||||
The Team Leader runs the roster; Captains help invite, host jams and keep the crew tidy.
|
||||
{t('socialUi.leadership.footer')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Link } from 'react-router-dom';
|
|||
import { Store, ChevronRight, Loader2, BadgeCheck } from 'lucide-react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useSkateShop } from '@/hooks/useSkateShops';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { parseAddrCoord } from '@/lib/types';
|
||||
|
||||
/**
|
||||
|
|
@ -12,6 +13,7 @@ import { parseAddrCoord } from '@/lib/types';
|
|||
* dangling claim.
|
||||
*/
|
||||
export function CrewSponsorBanner({ sponsorRef }: { sponsorRef: string | undefined }) {
|
||||
const { t } = useTranslation();
|
||||
const coord = sponsorRef ? parseAddrCoord(sponsorRef) : null;
|
||||
const { data: shop, isLoading } = useSkateShop(coord?.pubkey, coord?.identifier);
|
||||
|
||||
|
|
@ -21,7 +23,7 @@ export function CrewSponsorBanner({ sponsorRef }: { sponsorRef: string | undefin
|
|||
return (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-amber-500/25 bg-amber-500/[0.05] px-4 py-3">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-amber-400" />
|
||||
<span className="text-sm text-gray-400">Loading sponsor…</span>
|
||||
<span className="text-sm text-gray-400">{t('socialUi.sponsorBanner.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -48,7 +50,7 @@ export function CrewSponsorBanner({ sponsorRef }: { sponsorRef: string | undefin
|
|||
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<p className="flex items-center gap-1 text-[10px] font-black uppercase tracking-widest text-amber-400/80">
|
||||
<BadgeCheck className="h-3 w-3" /> Sponsored by
|
||||
<BadgeCheck className="h-3 w-3" /> {t('socialUi.sponsorBanner.sponsoredBy')}
|
||||
</p>
|
||||
<p className="truncate text-base font-black text-white">{shop.name}</p>
|
||||
{shop.location && (
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Store, Search, X, Check, Loader2, MapPin } from 'lucide-react';
|
|||
import { Input } from '@/components/ui/input';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useSkateShops } from '@/hooks/useSkateShops';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { SKATE_SHOP_KIND, parseAddrCoord, type SkateShop } from '@/lib/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
|
|
@ -18,6 +19,7 @@ export function CrewSponsorPicker({
|
|||
value: string;
|
||||
onChange: (coord: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { data: shops, isLoading } = useSkateShops(200);
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
|
|
@ -60,7 +62,7 @@ export function CrewSponsorPicker({
|
|||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex items-center gap-1 text-[10px] font-black uppercase tracking-widest text-amber-400/80">
|
||||
<Check className="h-3 w-3" /> Sponsor selected
|
||||
<Check className="h-3 w-3" /> {t('socialUi.sponsorPicker.selected')}
|
||||
</p>
|
||||
<p className="truncate text-sm font-black text-white">{selected.name}</p>
|
||||
{selected.location && (
|
||||
|
|
@ -72,7 +74,7 @@ export function CrewSponsorPicker({
|
|||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm text-gray-400">
|
||||
{isLoading ? <Loader2 className="h-4 w-4 animate-spin text-amber-400" /> : <Store className="h-4 w-4 text-amber-400" />}
|
||||
<span className="truncate">
|
||||
{hasValueButUnresolved ? 'Sponsor set (shop loading…)' : 'Sponsor set'}
|
||||
{hasValueButUnresolved ? t('socialUi.sponsorPicker.setLoading') : t('socialUi.sponsorPicker.set')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -80,7 +82,7 @@ export function CrewSponsorPicker({
|
|||
type="button"
|
||||
onClick={() => onChange('')}
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-white/10 text-gray-400 transition-colors hover:border-red-500/40 hover:text-red-400"
|
||||
aria-label="Clear sponsor"
|
||||
aria-label={t('socialUi.sponsorPicker.clearAria')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
|
@ -95,18 +97,18 @@ export function CrewSponsorPicker({
|
|||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search skate shops by name or city…"
|
||||
placeholder={t('socialUi.sponsorPicker.searchPlaceholder')}
|
||||
className="bg-gray-800 border-gray-700 pl-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 px-1 py-3 text-sm text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading shops…
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> {t('socialUi.sponsorPicker.loading')}
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<p className="px-1 py-3 text-sm text-gray-500">
|
||||
{query ? 'No shops match — try a different search.' : 'No shops found yet.'}
|
||||
{query ? t('socialUi.sponsorPicker.noMatch') : t('socialUi.sponsorPicker.none')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-56 space-y-1.5 overflow-y-auto pr-1">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState } from 'react';
|
||||
import { Play, Film, ExternalLink, Instagram } from 'lucide-react';
|
||||
import { parseCrewVideo, youtubeThumb, type ParsedCrewVideo } from '@/lib/videoEmbed';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
|
|
@ -25,6 +26,7 @@ export function CrewVideoSection({
|
|||
crewName: string;
|
||||
accentHex?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const parsed = parseCrewVideo(videoUrl);
|
||||
if (!parsed) return null;
|
||||
|
||||
|
|
@ -44,10 +46,10 @@ export function CrewVideoSection({
|
|||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-black uppercase leading-none text-white" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
|
||||
The Part
|
||||
{t('socialUi.video.title')}
|
||||
</h2>
|
||||
<p className="text-[11px] font-bold uppercase tracking-widest" style={{ color: accentHex }}>
|
||||
{parsed.source === 'youtube' ? 'Full video · montage' : 'Latest clip'}
|
||||
{parsed.source === 'youtube' ? t('socialUi.video.fullVideo') : t('socialUi.video.latestClip')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -58,7 +60,7 @@ export function CrewVideoSection({
|
|||
className="flex items-center gap-1.5 rounded-full border border-white/10 px-3 py-1.5 text-xs font-bold text-gray-300 transition-colors hover:border-white/20 hover:text-white"
|
||||
>
|
||||
{parsed.source === 'instagram' ? <Instagram className="h-3.5 w-3.5" /> : <ExternalLink className="h-3.5 w-3.5" />}
|
||||
Watch
|
||||
{t('socialUi.video.watch')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
|
@ -83,6 +85,7 @@ function YouTubeFacade({
|
|||
crewName: string;
|
||||
accentHex: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [playing, setPlaying] = useState(false);
|
||||
|
||||
if (playing) {
|
||||
|
|
@ -90,7 +93,7 @@ function YouTubeFacade({
|
|||
<div className="relative aspect-video w-full overflow-hidden rounded-2xl bg-black">
|
||||
<iframe
|
||||
src={`${video.embedUrl}&autoplay=1`}
|
||||
title={`${crewName} — video`}
|
||||
title={t('socialUi.video.iframeTitle', { crew: crewName })}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
|
|
@ -106,12 +109,12 @@ function YouTubeFacade({
|
|||
type="button"
|
||||
onClick={() => setPlaying(true)}
|
||||
className="group relative block aspect-video w-full overflow-hidden rounded-2xl bg-black"
|
||||
aria-label={`Play ${crewName} video`}
|
||||
aria-label={t('socialUi.video.playAria', { crew: crewName })}
|
||||
>
|
||||
{video.id && (
|
||||
<img
|
||||
src={youtubeThumb(video.id)}
|
||||
alt={`${crewName} video thumbnail`}
|
||||
alt={t('socialUi.video.thumbAlt', { crew: crewName })}
|
||||
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
|
@ -133,11 +136,12 @@ function YouTubeFacade({
|
|||
|
||||
/** Instagram permalink embed. */
|
||||
function InstagramEmbed({ video }: { video: ParsedCrewVideo }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md overflow-hidden rounded-2xl border border-white/10 bg-black">
|
||||
<iframe
|
||||
src={video.embedUrl}
|
||||
title="Instagram clip"
|
||||
title={t('socialUi.video.instagramTitle')}
|
||||
className="h-[560px] w-full"
|
||||
allow="encrypted-media; picture-in-picture"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
Users,
|
||||
Shield,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
interface RelayEducationProps {
|
||||
onComplete: () => void;
|
||||
|
|
@ -23,6 +24,7 @@ interface RelayEducationProps {
|
|||
}
|
||||
|
||||
export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
|
||||
const { t } = useTranslation();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const totalSteps = 3;
|
||||
|
||||
|
|
@ -47,10 +49,10 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
|
|||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-bold text-gray-400 uppercase tracking-wide">
|
||||
Understanding Nostr
|
||||
{t('settingsUi.relayEdu.understandingNostr')}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
Step {currentStep} of {totalSteps}
|
||||
{t('settingsUi.relayEdu.stepOf', { current: currentStep, total: totalSteps })}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={(currentStep / totalSteps) * 100} className="h-1.5" />
|
||||
|
|
@ -63,9 +65,9 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
|
|||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-purple-500/20 mb-4">
|
||||
<Globe className="h-8 w-8 text-purple-500" />
|
||||
</div>
|
||||
<h2 className="text-xl font-black uppercase mb-2">Understanding Relays</h2>
|
||||
<h2 className="text-xl font-black uppercase mb-2">{t('settingsUi.relayEdu.step1Title')}</h2>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Think of relays like skate shops that share news about spots
|
||||
{t('settingsUi.relayEdu.step1Desc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -75,7 +77,7 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
|
|||
<div className="w-12 h-12 rounded-lg bg-red-500/20 flex items-center justify-center">
|
||||
<span className="text-2xl">🛹</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">You</span>
|
||||
<span className="text-xs text-gray-500">{t('settingsUi.relayEdu.you')}</span>
|
||||
</div>
|
||||
|
||||
<ChevronRight className="h-5 w-5 text-gray-600" />
|
||||
|
|
@ -89,7 +91,7 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
|
|||
<Store className="h-5 w-5 text-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">Relays</span>
|
||||
<span className="text-xs text-gray-500">{t('settingsUi.relayEdu.relays')}</span>
|
||||
</div>
|
||||
|
||||
<ChevronRight className="h-5 w-5 text-gray-600" />
|
||||
|
|
@ -98,7 +100,7 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
|
|||
<div className="w-12 h-12 rounded-lg bg-green-500/20 flex items-center justify-center">
|
||||
<Users className="h-6 w-6 text-green-500" />
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">Skaters</span>
|
||||
<span className="text-xs text-gray-500">{t('settingsUi.relayEdu.skaters')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -106,22 +108,22 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
|
|||
<div className="flex items-start gap-3 p-3 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="font-bold text-green-400">No company owns your account</span>
|
||||
<p className="text-gray-400 text-xs mt-0.5">Your identity lives on multiple relay servers</p>
|
||||
<span className="font-bold text-green-400">{t('settingsUi.relayEdu.s1Good1')}</span>
|
||||
<p className="text-gray-400 text-xs mt-0.5">{t('settingsUi.relayEdu.s1Good1Desc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="font-bold text-green-400">You control where your data lives</span>
|
||||
<p className="text-gray-400 text-xs mt-0.5">Your posts sync across relays you choose — pick your own, or run one yourself</p>
|
||||
<span className="font-bold text-green-400">{t('settingsUi.relayEdu.s1Good2')}</span>
|
||||
<p className="text-gray-400 text-xs mt-0.5">{t('settingsUi.relayEdu.s1Good2Desc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="font-bold text-amber-400">Relay operators can see public posts</span>
|
||||
<p className="text-gray-400 text-xs mt-0.5">Think of it like posting on a bulletin board</p>
|
||||
<span className="font-bold text-amber-400">{t('settingsUi.relayEdu.s1Warn')}</span>
|
||||
<p className="text-gray-400 text-xs mt-0.5">{t('settingsUi.relayEdu.s1WarnDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -135,9 +137,9 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
|
|||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-500/20 mb-4">
|
||||
<Shield className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
<h2 className="text-xl font-black uppercase mb-2">Public vs Private</h2>
|
||||
<h2 className="text-xl font-black uppercase mb-2">{t('settingsUi.relayEdu.step2Title')}</h2>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Know what's visible and what's encrypted
|
||||
{t('settingsUi.relayEdu.step2Desc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -146,24 +148,24 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
|
|||
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Unlock className="h-5 w-5 text-red-400" />
|
||||
<span className="font-bold text-red-400 uppercase text-sm">Public (Anyone Can See)</span>
|
||||
<span className="font-bold text-red-400 uppercase text-sm">{t('settingsUi.relayEdu.publicTitle')}</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-gray-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-red-400">•</span>
|
||||
<span>Spots you add to the map</span>
|
||||
<span>{t('settingsUi.relayEdu.pub1')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-red-400">•</span>
|
||||
<span>Reviews and comments</span>
|
||||
<span>{t('settingsUi.relayEdu.pub2')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-red-400">•</span>
|
||||
<span>Your profile info (name, picture)</span>
|
||||
<span>{t('settingsUi.relayEdu.pub3')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-red-400">•</span>
|
||||
<span>Who you follow</span>
|
||||
<span>{t('settingsUi.relayEdu.pub4')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -172,16 +174,16 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
|
|||
<div className="p-4 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Lock className="h-5 w-5 text-green-400" />
|
||||
<span className="font-bold text-green-400 uppercase text-sm">Private (Encrypted)</span>
|
||||
<span className="font-bold text-green-400 uppercase text-sm">{t('settingsUi.relayEdu.privateTitle')}</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-gray-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-400">•</span>
|
||||
<span>Direct messages</span>
|
||||
<span>{t('settingsUi.relayEdu.priv1')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-400">•</span>
|
||||
<span>Wallet communications</span>
|
||||
<span>{t('settingsUi.relayEdu.priv2')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -189,8 +191,7 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
|
|||
|
||||
<div className="p-3 rounded-lg bg-gray-800/50 border border-gray-700">
|
||||
<p className="text-xs text-gray-400 text-center">
|
||||
<span className="text-amber-400 font-bold">Note:</span> Posts may be stored permanently on relays.
|
||||
Think before you post - just like real life!
|
||||
<span className="text-amber-400 font-bold">{t('settingsUi.relayEdu.noteStrong')}</span> {t('settingsUi.relayEdu.note')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -203,9 +204,9 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
|
|||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-amber-500/20 mb-4">
|
||||
<Key className="h-8 w-8 text-amber-500" />
|
||||
</div>
|
||||
<h2 className="text-xl font-black uppercase mb-2">Your Keys = Your Account</h2>
|
||||
<h2 className="text-xl font-black uppercase mb-2">{t('settingsUi.relayEdu.step3Title')}</h2>
|
||||
<p className="text-gray-400 text-sm">
|
||||
This is the most important thing to understand
|
||||
{t('settingsUi.relayEdu.step3Desc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -213,24 +214,23 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
|
|||
<div className="p-4 rounded-lg bg-amber-500/10 border border-amber-500/30">
|
||||
<h3 className="font-bold text-amber-400 mb-2 flex items-center gap-2">
|
||||
<Key className="h-4 w-4" />
|
||||
Your Secret Key (nsec)
|
||||
{t('settingsUi.relayEdu.secretKeyTitle')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-300 mb-3">
|
||||
Think of it like your skateboard - never lend it to anyone.
|
||||
It proves you own your account.
|
||||
{t('settingsUi.relayEdu.secretKeyDesc')}
|
||||
</p>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-start gap-2 text-red-400">
|
||||
<span className="font-bold">⚠️</span>
|
||||
<span>If lost, your account can't be recovered</span>
|
||||
<span>{t('settingsUi.relayEdu.s3Bad1')}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 text-red-400">
|
||||
<span className="font-bold">⚠️</span>
|
||||
<span>No "forgot password" - nobody can reset it</span>
|
||||
<span>{t('settingsUi.relayEdu.s3Bad2')}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 text-green-400">
|
||||
<span className="font-bold">✓</span>
|
||||
<span>Back it up and store it safely</span>
|
||||
<span>{t('settingsUi.relayEdu.s3Good')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -238,17 +238,17 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
|
|||
<div className="p-4 rounded-lg bg-purple-500/10 border border-purple-500/30">
|
||||
<h3 className="font-bold text-purple-400 mb-2 flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
Your Public Key (npub)
|
||||
{t('settingsUi.relayEdu.publicKeyTitle')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-300">
|
||||
This is like your username - share it freely so others can find you!
|
||||
{t('settingsUi.relayEdu.publicKeyDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-lg bg-green-500/10 border border-green-500/20 text-center">
|
||||
<p className="text-sm text-green-400 font-bold">
|
||||
🔐 You're in control - no company can lock you out!
|
||||
{t('settingsUi.relayEdu.inControl')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -264,7 +264,7 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
|
|||
className="gap-1"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Back
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
) : onSkip ? (
|
||||
<Button
|
||||
|
|
@ -272,7 +272,7 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
|
|||
onClick={onSkip}
|
||||
className="text-gray-500"
|
||||
>
|
||||
Skip
|
||||
{t('settingsUi.relayEdu.skip')}
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
|
|
@ -290,12 +290,12 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
|
|||
>
|
||||
{currentStep === totalSteps ? (
|
||||
<>
|
||||
Got It!
|
||||
{t('settingsUi.relayEdu.gotIt')}
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Next
|
||||
{t('common.next')}
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import { QRScannerModal } from '@/components/wallet/QRScannerModal';
|
|||
import { MintPrivacyManager } from '@/components/wallet/MintPrivacyManager';
|
||||
import { RecoveryPhraseDialog } from '@/components/wallet/RecoveryPhraseDialog';
|
||||
import { isNativePlatform } from '@/lib/platform';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { Smartphone } from 'lucide-react';
|
||||
|
||||
/**
|
||||
|
|
@ -62,6 +63,7 @@ export function CashuWalletCard() {
|
|||
}
|
||||
|
||||
function CashuWalletBrowserNotice() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card className="relative overflow-hidden border-orange-500/30 bg-gradient-to-br from-orange-500/[0.07] via-background to-yellow-500/[0.05]">
|
||||
{/* Ambient glow */}
|
||||
|
|
@ -75,13 +77,13 @@ function CashuWalletBrowserNotice() {
|
|||
</div>
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-base font-black tracking-tight flex items-center gap-2">
|
||||
Built-in Ecash Wallet
|
||||
{t('walletUi.cashu.browser.title')}
|
||||
<Badge className="border-0 bg-orange-500/15 text-[10px] font-bold uppercase tracking-wider text-orange-400">
|
||||
App only
|
||||
{t('walletUi.cashu.browser.appOnly')}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs mt-0.5">
|
||||
Self-custodial Cashu ecash, powered by Bitcoin Lightning
|
||||
{t('walletUi.cashu.browser.desc')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -95,11 +97,9 @@ function CashuWalletBrowserNotice() {
|
|||
<Smartphone className="h-4 w-4 text-orange-400" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-sm font-bold leading-tight">Unlock the full wallet in the SkateSpots app</p>
|
||||
<p className="text-sm font-bold leading-tight">{t('walletUi.cashu.browser.unlockTitle')}</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
The built-in ecash wallet lets you mint, send and redeem Cashu tokens right at the
|
||||
spot. It lives in the native app, where it can talk to mints directly — browsers
|
||||
sandbox those connections for your security.
|
||||
{t('walletUi.cashu.browser.unlockDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -111,12 +111,10 @@ function CashuWalletBrowserNotice() {
|
|||
<span className="flex h-6 w-6 items-center justify-center rounded-lg bg-amber-500/15">
|
||||
<Zap className="h-3.5 w-3.5 text-amber-500" />
|
||||
</span>
|
||||
Zap & tip — works in your browser now
|
||||
{t('walletUi.cashu.browser.zapTipTitle')}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground leading-relaxed">
|
||||
Sending sats to spots, events, coaches and crews works right here. Just connect a
|
||||
Lightning wallet below — Nostr Wallet Connect or a browser extension like Alby. No
|
||||
Cashu mint needed.
|
||||
{t('walletUi.cashu.browser.zapTipDesc')}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{['Nostr Wallet Connect', 'Alby / WebLN', 'Lightning address'].map((label) => (
|
||||
|
|
@ -136,6 +134,7 @@ function CashuWalletBrowserNotice() {
|
|||
}
|
||||
|
||||
function CashuWalletCardNative() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
coco,
|
||||
repositories,
|
||||
|
|
@ -227,12 +226,12 @@ function CashuWalletCardNative() {
|
|||
if (coco) {
|
||||
try {
|
||||
await coco.wallet.receive(result.value);
|
||||
toast({ title: 'Token received', description: 'Cashu token redeemed successfully!' });
|
||||
toast({ title: t('walletUi.cashu.tokenReceived'), description: t('walletUi.cashu.tokenReceivedDesc') });
|
||||
} catch (err) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Receive failed',
|
||||
description: err instanceof Error ? err.message : 'Unknown error'
|
||||
title: t('walletUi.cashu.receiveFailed'),
|
||||
description: err instanceof Error ? err.message : t('walletUi.cashu.unknownError')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -248,41 +247,41 @@ function CashuWalletCardNative() {
|
|||
// Recognized so the user gets an honest answer instead of "unknown".
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Cashu request not supported yet',
|
||||
description: 'This wallet can’t pay a Cashu (creq) request yet — ask for a Lightning invoice or address instead.',
|
||||
title: t('walletUi.cashu.requestUnsupported'),
|
||||
description: t('walletUi.cashu.requestUnsupportedDesc'),
|
||||
});
|
||||
break;
|
||||
case 'bitcoin_address':
|
||||
// Recognized, but this is an ecash/Lightning wallet — no on-chain send.
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'On-chain not supported here',
|
||||
description: 'That’s a Bitcoin address. This wallet only sends Cashu tokens and Lightning payments.',
|
||||
title: t('walletUi.cashu.onchainUnsupported'),
|
||||
description: t('walletUi.cashu.onchainUnsupportedDesc'),
|
||||
});
|
||||
break;
|
||||
case 'npub':
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'That’s a Nostr profile',
|
||||
description: 'Scan a Cashu token, Lightning invoice, or Lightning address to pay.',
|
||||
title: t('walletUi.cashu.nostrProfile'),
|
||||
description: t('walletUi.cashu.nostrProfileDesc'),
|
||||
});
|
||||
break;
|
||||
case 'clink_offer':
|
||||
case 'clink_debit':
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Payment code not supported yet',
|
||||
description: 'This wallet can’t pay CLINK codes yet. Use a Lightning invoice or address instead.',
|
||||
title: t('walletUi.cashu.clinkUnsupported'),
|
||||
description: t('walletUi.cashu.clinkUnsupportedDesc'),
|
||||
});
|
||||
break;
|
||||
default:
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Unrecognized QR code',
|
||||
description: 'Scan a Cashu token, Lightning invoice, or Lightning address.',
|
||||
title: t('walletUi.cashu.unrecognizedQr'),
|
||||
description: t('walletUi.cashu.unrecognizedQrDesc'),
|
||||
});
|
||||
}
|
||||
}, [coco, setTokenInput, setLightningInvoice, toast]);
|
||||
}, [coco, setTokenInput, setLightningInvoice, toast, t]);
|
||||
|
||||
// Handle paying a Lightning address
|
||||
const [isPayingAddress, setIsPayingAddress] = useState(false);
|
||||
|
|
@ -292,7 +291,7 @@ function CashuWalletCardNative() {
|
|||
try {
|
||||
const amount = parseInt(lightningAddressAmount);
|
||||
if (isNaN(amount) || amount <= 0) {
|
||||
toast({ variant: 'destructive', title: 'Invalid amount', description: 'Please enter a valid amount' });
|
||||
toast({ variant: 'destructive', title: t('walletUi.cashu.invalidAmount'), description: t('walletUi.cashu.invalidAmountDesc') });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -305,25 +304,25 @@ function CashuWalletCardNative() {
|
|||
const meltQuote = await coco.quotes.createMeltQuote(activeMintUrl, invoice);
|
||||
await coco.quotes.payMeltQuote(activeMintUrl, meltQuote.quote);
|
||||
|
||||
toast({ title: 'Payment sent', description: `Sent ${amount} sats to ${lightningAddress}` });
|
||||
toast({ title: t('walletUi.cashu.paymentSent'), description: t('walletUi.cashu.paymentSentDesc', { amount, address: lightningAddress }) });
|
||||
setLightningAddress('');
|
||||
setLightningAddressAmount('');
|
||||
} catch (err) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Payment failed',
|
||||
description: err instanceof Error ? err.message : 'Unknown error'
|
||||
title: t('walletUi.cashu.paymentFailed'),
|
||||
description: err instanceof Error ? err.message : t('walletUi.cashu.unknownError')
|
||||
});
|
||||
} finally {
|
||||
setIsPayingAddress(false);
|
||||
}
|
||||
}, [lightningAddress, lightningAddressAmount, coco, activeMintUrl, getLightningInvoice, toast]);
|
||||
}, [lightningAddress, lightningAddressAmount, coco, activeMintUrl, getLightningInvoice, toast, t]);
|
||||
|
||||
// Copy to clipboard helper
|
||||
const copyToClipboard = async (text: string, label: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast({ title: 'Copied', description: `${label} copied to clipboard` });
|
||||
toast({ title: t('walletUi.cashu.copied'), description: t('walletUi.cashu.copiedDesc', { label }) });
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
|
|
@ -336,9 +335,9 @@ function CashuWalletCardNative() {
|
|||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Wallet className="h-5 w-5 text-orange-500" />
|
||||
Cashu Wallet
|
||||
{t('walletUi.cashu.title')}
|
||||
</CardTitle>
|
||||
<CardDescription>Initializing wallet...</CardDescription>
|
||||
<CardDescription>{t('walletUi.cashu.initializing')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
|
|
@ -356,7 +355,7 @@ function CashuWalletCardNative() {
|
|||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-destructive">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
Wallet Error
|
||||
{t('walletUi.cashu.walletError')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -377,21 +376,21 @@ function CashuWalletCardNative() {
|
|||
<CardHeader className="p-4">
|
||||
<CardTitle className="text-base font-bold flex items-center gap-2">
|
||||
<Wallet className="h-5 w-5 text-orange-500" />
|
||||
Cashu Wallet
|
||||
{t('walletUi.cashu.title')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Built-in ecash wallet — no external setup required
|
||||
{t('walletUi.cashu.desc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 p-4 pt-0">
|
||||
{/* Balance Display — compact on mobile since BalanceHero already shows it */}
|
||||
<div className="flex items-center justify-between py-3 px-4 rounded-xl bg-orange-500/10 border border-orange-500/20">
|
||||
<div>
|
||||
<div className="text-[10px] text-orange-400/80 uppercase tracking-widest font-bold mb-0.5">Balance</div>
|
||||
<div className="text-[10px] text-orange-400/80 uppercase tracking-widest font-bold mb-0.5">{t('walletUi.cashu.balance')}</div>
|
||||
<div
|
||||
className="text-2xl font-black tabular-nums leading-none"
|
||||
aria-live="polite"
|
||||
aria-label={`Wallet balance ${totalBalance.toLocaleString()} sats`}
|
||||
aria-label={t('walletUi.cashu.balanceAria', { amount: totalBalance.toLocaleString() })}
|
||||
>
|
||||
{totalBalance.toLocaleString()}
|
||||
<span className="text-sm font-semibold text-muted-foreground ml-1.5">sats</span>
|
||||
|
|
@ -408,10 +407,10 @@ function CashuWalletCardNative() {
|
|||
{/* Active Mint Selector */}
|
||||
{mints.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Active Mint</Label>
|
||||
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">{t('walletUi.cashu.activeMint')}</Label>
|
||||
<Select value={activeMintUrl || undefined} onValueChange={setActiveMintUrl}>
|
||||
<SelectTrigger className="h-11">
|
||||
<SelectValue placeholder="Select a mint" />
|
||||
<SelectValue placeholder={t('walletUi.cashu.selectMint')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{mints.map((mint) => (
|
||||
|
|
@ -434,11 +433,11 @@ function CashuWalletCardNative() {
|
|||
<TabsList className="grid w-full grid-cols-2 h-11 rounded-xl">
|
||||
<TabsTrigger value="receive" className="gap-2 rounded-lg h-full font-bold text-sm data-[state=active]:bg-green-600 data-[state=active]:text-white">
|
||||
<ArrowDownCircle className="h-4 w-4" />
|
||||
Receive
|
||||
{t('walletUi.cashu.receive')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="send" className="gap-2 rounded-lg h-full font-bold text-sm data-[state=active]:bg-red-600 data-[state=active]:text-white">
|
||||
<ArrowUpCircle className="h-4 w-4" />
|
||||
Send
|
||||
{t('walletUi.cashu.send')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
|
@ -446,12 +445,12 @@ function CashuWalletCardNative() {
|
|||
<TabsContent value="receive" className="space-y-4 mt-4">
|
||||
{/* Lightning Invoice */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">⚡ Receive via Lightning</Label>
|
||||
<Label className="text-sm font-semibold">{t('walletUi.cashu.receiveViaLightning')}</Label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder="Amount in sats"
|
||||
placeholder={t('walletUi.cashu.amountSats')}
|
||||
value={quoteAmount}
|
||||
onChange={(e) => setQuoteAmount(e.target.value)}
|
||||
className="h-11 min-w-0"
|
||||
|
|
@ -461,7 +460,7 @@ function CashuWalletCardNative() {
|
|||
disabled={!activeMintUrl || !quoteAmount || isGeneratingInvoice}
|
||||
className="h-11 bg-orange-500 hover:bg-orange-600 font-bold"
|
||||
>
|
||||
{isGeneratingInvoice ? 'Generating…' : 'Generate Invoice'}
|
||||
{isGeneratingInvoice ? t('walletUi.cashu.generating') : t('walletUi.cashu.generateInvoice')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -471,14 +470,14 @@ function CashuWalletCardNative() {
|
|||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">Or</span>
|
||||
<span className="bg-background px-2 text-muted-foreground">{t('walletUi.cashu.or')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cashu Token */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-semibold">🥜 Receive Cashu Token</Label>
|
||||
<Label className="text-sm font-semibold">{t('walletUi.cashu.receiveCashu')}</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
@ -486,12 +485,12 @@ function CashuWalletCardNative() {
|
|||
onClick={() => setShowQRScanner(true)}
|
||||
>
|
||||
<ScanLine className="h-4 w-4" />
|
||||
Scan QR
|
||||
{t('walletUi.cashu.scanQr')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="Paste Cashu token (cashu...)"
|
||||
placeholder={t('walletUi.cashu.pasteCashu')}
|
||||
value={tokenInput}
|
||||
onChange={(e) => setTokenInput(e.target.value)}
|
||||
className="font-mono text-xs h-11"
|
||||
|
|
@ -501,7 +500,7 @@ function CashuWalletCardNative() {
|
|||
disabled={!tokenInput || isReceivingToken}
|
||||
className="w-full h-11 bg-green-600 hover:bg-green-700 font-bold"
|
||||
>
|
||||
{isReceivingToken ? 'Receiving…' : 'Receive Token'}
|
||||
{isReceivingToken ? t('walletUi.cashu.receiving') : t('walletUi.cashu.receiveToken')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -511,12 +510,12 @@ function CashuWalletCardNative() {
|
|||
<TabsContent value="send" className="space-y-4 mt-4">
|
||||
{/* Send Token */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">🥜 Create Cashu Token</Label>
|
||||
<Label className="text-sm font-semibold">{t('walletUi.cashu.createCashu')}</Label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder="Amount in sats"
|
||||
placeholder={t('walletUi.cashu.amountSats')}
|
||||
value={sendAmount}
|
||||
onChange={(e) => setSendAmount(e.target.value)}
|
||||
className="min-w-0 h-11"
|
||||
|
|
@ -526,7 +525,7 @@ function CashuWalletCardNative() {
|
|||
disabled={!activeMintUrl || !sendAmount || isCreatingToken}
|
||||
className="h-11 bg-orange-500 hover:bg-orange-600 font-bold"
|
||||
>
|
||||
{isCreatingToken ? 'Creating…' : 'Create Token'}
|
||||
{isCreatingToken ? t('walletUi.cashu.creating') : t('walletUi.cashu.createToken')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -536,14 +535,14 @@ function CashuWalletCardNative() {
|
|||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">Or</span>
|
||||
<span className="bg-background px-2 text-muted-foreground">{t('walletUi.cashu.or')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pay Lightning Invoice */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-semibold">⚡ Pay Lightning Invoice</Label>
|
||||
<Label className="text-sm font-semibold">{t('walletUi.cashu.payInvoice')}</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
@ -551,12 +550,12 @@ function CashuWalletCardNative() {
|
|||
onClick={() => setShowQRScanner(true)}
|
||||
>
|
||||
<ScanLine className="h-4 w-4" />
|
||||
Scan QR
|
||||
{t('walletUi.cashu.scanQr')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="Paste Lightning invoice (lnbc...)"
|
||||
placeholder={t('walletUi.cashu.pasteInvoice')}
|
||||
value={lightningInvoice}
|
||||
onChange={(e) => setLightningInvoice(e.target.value)}
|
||||
className="font-mono text-xs h-11"
|
||||
|
|
@ -567,7 +566,7 @@ function CashuWalletCardNative() {
|
|||
className="w-full h-11 bg-blue-600 hover:bg-blue-700 font-bold"
|
||||
>
|
||||
<Zap className="h-4 w-4 mr-2" />
|
||||
{isProcessingPayment ? 'Processing...' : 'Pay Invoice'}
|
||||
{isProcessingPayment ? t('walletUi.cashu.processing') : t('walletUi.cashu.payInvoiceBtn')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -577,13 +576,13 @@ function CashuWalletCardNative() {
|
|||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">Or</span>
|
||||
<span className="bg-background px-2 text-muted-foreground">{t('walletUi.cashu.or')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pay Lightning Address */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">📧 Pay Lightning Address</Label>
|
||||
<Label className="text-sm font-semibold">{t('walletUi.cashu.payAddress')}</Label>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="user@domain.com"
|
||||
|
|
@ -594,7 +593,7 @@ function CashuWalletCardNative() {
|
|||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder="Amount in sats"
|
||||
placeholder={t('walletUi.cashu.amountSats')}
|
||||
value={lightningAddressAmount}
|
||||
onChange={(e) => setLightningAddressAmount(e.target.value)}
|
||||
className="h-11"
|
||||
|
|
@ -604,7 +603,7 @@ function CashuWalletCardNative() {
|
|||
disabled={!lightningAddress || !lightningAddressAmount || isResolvingAddress || isPayingAddress}
|
||||
className="w-full h-11 bg-purple-600 hover:bg-purple-700 font-bold"
|
||||
>
|
||||
{isResolvingAddress || isPayingAddress ? 'Sending…' : 'Send to Address'}
|
||||
{isResolvingAddress || isPayingAddress ? t('walletUi.cashu.sending') : t('walletUi.cashu.sendToAddress')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -616,7 +615,7 @@ function CashuWalletCardNative() {
|
|||
<div className="space-y-3">
|
||||
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||
<History className="h-3.5 w-3.5" />
|
||||
Recent Activity
|
||||
{t('walletUi.cashu.recentActivity')}
|
||||
</Label>
|
||||
<div className="space-y-2 max-h-52 overflow-y-auto scroll-container">
|
||||
{history.slice(0, 5).map((entry) => (
|
||||
|
|
@ -632,10 +631,10 @@ function CashuWalletCardNative() {
|
|||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold leading-tight">
|
||||
{entry.type === 'mint' && 'Received ⚡ Lightning'}
|
||||
{entry.type === 'melt' && 'Sent ⚡ Lightning'}
|
||||
{entry.type === 'send' && 'Sent 🥜 Token'}
|
||||
{entry.type === 'receive' && 'Received 🥜 Token'}
|
||||
{entry.type === 'mint' && t('walletUi.cashu.receivedLightning')}
|
||||
{entry.type === 'melt' && t('walletUi.cashu.sentLightning')}
|
||||
{entry.type === 'send' && t('walletUi.cashu.sentToken')}
|
||||
{entry.type === 'receive' && t('walletUi.cashu.receivedToken')}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{new Date(entry.timestamp).toLocaleDateString()}
|
||||
|
|
@ -662,19 +661,19 @@ function CashuWalletCardNative() {
|
|||
<CardHeader className="px-4 pt-4 pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Zap className="h-5 w-5 text-amber-500" />
|
||||
What is Cashu?
|
||||
{t('walletUi.cashu.whatIsCashu')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4 space-y-3">
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
Cashu is digital cash backed by Bitcoin Lightning. Think of it as Bitcoin tokens you hold in your browser.
|
||||
{t('walletUi.cashu.whatIsCashuDesc')}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
|
||||
{[
|
||||
'Works offline at skate spots',
|
||||
'Instant peer-to-peer payments',
|
||||
"Privacy-focused (mints can't track you)",
|
||||
'Always redeemable for Lightning sats',
|
||||
t('walletUi.cashu.benefitOffline'),
|
||||
t('walletUi.cashu.benefitInstant'),
|
||||
t('walletUi.cashu.benefitPrivacy'),
|
||||
t('walletUi.cashu.benefitRedeemable'),
|
||||
].map((item) => (
|
||||
<div key={item} className="flex items-start gap-2 p-2.5 bg-muted/40 rounded-lg">
|
||||
<span className="text-green-500 flex-shrink-0 mt-0.5">✅</span>
|
||||
|
|
@ -689,7 +688,7 @@ function CashuWalletCardNative() {
|
|||
className="inline-flex items-center gap-2 text-sm text-primary hover:underline font-medium"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Find mints at mintradar.pedani.eu
|
||||
{t('walletUi.cashu.findMints')}
|
||||
</a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -712,22 +711,22 @@ function CashuWalletCardNative() {
|
|||
<CardHeader className="px-4 pt-4 pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-amber-600 text-base">
|
||||
<Shield className="h-5 w-5" />
|
||||
Backup Your Wallet
|
||||
{t('walletUi.cashu.backupTitle')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4 space-y-3">
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
Your wallet is secured with a 12-word recovery phrase. Save it safely — you'll need it to restore your balance if you clear browser data.
|
||||
{t('walletUi.cashu.backupDesc')}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setShowRecoveryPhrase(true)}
|
||||
variant="outline"
|
||||
className="w-full h-11 border-amber-500/50 text-amber-600 hover:bg-amber-500/10 font-bold"
|
||||
>
|
||||
View Recovery Phrase
|
||||
{t('walletUi.cashu.viewRecovery')}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
This is like your Nostr nsec — keep it secret and backed up!
|
||||
{t('walletUi.cashu.backupNote')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -737,18 +736,18 @@ function CashuWalletCardNative() {
|
|||
<Dialog open={showInvoiceModal} onOpenChange={setShowInvoiceModal}>
|
||||
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-md p-4 sm:p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">Receive {currentInvoice?.amount} sats</DialogTitle>
|
||||
<DialogTitle className="text-base sm:text-lg">{t('walletUi.cashu.receiveSats', { amount: currentInvoice?.amount ?? 0 })}</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
Pay this invoice from any Lightning wallet
|
||||
{t('walletUi.cashu.payInvoiceHint')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{currentInvoice && (
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<div className="flex justify-center p-2 sm:p-4 bg-white rounded-lg">
|
||||
<img src={currentInvoice.qrCodeUrl} alt="Invoice QR Code" className="max-w-full max-h-[40vh] h-auto object-contain" />
|
||||
<img src={currentInvoice.qrCodeUrl} alt={t('walletUi.cashu.invoiceQrAlt')} className="max-w-full max-h-[40vh] h-auto object-contain" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Lightning Invoice</Label>
|
||||
<Label className="text-xs text-muted-foreground">{t('walletUi.cashu.lightningInvoice')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={currentInvoice.invoice}
|
||||
|
|
@ -758,14 +757,14 @@ function CashuWalletCardNative() {
|
|||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => copyToClipboard(currentInvoice.invoice, 'Invoice')}
|
||||
onClick={() => copyToClipboard(currentInvoice.invoice, t('walletUi.cashu.invoiceLabel'))}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-amber-600">
|
||||
⏳ Waiting for payment...
|
||||
{t('walletUi.cashu.waiting')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -776,20 +775,20 @@ function CashuWalletCardNative() {
|
|||
<Dialog open={!!sentToken} onOpenChange={(open) => !open && setSentToken(null)}>
|
||||
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-md p-4 sm:p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">Token Ready to Send</DialogTitle>
|
||||
<DialogTitle className="text-base sm:text-lg">{t('walletUi.cashu.tokenReady')}</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
Share this token with the recipient
|
||||
{t('walletUi.cashu.shareToken')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{sentToken && (
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{sentTokenQRCode && (
|
||||
<div className="flex justify-center p-2 sm:p-4 bg-white rounded-lg">
|
||||
<img src={sentTokenQRCode} alt="Token QR Code" className="max-w-full max-h-[40vh] h-auto object-contain" />
|
||||
<img src={sentTokenQRCode} alt={t('walletUi.cashu.tokenQrAlt')} className="max-w-full max-h-[40vh] h-auto object-contain" />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Cashu Token</Label>
|
||||
<Label className="text-xs text-muted-foreground">{t('walletUi.cashu.cashuToken')}</Label>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={sentToken}
|
||||
|
|
@ -800,11 +799,11 @@ function CashuWalletCardNative() {
|
|||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => copyToClipboard(sentToken, 'Token')}
|
||||
onClick={() => copyToClipboard(sentToken, t('walletUi.cashu.tokenLabel'))}
|
||||
className="flex-1"
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy Token
|
||||
{t('walletUi.cashu.copyToken')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -812,7 +811,7 @@ function CashuWalletCardNative() {
|
|||
<Alert className="border-amber-500/50 bg-amber-500/5">
|
||||
<AlertCircle className="h-4 w-4 text-amber-600" />
|
||||
<AlertDescription className="text-xs sm:text-sm">
|
||||
Anyone with this token can claim it - treat it like cash!
|
||||
{t('walletUi.cashu.tokenWarning')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useCashuEscrow } from '@/hooks/wallet/useCashuEscrow';
|
|||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { Handshake, Loader2, Check } from 'lucide-react';
|
||||
|
||||
/**
|
||||
|
|
@ -13,6 +14,7 @@ import { Handshake, Loader2, Check } from 'lucide-react';
|
|||
* buyers can lock escrow funds to them. Native-only; renders nothing otherwise.
|
||||
*/
|
||||
export function EscrowKeyCard() {
|
||||
const { t } = useTranslation();
|
||||
const escrow = useCashuEscrow();
|
||||
const { user } = useCurrentUser();
|
||||
const author = useAuthor(user?.pubkey);
|
||||
|
|
@ -29,13 +31,13 @@ export function EscrowKeyCard() {
|
|||
try {
|
||||
await escrow.advertiseEscrowKey();
|
||||
toast({
|
||||
title: 'Escrow key published',
|
||||
description: 'Buyers can now lock escrow funds to you.',
|
||||
title: t('walletUi.escrow.published'),
|
||||
description: t('walletUi.escrow.publishedDesc'),
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not publish key',
|
||||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||
title: t('walletUi.escrow.failed'),
|
||||
description: e instanceof Error ? e.message : t('walletUi.addToCollection.somethingWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -48,17 +50,16 @@ export function EscrowKeyCard() {
|
|||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg text-[#ff6600]">
|
||||
<Handshake className="h-5 w-5" />
|
||||
Escrow
|
||||
{t('walletUi.escrow.title')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Advertise a public key so buyers can lock funds to you for a trade. Funds
|
||||
are refundable to the buyer if the deal falls through.
|
||||
{t('walletUi.escrow.desc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{advertised ? (
|
||||
<Badge className="gap-1 bg-[#ccff00]/15 text-[#ccff00] border border-[#ccff00]/30">
|
||||
<Check className="h-3 w-3" /> Escrow key active
|
||||
<Check className="h-3 w-3" /> {t('walletUi.escrow.active')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
|
|
@ -67,7 +68,7 @@ export function EscrowKeyCard() {
|
|||
className="gap-2 bg-[#ff6600] font-bold text-black hover:bg-[#ff6600]/90"
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Handshake className="h-4 w-4" />}
|
||||
Publish escrow key
|
||||
{t('walletUi.escrow.publish')}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
|
|||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Shield, Eye, AlertTriangle, ChevronDown, ChevronUp, Nut, Zap, Globe, Lock, CheckCircle2, Info } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
interface LightningPrivacyTipsProps {
|
||||
className?: string;
|
||||
|
|
@ -11,6 +12,7 @@ interface LightningPrivacyTipsProps {
|
|||
}
|
||||
|
||||
export function LightningPrivacyTips({ className, defaultExpanded = false }: LightningPrivacyTipsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
|
||||
return (
|
||||
|
|
@ -22,7 +24,7 @@ export function LightningPrivacyTips({ className, defaultExpanded = false }: Lig
|
|||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Shield className="h-5 w-5 text-blue-500" />
|
||||
Privacy Tips
|
||||
{t('walletUi.privacy.title')}
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
{isExpanded ? (
|
||||
|
|
@ -34,7 +36,7 @@ export function LightningPrivacyTips({ className, defaultExpanded = false }: Lig
|
|||
</div>
|
||||
{!isExpanded && (
|
||||
<CardDescription className="text-xs">
|
||||
Learn how to protect your financial privacy
|
||||
{t('walletUi.privacy.subtitle')}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
|
@ -47,24 +49,24 @@ export function LightningPrivacyTips({ className, defaultExpanded = false }: Lig
|
|||
<div className="p-3 rounded-lg bg-green-500/10 border border-green-500/30">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Nut className="h-5 w-5 text-orange-500" />
|
||||
<span className="font-bold text-sm text-green-400">CASHU (Recommended)</span>
|
||||
<span className="font-bold text-sm text-green-400">{t('walletUi.privacy.cashuRecommended')}</span>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-xs text-gray-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-3 w-3 text-green-500" />
|
||||
<span>Anonymous transactions</span>
|
||||
<span>{t('walletUi.privacy.anon')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-3 w-3 text-green-500" />
|
||||
<span>Blind signatures hide activity</span>
|
||||
<span>{t('walletUi.privacy.blind')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-3 w-3 text-green-500" />
|
||||
<span>No IP exposure</span>
|
||||
<span>{t('walletUi.privacy.noIp')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-amber-400">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
<span>Trust mint with funds</span>
|
||||
<span>{t('walletUi.privacy.trustMint')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -73,24 +75,24 @@ export function LightningPrivacyTips({ className, defaultExpanded = false }: Lig
|
|||
<div className="p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Zap className="h-5 w-5 text-amber-500" />
|
||||
<span className="font-bold text-sm text-amber-400">LIGHTNING ADDRESS</span>
|
||||
<span className="font-bold text-sm text-amber-400">{t('walletUi.privacy.lightningAddress')}</span>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-xs text-gray-300">
|
||||
<div className="flex items-center gap-2 text-amber-400">
|
||||
<Eye className="h-3 w-3" />
|
||||
<span>Can be linked to identity</span>
|
||||
<span>{t('walletUi.privacy.linked')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-amber-400">
|
||||
<Eye className="h-3 w-3" />
|
||||
<span>Payment history traceable</span>
|
||||
<span>{t('walletUi.privacy.traceable')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-amber-400">
|
||||
<Globe className="h-3 w-3" />
|
||||
<span>IP visible when invoicing</span>
|
||||
<span>{t('walletUi.privacy.ipVisible')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-green-400">
|
||||
<Lock className="h-3 w-3" />
|
||||
<span>Self-custody possible</span>
|
||||
<span>{t('walletUi.privacy.selfCustody')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -100,35 +102,35 @@ export function LightningPrivacyTips({ className, defaultExpanded = false }: Lig
|
|||
<div className="space-y-2">
|
||||
<h4 className="font-bold text-sm flex items-center gap-2">
|
||||
<Info className="h-4 w-4 text-blue-500" />
|
||||
Best Practices
|
||||
{t('walletUi.privacy.bestPractices')}
|
||||
</h4>
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex items-start gap-2 p-2 rounded bg-gray-800/50">
|
||||
<span className="text-blue-400 font-bold">1.</span>
|
||||
<div>
|
||||
<span className="font-medium text-gray-200">Use Cashu for receiving tips</span>
|
||||
<p className="text-gray-500 mt-0.5">Built-in Cashu wallet is the most private way to receive zaps</p>
|
||||
<span className="font-medium text-gray-200">{t('walletUi.privacy.tip1Title')}</span>
|
||||
<p className="text-gray-500 mt-0.5">{t('walletUi.privacy.tip1Desc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 p-2 rounded bg-gray-800/50">
|
||||
<span className="text-blue-400 font-bold">2.</span>
|
||||
<div>
|
||||
<span className="font-medium text-gray-200">Use a separate wallet for SkateSpots</span>
|
||||
<p className="text-gray-500 mt-0.5">Don't use your main savings wallet - keep app funds separate</p>
|
||||
<span className="font-medium text-gray-200">{t('walletUi.privacy.tip2Title')}</span>
|
||||
<p className="text-gray-500 mt-0.5">{t('walletUi.privacy.tip2Desc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 p-2 rounded bg-gray-800/50">
|
||||
<span className="text-blue-400 font-bold">3.</span>
|
||||
<div>
|
||||
<span className="font-medium text-gray-200">Consider using VPN or Tor</span>
|
||||
<p className="text-gray-500 mt-0.5">Hides your IP address when making transactions</p>
|
||||
<span className="font-medium text-gray-200">{t('walletUi.privacy.tip3Title')}</span>
|
||||
<p className="text-gray-500 mt-0.5">{t('walletUi.privacy.tip3Desc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 p-2 rounded bg-gray-800/50">
|
||||
<span className="text-blue-400 font-bold">4.</span>
|
||||
<div>
|
||||
<span className="font-medium text-gray-200">Avoid identity-linked Lightning addresses</span>
|
||||
<p className="text-gray-500 mt-0.5">Use pseudonymous addresses not tied to your real name</p>
|
||||
<span className="font-medium text-gray-200">{t('walletUi.privacy.tip4Title')}</span>
|
||||
<p className="text-gray-500 mt-0.5">{t('walletUi.privacy.tip4Desc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -138,8 +140,8 @@ export function LightningPrivacyTips({ className, defaultExpanded = false }: Lig
|
|||
<Alert className="border-green-500/30 bg-green-500/5">
|
||||
<Nut className="h-4 w-4 text-orange-500" />
|
||||
<AlertDescription className="text-xs">
|
||||
<span className="font-bold text-green-400">Pro tip:</span> Receive payments to your Cashu wallet first,
|
||||
then periodically move larger amounts to your personal wallet using a VPN for the transfer.
|
||||
<span className="font-bold text-green-400">{t('walletUi.privacy.proTip')}</span>{' '}
|
||||
{t('walletUi.privacy.proTipDesc')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
|
|
@ -156,32 +158,33 @@ export function LightningAddressPrivacyWarning({
|
|||
onUseCashu?: () => void;
|
||||
onContinue: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="p-4 rounded-lg bg-amber-500/10 border border-amber-500/30 space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-bold text-amber-400 text-sm">Privacy Note</h4>
|
||||
<h4 className="font-bold text-amber-400 text-sm">{t('walletUi.privacy.warnTitle')}</h4>
|
||||
<p className="text-xs text-gray-300 mt-1">
|
||||
Lightning addresses can potentially link your SkateSpots activity to your real identity.
|
||||
{t('walletUi.privacy.warnDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-xs text-gray-400">
|
||||
<p className="font-medium">For better privacy:</p>
|
||||
<p className="font-medium">{t('walletUi.privacy.betterPrivacy')}</p>
|
||||
<ul className="space-y-1 ml-4">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="text-amber-400">•</span>
|
||||
Use a dedicated wallet for this app
|
||||
{t('walletUi.privacy.bullet1')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="text-amber-400">•</span>
|
||||
Consider using Cashu wallet instead
|
||||
{t('walletUi.privacy.bullet2')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="text-amber-400">•</span>
|
||||
Use VPN/Tor for transactions
|
||||
{t('walletUi.privacy.bullet3')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -195,7 +198,7 @@ export function LightningAddressPrivacyWarning({
|
|||
className="flex-1 border-green-500/50 text-green-400 hover:bg-green-500/10"
|
||||
>
|
||||
<Nut className="h-4 w-4 mr-1" />
|
||||
Use Cashu Instead
|
||||
{t('walletUi.privacy.useCashu')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
|
|
@ -204,7 +207,7 @@ export function LightningAddressPrivacyWarning({
|
|||
onClick={onContinue}
|
||||
className="flex-1"
|
||||
>
|
||||
I Understand
|
||||
{t('walletUi.privacy.understand')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { isNativePlatform } from '@/lib/platform';
|
||||
|
||||
interface Mint {
|
||||
|
|
@ -56,18 +57,18 @@ const COINOS = {
|
|||
const LNBITS = {
|
||||
url: 'https://legend.lnbits.com/cashu/api/v1/AptDNABNBXv8gpuywhx6NV',
|
||||
name: 'LNbits Legend',
|
||||
description: 'Community mint on LNbits',
|
||||
description: 'walletUi.mintPrivacy.mintLnbitsDesc',
|
||||
};
|
||||
|
||||
const RECOMMENDED_MINTS_WEB = [
|
||||
{ ...COINOS, description: 'Web-friendly mint (recommended for browsers)' },
|
||||
{ ...COINOS, description: 'walletUi.mintPrivacy.mintCoinosWebDesc' },
|
||||
LNBITS,
|
||||
{ ...MINIBITS, description: '⚠ Native app only — not reachable from web browsers' },
|
||||
{ ...MINIBITS, description: 'walletUi.mintPrivacy.mintMinibitsWebDesc' },
|
||||
];
|
||||
|
||||
const RECOMMENDED_MINTS_NATIVE = [
|
||||
{ ...MINIBITS, description: 'SkateSpots mint — recommended' },
|
||||
{ ...COINOS, description: 'Web-friendly mint' },
|
||||
{ ...MINIBITS, description: 'walletUi.mintPrivacy.mintMinibitsNativeDesc' },
|
||||
{ ...COINOS, description: 'walletUi.mintPrivacy.mintCoinosDesc' },
|
||||
LNBITS,
|
||||
];
|
||||
|
||||
|
|
@ -81,6 +82,7 @@ export function MintPrivacyManager({
|
|||
totalBalance,
|
||||
className,
|
||||
}: MintPrivacyManagerProps) {
|
||||
const { t } = useTranslation();
|
||||
const RECOMMENDED_MINTS = isNativePlatform() ? RECOMMENDED_MINTS_NATIVE : RECOMMENDED_MINTS_WEB;
|
||||
const [showPrivacyTips, setShowPrivacyTips] = useState(false);
|
||||
const [showAddMint, setShowAddMint] = useState(false);
|
||||
|
|
@ -98,14 +100,14 @@ export function MintPrivacyManager({
|
|||
|
||||
// Privacy score based on distribution
|
||||
const getPrivacyScore = () => {
|
||||
if (mints.length === 0) return { score: 0, label: 'No Mints', color: 'text-gray-500' };
|
||||
if (mints.length === 1) return { score: 30, label: 'Single Mint', color: 'text-red-400' };
|
||||
if (mints.length === 0) return { score: 0, label: t('walletUi.mintPrivacy.noMints'), color: 'text-gray-500' };
|
||||
if (mints.length === 1) return { score: 30, label: t('walletUi.mintPrivacy.singleMint'), color: 'text-red-400' };
|
||||
|
||||
// Check if funds are well distributed
|
||||
const maxPercentage = Math.max(...mintDistribution.map(m => m.percentage));
|
||||
if (maxPercentage > 80) return { score: 50, label: 'Concentrated', color: 'text-amber-400' };
|
||||
if (maxPercentage > 60) return { score: 70, label: 'Moderate', color: 'text-yellow-400' };
|
||||
return { score: 90, label: 'Well Distributed', color: 'text-green-400' };
|
||||
if (maxPercentage > 80) return { score: 50, label: t('walletUi.mintPrivacy.concentrated'), color: 'text-amber-400' };
|
||||
if (maxPercentage > 60) return { score: 70, label: t('walletUi.mintPrivacy.moderate'), color: 'text-yellow-400' };
|
||||
return { score: 90, label: t('walletUi.mintPrivacy.wellDistributed'), color: 'text-green-400' };
|
||||
};
|
||||
|
||||
const privacyScore = getPrivacyScore();
|
||||
|
|
@ -115,7 +117,7 @@ export function MintPrivacyManager({
|
|||
|
||||
// Basic validation
|
||||
if (!mintUrl.startsWith('https://')) {
|
||||
toast({ variant: 'destructive', title: 'Invalid URL', description: 'Mint URL must start with https://' });
|
||||
toast({ variant: 'destructive', title: t('walletUi.mintPrivacy.invalidUrl'), description: t('walletUi.mintPrivacy.invalidUrlDesc') });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -124,12 +126,12 @@ export function MintPrivacyManager({
|
|||
await onAddMint(mintUrl.trim());
|
||||
setMintUrl('');
|
||||
setShowAddMint(false);
|
||||
toast({ title: 'Mint Added', description: 'New mint added successfully' });
|
||||
toast({ title: t('walletUi.mintPrivacy.mintAdded'), description: t('walletUi.mintPrivacy.mintAddedDesc') });
|
||||
} catch (err) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Failed to add mint',
|
||||
description: err instanceof Error ? err.message : 'Unknown error'
|
||||
title: t('walletUi.mintPrivacy.failedAdd'),
|
||||
description: err instanceof Error ? err.message : t('walletUi.cashu.unknownError')
|
||||
});
|
||||
} finally {
|
||||
setIsAddingMint(false);
|
||||
|
|
@ -160,7 +162,7 @@ export function MintPrivacyManager({
|
|||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Shield className="h-5 w-5 text-purple-500" />
|
||||
Mint Privacy
|
||||
{t('walletUi.mintPrivacy.title')}
|
||||
</CardTitle>
|
||||
<Badge
|
||||
variant="outline"
|
||||
|
|
@ -170,7 +172,7 @@ export function MintPrivacyManager({
|
|||
</Badge>
|
||||
</div>
|
||||
<CardDescription className="text-xs">
|
||||
Using multiple mints improves privacy by preventing any single mint from seeing all your transactions
|
||||
{t('walletUi.mintPrivacy.desc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
|
|
@ -178,7 +180,7 @@ export function MintPrivacyManager({
|
|||
{/* Distribution Visualization */}
|
||||
{mints.length > 0 && totalBalance > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Fund Distribution</Label>
|
||||
<Label className="text-xs text-muted-foreground">{t('walletUi.mintPrivacy.distribution')}</Label>
|
||||
<div className="h-3 rounded-full bg-gray-800 overflow-hidden flex">
|
||||
{mintDistribution.map((item, index) => (
|
||||
<div
|
||||
|
|
@ -220,15 +222,14 @@ export function MintPrivacyManager({
|
|||
<Alert className="border-amber-500/30 bg-amber-500/5">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
<AlertDescription className="text-xs">
|
||||
Using a single mint means that mint can see all your transaction timing and amounts.
|
||||
Consider adding more mints for better privacy.
|
||||
{t('walletUi.mintPrivacy.singleWarning')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Mint List */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">Your Mints ({mints.length})</Label>
|
||||
<Label className="text-xs font-medium">{t('walletUi.mintPrivacy.yourMints', { count: mints.length })}</Label>
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{mints.map((mint) => (
|
||||
<div
|
||||
|
|
@ -251,7 +252,7 @@ export function MintPrivacyManager({
|
|||
{activeMintUrl === mint.url && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
<Star className="h-2.5 w-2.5 mr-0.5" />
|
||||
Default
|
||||
{t('walletUi.mintPrivacy.default')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -279,7 +280,7 @@ export function MintPrivacyManager({
|
|||
onClick={() => setShowAddMint(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Mint
|
||||
{t('walletUi.mintPrivacy.addMint')}
|
||||
</Button>
|
||||
|
||||
{/* Auto Distribution Toggle — not yet implemented, so it's shown as a
|
||||
|
|
@ -288,16 +289,16 @@ export function MintPrivacyManager({
|
|||
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50 opacity-70">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm font-medium">Auto-distribute funds</Label>
|
||||
<Label className="text-sm font-medium">{t('walletUi.mintPrivacy.autoDistribute')}</Label>
|
||||
<Badge variant="outline" className="text-[10px] text-amber-400 border-amber-500/30">
|
||||
Soon
|
||||
{t('walletUi.mintPrivacy.soon')}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Spread received funds across multiple mints
|
||||
{t('walletUi.mintPrivacy.autoDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={false} disabled aria-label="Auto-distribute funds (coming soon)" />
|
||||
<Switch checked={false} disabled aria-label={t('walletUi.mintPrivacy.autoAria')} />
|
||||
</div>
|
||||
|
||||
{/* Privacy Tips Toggle */}
|
||||
|
|
@ -308,7 +309,7 @@ export function MintPrivacyManager({
|
|||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Info className="h-4 w-4 text-blue-500" />
|
||||
Privacy Tips
|
||||
{t('walletUi.privacy.title')}
|
||||
</span>
|
||||
{showPrivacyTips ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
|
|
@ -321,23 +322,23 @@ export function MintPrivacyManager({
|
|||
<div className="space-y-2 text-xs p-3 rounded-lg bg-blue-500/5 border border-blue-500/20">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-blue-400 font-bold">•</span>
|
||||
<span className="text-gray-300">Use 2-3 different mints minimum for better privacy</span>
|
||||
<span className="text-gray-300">{t('walletUi.mintPrivacy.tip1')}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-blue-400 font-bold">•</span>
|
||||
<span className="text-gray-300">Don't keep all funds in one mint - spread them out</span>
|
||||
<span className="text-gray-300">{t('walletUi.mintPrivacy.tip2')}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-blue-400 font-bold">•</span>
|
||||
<span className="text-gray-300">Consider mint jurisdiction (different countries = different legal access)</span>
|
||||
<span className="text-gray-300">{t('walletUi.mintPrivacy.tip3')}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-blue-400 font-bold">•</span>
|
||||
<span className="text-gray-300">Mints see timing of transactions but NOT who you pay (blind signatures)</span>
|
||||
<span className="text-gray-300">{t('walletUi.mintPrivacy.tip4')}</span>
|
||||
</div>
|
||||
<div className="mt-2 pt-2 border-t border-blue-500/20">
|
||||
<p className="text-gray-500 italic">
|
||||
Future features: Mint rotation and privacy scoring - stay tuned!
|
||||
{t('walletUi.mintPrivacy.future')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -351,7 +352,7 @@ export function MintPrivacyManager({
|
|||
className="inline-flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Find more mints at mintradar.pedani.eu
|
||||
{t('walletUi.mintPrivacy.findMore')}
|
||||
</a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -360,16 +361,16 @@ export function MintPrivacyManager({
|
|||
<Dialog open={showAddMint} onOpenChange={setShowAddMint}>
|
||||
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Mint</DialogTitle>
|
||||
<DialogTitle>{t('walletUi.mintPrivacy.addMint')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new Cashu mint to your wallet
|
||||
{t('walletUi.mintPrivacy.addMintDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Manual Entry */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Mint URL</Label>
|
||||
<Label className="text-sm">{t('walletUi.mintPrivacy.mintUrl')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="https://mint.example.com"
|
||||
|
|
@ -381,7 +382,7 @@ export function MintPrivacyManager({
|
|||
onClick={handleAddMint}
|
||||
disabled={!mintUrl || isAddingMint}
|
||||
>
|
||||
{isAddingMint ? 'Adding...' : 'Add'}
|
||||
{isAddingMint ? t('walletUi.mintPrivacy.adding') : t('walletUi.mintPrivacy.add')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -391,13 +392,13 @@ export function MintPrivacyManager({
|
|||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">Or choose from list</span>
|
||||
<span className="bg-background px-2 text-muted-foreground">{t('walletUi.mintPrivacy.orChoose')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommended Mints */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Recommended Mints</Label>
|
||||
<Label className="text-xs text-muted-foreground">{t('walletUi.mintPrivacy.recommended')}</Label>
|
||||
{RECOMMENDED_MINTS.map((mint) => {
|
||||
const isAdded = mints.some(m => m.url.toLowerCase() === mint.url.toLowerCase());
|
||||
return (
|
||||
|
|
@ -413,10 +414,10 @@ export function MintPrivacyManager({
|
|||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">{mint.name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground ml-6">{mint.description}</p>
|
||||
<p className="text-xs text-muted-foreground ml-6">{t(mint.description)}</p>
|
||||
</div>
|
||||
{isAdded ? (
|
||||
<Badge variant="secondary" className="text-xs">Added</Badge>
|
||||
<Badge variant="secondary" className="text-xs">{t('walletUi.mintPrivacy.added')}</Badge>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -425,12 +426,12 @@ export function MintPrivacyManager({
|
|||
setIsAddingMint(true);
|
||||
try {
|
||||
await onAddMint(mint.url);
|
||||
toast({ title: 'Mint Added', description: `${mint.name} added successfully` });
|
||||
toast({ title: t('walletUi.mintPrivacy.mintAdded'), description: t('walletUi.mintPrivacy.mintAddedNamed', { name: mint.name }) });
|
||||
} catch (err) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Failed',
|
||||
description: err instanceof Error ? err.message : 'Unknown error'
|
||||
title: t('walletUi.mintPrivacy.failed'),
|
||||
description: err instanceof Error ? err.message : t('walletUi.cashu.unknownError')
|
||||
});
|
||||
} finally {
|
||||
setIsAddingMint(false);
|
||||
|
|
@ -450,9 +451,7 @@ export function MintPrivacyManager({
|
|||
<Alert className="border-amber-500/30 bg-amber-500/5">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
<AlertDescription className="text-xs">
|
||||
<span className="font-bold text-amber-400">Trust Warning:</span> Mints are custodial - they hold your funds.
|
||||
Only use mints you trust. If a mint disappears, your funds may be lost.
|
||||
Spreading funds across multiple mints reduces risk.
|
||||
<span className="font-bold text-amber-400">{t('walletUi.mintPrivacy.trustWarning')}</span> {t('walletUi.mintPrivacy.trustWarningDesc')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
|
@ -463,9 +462,9 @@ export function MintPrivacyManager({
|
|||
<Dialog open={!!mintToRemove} onOpenChange={(open) => !open && setMintToRemove(null)}>
|
||||
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove Mint?</DialogTitle>
|
||||
<DialogTitle>{t('walletUi.mintPrivacy.removeTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to remove {mintToRemove && getCleanMintLabel(mintToRemove)}?
|
||||
{t('walletUi.mintPrivacy.removeDesc', { name: mintToRemove ? getCleanMintLabel(mintToRemove) : '' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
|
|
@ -473,17 +472,16 @@ export function MintPrivacyManager({
|
|||
<Alert className="border-red-500/30 bg-red-500/5">
|
||||
<AlertTriangle className="h-4 w-4 text-red-500" />
|
||||
<AlertDescription className="text-xs">
|
||||
<span className="font-bold text-red-400">Warning:</span> This mint has {(balances[mintToRemove.url] || 0).toLocaleString()} sats.
|
||||
Withdraw your funds before removing!
|
||||
<span className="font-bold text-red-400">{t('walletUi.mintPrivacy.warning')}</span> {t('walletUi.mintPrivacy.withdrawWarning', { amount: (balances[mintToRemove.url] || 0).toLocaleString() })}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="outline" onClick={() => setMintToRemove(null)}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleConfirmRemove}>
|
||||
Remove Mint
|
||||
{t('walletUi.mintPrivacy.removeMint')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Textarea } from '@/components/ui/textarea';
|
|||
import { useNWC } from '@/hooks/useNWCContext';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { getSatoshisFromBolt11 } from '@/lib/bolt11';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
|
|
@ -26,6 +27,7 @@ import {
|
|||
* pay-invoice (send). It renders nothing when no NWC connection is active.
|
||||
*/
|
||||
export function NwcWalletCard() {
|
||||
const { t } = useTranslation();
|
||||
const { getActiveConnection, getBalance, makeInvoice, sendPayment } = useNWC();
|
||||
const { toast } = useToast();
|
||||
const connection = getActiveConnection();
|
||||
|
|
@ -49,14 +51,14 @@ export function NwcWalletCard() {
|
|||
setBalance(await getBalance(connection));
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not read balance',
|
||||
description: e instanceof Error ? e.message : 'Your wallet did not respond.',
|
||||
title: t('walletUi.nwc.readBalanceFailed'),
|
||||
description: e instanceof Error ? e.message : t('walletUi.nwc.readBalanceFailedDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoadingBalance(false);
|
||||
}
|
||||
}, [connection, getBalance, toast]);
|
||||
}, [connection, getBalance, toast, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshBalance();
|
||||
|
|
@ -66,7 +68,7 @@ export function NwcWalletCard() {
|
|||
if (!connection) return;
|
||||
const sats = Number(receiveAmount);
|
||||
if (!Number.isFinite(sats) || sats <= 0) {
|
||||
toast({ title: 'Enter an amount in sats', variant: 'destructive' });
|
||||
toast({ title: t('walletUi.nwc.enterAmount'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
|
|
@ -77,8 +79,8 @@ export function NwcWalletCard() {
|
|||
setQr(await QRCode.toDataURL(inv.toUpperCase(), { width: 256, margin: 1 }));
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not create invoice',
|
||||
description: e instanceof Error ? e.message : 'Try again in a moment.',
|
||||
title: t('walletUi.nwc.createInvoiceFailed'),
|
||||
description: e instanceof Error ? e.message : t('walletUi.nwc.createInvoiceFailedDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -94,13 +96,13 @@ export function NwcWalletCard() {
|
|||
try {
|
||||
await sendPayment(connection, inv);
|
||||
const sats = (() => { try { return getSatoshisFromBolt11(inv); } catch { return 0; } })();
|
||||
toast({ title: 'Payment sent', description: sats > 0 ? `Paid ${sats.toLocaleString()} sats.` : 'Paid.' });
|
||||
toast({ title: t('walletUi.nwc.paymentSent'), description: sats > 0 ? t('walletUi.nwc.paidAmount', { amount: sats.toLocaleString() }) : t('walletUi.nwc.paid') });
|
||||
setPayInput('');
|
||||
void refreshBalance();
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Payment failed',
|
||||
description: e instanceof Error ? e.message : 'The wallet rejected the payment.',
|
||||
title: t('walletUi.cashu.paymentFailed'),
|
||||
description: e instanceof Error ? e.message : t('walletUi.nwc.paymentRejected'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -130,17 +132,17 @@ export function NwcWalletCard() {
|
|||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg text-[#ff6600]">
|
||||
<Zap className="h-5 w-5" />
|
||||
Lightning Wallet
|
||||
{t('walletUi.nwc.title')}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-gray-500">
|
||||
{connection.alias || 'NWC wallet'} · NIP-47
|
||||
{connection.alias || t('walletUi.nwc.noConnection')} · NIP-47
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{/* Balance */}
|
||||
<div className="flex items-center justify-between rounded-xl border border-white/10 bg-white/[0.03] px-4 py-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-500">Balance</p>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-500">{t('walletUi.cashu.balance')}</p>
|
||||
<p className="text-2xl font-black text-white">
|
||||
{loadingBalance && balance === null ? '…' : balance === null ? '—' : balance.toLocaleString()}
|
||||
<span className="ml-1 text-sm font-bold text-gray-400">sats</span>
|
||||
|
|
@ -149,7 +151,7 @@ export function NwcWalletCard() {
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Refresh balance"
|
||||
aria-label={t('walletUi.nwc.refresh')}
|
||||
onClick={() => refreshBalance()}
|
||||
disabled={loadingBalance}
|
||||
className="text-gray-400 hover:text-white"
|
||||
|
|
@ -161,14 +163,14 @@ export function NwcWalletCard() {
|
|||
{/* Receive */}
|
||||
<div className="space-y-2">
|
||||
<p className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-gray-400">
|
||||
<ArrowDownToLine className="h-3.5 w-3.5 text-[#ccff00]" /> Receive
|
||||
<ArrowDownToLine className="h-3.5 w-3.5 text-[#ccff00]" /> {t('walletUi.cashu.receive')}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={receiveAmount}
|
||||
onChange={(e) => setReceiveAmount(e.target.value)}
|
||||
inputMode="numeric"
|
||||
placeholder="Amount in sats"
|
||||
placeholder={t('walletUi.cashu.amountSats')}
|
||||
className="h-11 border-gray-700 bg-gray-900 text-white"
|
||||
/>
|
||||
<Button
|
||||
|
|
@ -176,16 +178,16 @@ export function NwcWalletCard() {
|
|||
disabled={creating}
|
||||
className="h-11 shrink-0 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||
>
|
||||
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Create invoice'}
|
||||
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : t('walletUi.nwc.createInvoice')}
|
||||
</Button>
|
||||
</div>
|
||||
{invoice && (
|
||||
<div className="space-y-2 rounded-xl border border-white/10 bg-white/[0.03] p-3">
|
||||
{qr && <img src={qr} alt="Lightning invoice QR code" className="mx-auto h-48 w-48 rounded-lg bg-white p-2" />}
|
||||
{qr && <img src={qr} alt={t('walletUi.nwc.invoiceQrAlt')} className="mx-auto h-48 w-48 rounded-lg bg-white p-2" />}
|
||||
<p className="break-all font-mono text-[11px] text-gray-400">{invoice}</p>
|
||||
<Button variant="outline" onClick={copyInvoice} className="w-full gap-2 border-white/15">
|
||||
{copied ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
|
||||
{copied ? 'Copied' : 'Copy invoice'}
|
||||
{copied ? t('common.copied') : t('walletUi.nwc.copyInvoice')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -194,18 +196,18 @@ export function NwcWalletCard() {
|
|||
{/* Send */}
|
||||
<div className="space-y-2">
|
||||
<p className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-gray-400">
|
||||
<ArrowUpFromLine className="h-3.5 w-3.5 text-[#ff6600]" /> Send
|
||||
<ArrowUpFromLine className="h-3.5 w-3.5 text-[#ff6600]" /> {t('walletUi.cashu.send')}
|
||||
</p>
|
||||
<Textarea
|
||||
value={payInput}
|
||||
onChange={(e) => setPayInput(e.target.value)}
|
||||
placeholder="Paste a Lightning invoice (lnbc…)"
|
||||
placeholder={t('walletUi.nwc.pasteInvoice')}
|
||||
rows={2}
|
||||
className="resize-none border-gray-700 bg-gray-900 font-mono text-xs text-white"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">
|
||||
{paySats > 0 ? `Amount: ${paySats.toLocaleString()} sats` : 'Paste an invoice to see the amount'}
|
||||
{paySats > 0 ? t('walletUi.nwc.amount', { amount: paySats.toLocaleString() }) : t('walletUi.nwc.pasteToSee')}
|
||||
</span>
|
||||
<Button
|
||||
onClick={handlePay}
|
||||
|
|
@ -213,7 +215,7 @@ export function NwcWalletCard() {
|
|||
className="ml-auto h-10 gap-2 bg-[#ff6600] font-bold text-black hover:bg-[#ff6600]/90"
|
||||
>
|
||||
{paying ? <Loader2 className="h-4 w-4 animate-spin" /> : <Zap className="h-4 w-4" />}
|
||||
Pay
|
||||
{t('walletUi.nwc.pay')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button';
|
|||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useQRCodeScanner, classifyQRCode, type QRScanResult } from '@/hooks/useQRCodeScanner';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { ScanLine, Clipboard, X } from 'lucide-react';
|
||||
|
||||
interface QRScannerModalProps {
|
||||
|
|
@ -16,6 +17,7 @@ export function QRScannerModal({
|
|||
onClose,
|
||||
onResult,
|
||||
}: QRScannerModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const { isScanning, error, startScanning, stopScanning } = useQRCodeScanner();
|
||||
|
||||
|
|
@ -33,8 +35,8 @@ export function QRScannerModal({
|
|||
if (!text || !text.trim()) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Clipboard empty',
|
||||
description: 'No text found in clipboard'
|
||||
title: t('walletUi.qr.clipboardEmpty'),
|
||||
description: t('walletUi.qr.clipboardEmptyDesc')
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -45,8 +47,8 @@ export function QRScannerModal({
|
|||
console.error('Error pasting from clipboard:', err);
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Paste failed',
|
||||
description: 'Could not read from clipboard'
|
||||
title: t('walletUi.qr.pasteFailed'),
|
||||
description: t('walletUi.qr.pasteFailedDesc')
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -82,10 +84,10 @@ export function QRScannerModal({
|
|||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<ScanLine className="h-5 w-5" />
|
||||
Scan QR Code
|
||||
{t('walletUi.qr.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
Position the QR code within the camera view to scan
|
||||
{t('walletUi.qr.desc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -120,7 +122,7 @@ export function QRScannerModal({
|
|||
size="sm"
|
||||
>
|
||||
<Clipboard className="h-4 w-4" />
|
||||
Paste from Clipboard
|
||||
{t('walletUi.qr.paste')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
|
|
@ -132,12 +134,12 @@ export function QRScannerModal({
|
|||
className="text-sm"
|
||||
>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground text-center mt-1 sm:mt-2">
|
||||
Scan a Cashu token, Lightning invoice, or Lightning address
|
||||
{t('walletUi.qr.hint')}
|
||||
</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Shield, Eye, EyeOff, Copy, Check } from 'lucide-react';
|
||||
|
||||
|
|
@ -47,6 +48,7 @@ export function RecoveryPhraseDialog({
|
|||
requireConfirm = false,
|
||||
onConfirm,
|
||||
}: RecoveryPhraseDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
|
@ -76,10 +78,10 @@ export function RecoveryPhraseDialog({
|
|||
try {
|
||||
await navigator.clipboard.writeText(mnemonic);
|
||||
setCopied(true);
|
||||
toast({ title: 'Copied', description: 'Recovery phrase copied — paste it somewhere safe, then clear your clipboard.' });
|
||||
toast({ title: t('common.copied'), description: t('walletUi.recovery.copiedDesc') });
|
||||
setTimeout(() => setCopied(false), 2500);
|
||||
} catch {
|
||||
toast({ variant: 'destructive', title: 'Copy failed', description: 'Please write the words down instead.' });
|
||||
toast({ variant: 'destructive', title: t('walletUi.recovery.copyFailed'), description: t('walletUi.recovery.copyFailedDesc') });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -94,10 +96,9 @@ export function RecoveryPhraseDialog({
|
|||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-2xl bg-amber-500/15">
|
||||
<Shield className="h-6 w-6 text-amber-500" />
|
||||
</div>
|
||||
<DialogTitle className="text-center text-lg font-black">Recovery Phrase</DialogTitle>
|
||||
<DialogTitle className="text-center text-lg font-black">{t('walletUi.recovery.title')}</DialogTitle>
|
||||
<DialogDescription className="text-center text-xs leading-relaxed">
|
||||
These 12 words are the only way to restore your ecash balance. Write them down in order
|
||||
and store them offline — never share them with anyone.
|
||||
{t('walletUi.recovery.desc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -134,9 +135,9 @@ export function RecoveryPhraseDialog({
|
|||
<Eye className="h-5 w-5 text-amber-500" />
|
||||
</span>
|
||||
<span className="text-xs font-bold uppercase tracking-wide text-amber-400">
|
||||
Tap to reveal
|
||||
{t('walletUi.recovery.tapReveal')}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">Make sure nobody's watching</span>
|
||||
<span className="text-[10px] text-muted-foreground">{t('walletUi.recovery.noWatching')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -149,7 +150,7 @@ export function RecoveryPhraseDialog({
|
|||
onClick={() => setRevealed((v) => !v)}
|
||||
>
|
||||
{revealed ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
{revealed ? 'Hide' : 'Reveal'}
|
||||
{revealed ? t('walletUi.recovery.hide') : t('walletUi.recovery.reveal')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -158,12 +159,12 @@ export function RecoveryPhraseDialog({
|
|||
disabled={!revealed}
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
{copied ? t('common.copied') : t('common.copy')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-[11px] leading-relaxed text-muted-foreground">
|
||||
Writing it on paper is safest — copying to your clipboard can leak to other apps.
|
||||
{t('walletUi.recovery.paperHint')}
|
||||
</p>
|
||||
|
||||
{/* Mandatory first-time backup confirmation. Disabled until the user has
|
||||
|
|
@ -175,7 +176,7 @@ export function RecoveryPhraseDialog({
|
|||
disabled={!revealed}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
{revealed ? "I've written it down" : 'Reveal the words first'}
|
||||
{revealed ? t('walletUi.recovery.written') : t('walletUi.recovery.revealFirst')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
interface WalletComparisonGuideProps {
|
||||
className?: string;
|
||||
|
|
@ -17,6 +18,7 @@ interface WalletComparisonGuideProps {
|
|||
}
|
||||
|
||||
export function WalletComparisonGuide({ className, defaultExpanded = false }: WalletComparisonGuideProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
|
||||
return (
|
||||
|
|
@ -30,7 +32,7 @@ export function WalletComparisonGuide({ className, defaultExpanded = false }: Wa
|
|||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">🛹</span>
|
||||
<span className="font-bold text-sm">
|
||||
{isExpanded ? 'Wallet Guide (Skater Edition)' : 'Quick Guide'}
|
||||
{isExpanded ? t('walletUi.guide.fullGuide') : t('walletUi.guide.quickGuide')}
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
|
|
@ -48,21 +50,21 @@ export function WalletComparisonGuide({ className, defaultExpanded = false }: Wa
|
|||
<div className="flex items-center gap-2">
|
||||
<Nut className="h-4 w-4 text-orange-500" />
|
||||
<span className="text-gray-300">
|
||||
<span className="font-bold text-orange-400">Cashu</span> = Privacy + Convenience
|
||||
<span className="text-gray-500"> (mint holds funds)</span>
|
||||
<span className="font-bold text-orange-400">Cashu</span> = {t('walletUi.guide.cashuSummary')}{' '}
|
||||
<span className="text-gray-500"> {t('walletUi.guide.mintHoldsFunds')}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-yellow-500" />
|
||||
<span className="text-gray-300">
|
||||
<span className="font-bold text-yellow-400">Lightning</span> = Control + Security
|
||||
<span className="text-gray-500"> (you hold keys)</span>
|
||||
<span className="font-bold text-yellow-400">Lightning</span> = {t('walletUi.guide.lightningSummary')}{' '}
|
||||
<span className="text-gray-500"> {t('walletUi.guide.youHoldKeys')}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 mt-3 p-2 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<Lightbulb className="h-4 w-4 text-green-500 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-xs text-green-400">
|
||||
Use both: Cashu for tips, Lightning for savings
|
||||
{t('walletUi.guide.useBoth')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -75,32 +77,32 @@ export function WalletComparisonGuide({ className, defaultExpanded = false }: Wa
|
|||
<div className="p-4 rounded-lg bg-orange-500/10 border border-orange-500/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Nut className="h-5 w-5 text-orange-500" />
|
||||
<h3 className="font-bold text-orange-400">CASHU WALLET</h3>
|
||||
<h3 className="font-bold text-orange-400">{t('walletUi.guide.cashuTitle')}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 italic mb-3">
|
||||
Like cash in your pocket at the skatepark
|
||||
{t('walletUi.guide.cashuTagline')}
|
||||
</p>
|
||||
<ul className="space-y-1.5 text-sm text-gray-300">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-orange-400">•</span>
|
||||
<span>Quick to pull out for a tip</span>
|
||||
<span>{t('walletUi.guide.cashuBullet1')}</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-orange-400">•</span>
|
||||
<span>Don't carry your life savings here</span>
|
||||
<span>{t('walletUi.guide.cashuBullet2')}</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-orange-400">•</span>
|
||||
<span>If the mint disappears, funds could be lost</span>
|
||||
<span>{t('walletUi.guide.cashuBullet3')}</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-orange-400">•</span>
|
||||
<span>Nobody knows how much you have (privacy!)</span>
|
||||
<span>{t('walletUi.guide.cashuBullet4')}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="mt-3 pt-3 border-t border-orange-500/20">
|
||||
<p className="text-xs text-orange-300">
|
||||
<span className="font-bold">Perfect for:</span> Session snacks, zapping clips, small tips
|
||||
<span className="font-bold">{t('walletUi.guide.perfectFor')}</span> {t('walletUi.guide.cashuPerfectFor')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -109,32 +111,32 @@ export function WalletComparisonGuide({ className, defaultExpanded = false }: Wa
|
|||
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Zap className="h-5 w-5 text-yellow-500" />
|
||||
<h3 className="font-bold text-yellow-400">LIGHTNING WALLET</h3>
|
||||
<h3 className="font-bold text-yellow-400">{t('walletUi.guide.lightningTitle')}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 italic mb-3">
|
||||
Like your bank account - you're in control
|
||||
{t('walletUi.guide.lightningTagline')}
|
||||
</p>
|
||||
<ul className="space-y-1.5 text-sm text-gray-300">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-yellow-400">•</span>
|
||||
<span>Secure, you control the keys</span>
|
||||
<span>{t('walletUi.guide.lightningBullet1')}</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-yellow-400">•</span>
|
||||
<span>Takes a moment to set up</span>
|
||||
<span>{t('walletUi.guide.lightningBullet2')}</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-yellow-400">•</span>
|
||||
<span>Full transaction history</span>
|
||||
<span>{t('walletUi.guide.lightningBullet3')}</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-yellow-400">•</span>
|
||||
<span>Better for bigger amounts</span>
|
||||
<span>{t('walletUi.guide.lightningBullet4')}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="mt-3 pt-3 border-t border-yellow-500/20">
|
||||
<p className="text-xs text-yellow-300">
|
||||
<span className="font-bold">Perfect for:</span> Gear purchases, savings, big zaps
|
||||
<span className="font-bold">{t('walletUi.guide.perfectFor')}</span> {t('walletUi.guide.lightningPerfectFor')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -143,31 +145,31 @@ export function WalletComparisonGuide({ className, defaultExpanded = false }: Wa
|
|||
<div className="p-4 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Sparkles className="h-5 w-5 text-green-500" />
|
||||
<h3 className="font-bold text-green-400">PRO TIPS</h3>
|
||||
<h3 className="font-bold text-green-400">{t('walletUi.guide.proTips')}</h3>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm text-gray-300">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-green-400 font-bold">1.</span>
|
||||
<span>
|
||||
<span className="font-medium text-white">Use both wallets</span> - Keep session money in Cashu, savings in Lightning
|
||||
<span className="font-medium text-white">{t('walletUi.guide.tip1Title')}</span> - {t('walletUi.guide.tip1Desc')}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-green-400 font-bold">2.</span>
|
||||
<span>
|
||||
<span className="font-medium text-white">Try different wallets</span> - Experiment to find what works for you. Each has different features and trade-offs
|
||||
<span className="font-medium text-white">{t('walletUi.guide.tip2Title')}</span> - {t('walletUi.guide.tip2Desc')}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-green-400 font-bold">3.</span>
|
||||
<span>
|
||||
<span className="font-medium text-white">Better privacy</span> - Using multiple wallets means no single app sees all your activity
|
||||
<span className="font-medium text-white">{t('walletUi.guide.tip3Title')}</span> - {t('walletUi.guide.tip3Desc')}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-green-400 font-bold">4.</span>
|
||||
<span>
|
||||
<span className="font-medium text-white">Gain experience</span> - The more tools you know, the more options you have. Start small, learn as you go
|
||||
<span className="font-medium text-white">{t('walletUi.guide.tip4Title')}</span> - {t('walletUi.guide.tip4Desc')}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
@ -176,8 +178,7 @@ export function WalletComparisonGuide({ className, defaultExpanded = false }: Wa
|
|||
{/* Bottom Summary */}
|
||||
<div className="text-center p-3 rounded-lg bg-gray-800/50 border border-gray-700">
|
||||
<p className="text-xs text-gray-400">
|
||||
🎯 <span className="font-bold text-white">TL;DR:</span> There's no "best" wallet - it depends on your needs.
|
||||
Try both, use what feels right, and don't keep all your sats in one place!
|
||||
🎯 <span className="font-bold text-white">{t('walletUi.guide.tldr')}</span> {t('walletUi.guide.tldrDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -60,13 +60,13 @@ export const NAV_CATALOG: NavTab[] = [
|
|||
{ id: 'events', href: '/events', labelKey: 'nav.events', label: 'Events', icon: Calendar },
|
||||
{ id: 'crews', href: '/crews', labelKey: 'nav.crews', label: 'Crews', icon: Users },
|
||||
{ id: 'wallet', href: '/wallet', labelKey: 'nav.wallet', label: 'Wallet', icon: Wallet, authOnly: true },
|
||||
{ id: 'mints', href: '/mints', label: 'Mints', icon: Coins },
|
||||
{ id: 'app-shop', href: '/app-shop', label: 'App Shop', icon: ShoppingBag },
|
||||
{ id: 'trades', href: '/marketplace', label: 'Marketplace', icon: Handshake },
|
||||
{ id: 'stash', href: '/stash', label: 'Stash', icon: Package, authOnly: true },
|
||||
{ id: 'collections', href: '/collections', label: 'Collections', icon: Bookmark, authOnly: true },
|
||||
{ id: 'contacts', href: '/contacts', label: 'Contacts', icon: Users, authOnly: true },
|
||||
{ id: 'messages', href: '/messages', label: 'Messages', icon: MessageCircle, authOnly: true },
|
||||
{ id: 'mints', href: '/mints', labelKey: 'nav.mints', label: 'Mints', icon: Coins },
|
||||
{ id: 'app-shop', href: '/app-shop', labelKey: 'nav.appShop', label: 'App Shop', icon: ShoppingBag },
|
||||
{ id: 'trades', href: '/marketplace', labelKey: 'nav.trades', label: 'Marketplace', icon: Handshake },
|
||||
{ id: 'stash', href: '/stash', labelKey: 'nav.stash', label: 'Stash', icon: Package, authOnly: true },
|
||||
{ id: 'collections', href: '/collections', labelKey: 'nav.collections', label: 'Collections', icon: Bookmark, authOnly: true },
|
||||
{ id: 'contacts', href: '/contacts', labelKey: 'nav.contacts', label: 'Contacts', icon: Users, authOnly: true },
|
||||
{ id: 'messages', href: '/messages', labelKey: 'nav.messages', label: 'Messages', icon: MessageCircle, authOnly: true },
|
||||
{ id: 'stats', href: '/stats', labelKey: 'nav.stats', label: 'Stats', icon: BarChart3 },
|
||||
];
|
||||
|
||||
|
|
|
|||
3720
src/i18n/en.ts
3720
src/i18n/en.ts
File diff suppressed because it is too large
Load diff
3722
src/i18n/es.ts
3722
src/i18n/es.ts
File diff suppressed because it is too large
Load diff
31
src/lib/spotTypeLabel.ts
Normal file
31
src/lib/spotTypeLabel.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { SPOT_TYPE_LABELS } from '@/lib/types';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
/**
|
||||
* Spot-type keys that have a translated label under `chromeUi.spotType.*`.
|
||||
* Anything else (feature tags like `wallrides`, legacy values) falls back to
|
||||
* the English label so map popups never show a raw tag string.
|
||||
*/
|
||||
const TRANSLATED_SPOT_TYPES = new Set([
|
||||
'street',
|
||||
'transition',
|
||||
'park',
|
||||
'plaza',
|
||||
'diy',
|
||||
'bowl',
|
||||
'vert',
|
||||
'indoor',
|
||||
'skatepark',
|
||||
'miniramp',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Localised label for a spot type/category key. Translates the ten spot-type
|
||||
* keys; every other key keeps its English `SPOT_TYPE_LABELS` label (or the key
|
||||
* itself as a last resort).
|
||||
*/
|
||||
export function spotTypeLabel(t: Translate, key: string): string {
|
||||
if (TRANSLATED_SPOT_TYPES.has(key)) return t(`chromeUi.spotType.${key}`);
|
||||
return SPOT_TYPE_LABELS[key] ?? key;
|
||||
}
|
||||
|
|
@ -19,11 +19,13 @@ import {
|
|||
Smartphone,
|
||||
MessageCircle,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
export default function AboutPage() {
|
||||
const { t } = useTranslation();
|
||||
useSeoMeta({
|
||||
title: 'About - SkateSpots',
|
||||
description: 'Learn about SkateSpots and our mission to build a decentralized skateboarding community.',
|
||||
title: t('settingsUi.about.seoTitle'),
|
||||
description: t('settingsUi.about.seoDescription'),
|
||||
});
|
||||
|
||||
const { user } = useCurrentUser();
|
||||
|
|
@ -32,19 +34,19 @@ export default function AboutPage() {
|
|||
const copyNpub = () => {
|
||||
navigator.clipboard.writeText('npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx');
|
||||
toast({
|
||||
title: 'Copied!',
|
||||
description: 'npub copied to clipboard. Paste it in your favorite Nostr client to send a DM.',
|
||||
title: t('common.copied'),
|
||||
description: t('settingsUi.about.npubCopied'),
|
||||
});
|
||||
};
|
||||
|
||||
// Bug report template for DM
|
||||
const bugReportTemplate = `🐛 Bug Report
|
||||
const bugReportTemplate = `${t('settingsUi.about.bugReport')}
|
||||
|
||||
What happened:
|
||||
${t('settingsUi.about.bugWhatHappened')}
|
||||
|
||||
What I expected:
|
||||
${t('settingsUi.about.bugExpected')}
|
||||
|
||||
Steps to reproduce:
|
||||
${t('settingsUi.about.bugSteps')}
|
||||
|
||||
Device/Browser: ${navigator.userAgent.includes('Mobile') ? 'Mobile' : 'Desktop'} - ${navigator.userAgent.split(' ').slice(-2).join(' ')}
|
||||
|
||||
|
|
@ -66,11 +68,11 @@ Version: v0.1.0`;
|
|||
fontFamily: 'Impact, "Arial Black", sans-serif',
|
||||
letterSpacing: '0.05em'
|
||||
}}>
|
||||
<span className="neon-red">ABOUT</span>{' '}
|
||||
<span className="neon-red">{t('settingsUi.about.aboutWord')}</span>{' '}
|
||||
<span className="gradient-text">SKATESPOTS</span>
|
||||
</h1>
|
||||
<p className="text-xl text-gray-300 max-w-2xl mx-auto mb-6">
|
||||
Connect with local skate scenes worldwide. <strong className="text-[#ff0040]">Skater-owned data</strong>, community-run relays.
|
||||
{t('settingsUi.about.heroBefore')} <strong className="text-[#ff0040]">{t('settingsUi.about.skaterOwned')}</strong>{t('settingsUi.about.heroAfter')}
|
||||
</p>
|
||||
<Link to="/stats">
|
||||
<Button
|
||||
|
|
@ -78,7 +80,7 @@ Version: v0.1.0`;
|
|||
className="gap-2 border-2 border-[#ccff00] text-[#ccff00] hover:bg-[#ccff00]/10"
|
||||
>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
View Community Stats
|
||||
{t('settingsUi.about.viewStats')}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -89,17 +91,14 @@ Version: v0.1.0`;
|
|||
<div className="flex items-start gap-4 mb-4">
|
||||
<Heart className="h-8 w-8 text-[#ff0040] flex-shrink-0" />
|
||||
<div>
|
||||
<h2 className="text-2xl font-black uppercase text-white mb-3">Our Mission</h2>
|
||||
<h2 className="text-2xl font-black uppercase text-white mb-3">{t('settingsUi.about.missionTitle')}</h2>
|
||||
<p className="text-gray-300 leading-relaxed">
|
||||
SkateSpots helps skateboarders discover spots, connect with pros at The Lab, and organize sessions -
|
||||
all without corporate control. The real skating happens offline, this tool just connects you to it.
|
||||
{t('settingsUi.about.mission1')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-300 leading-relaxed">
|
||||
Skateboarding knowledge should live with the community, not locked in corporate apps.
|
||||
That's why SkateSpots runs on Nostr - a relay-based system where skaters control their own
|
||||
data, choose which relays store it, and can even run their own. No single company owns the map.
|
||||
{t('settingsUi.about.mission2')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -110,37 +109,37 @@ Version: v0.1.0`;
|
|||
<div className="flex items-start gap-4 mb-6">
|
||||
<Globe className="h-8 w-8 text-[#ccff00] flex-shrink-0" />
|
||||
<div>
|
||||
<h2 className="text-2xl font-black uppercase text-white mb-3">Why Nostr?</h2>
|
||||
<h2 className="text-2xl font-black uppercase text-white mb-3">{t('settingsUi.about.whyNostr')}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="p-4 rounded-lg bg-black/50 border border-[#ff0040]/20">
|
||||
<Shield className="h-6 w-6 text-[#ff0040] mb-2" />
|
||||
<h3 className="font-bold text-white uppercase text-sm mb-2">Community-Controlled</h3>
|
||||
<h3 className="font-bold text-white uppercase text-sm mb-2">{t('settingsUi.about.why1Title')}</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Your spots live on community relays. Skaters run the servers, not corporations.
|
||||
{t('settingsUi.about.why1Desc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-black/50 border border-[#ccff00]/20">
|
||||
<Users className="h-6 w-6 text-[#ccff00] mb-2" />
|
||||
<h3 className="font-bold text-white uppercase text-sm mb-2">You Own Your Data</h3>
|
||||
<h3 className="font-bold text-white uppercase text-sm mb-2">{t('settingsUi.about.why2Title')}</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Your Nostr identity is yours. Take it to any Nostr app - no lock-in.
|
||||
{t('settingsUi.about.why2Desc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-black/50 border border-[#ff6600]/20">
|
||||
<Zap className="h-6 w-6 text-[#ff6600] mb-2" />
|
||||
<h3 className="font-bold text-white uppercase text-sm mb-2">Bitcoin Integration</h3>
|
||||
<h3 className="font-bold text-white uppercase text-sm mb-2">{t('settingsUi.about.why3Title')}</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Zap creators directly with Bitcoin. No platform taking 30%.
|
||||
{t('settingsUi.about.why3Desc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-black/50 border border-[#ccff00]/20">
|
||||
<Code className="h-6 w-6 text-[#ccff00] mb-2" />
|
||||
<h3 className="font-bold text-white uppercase text-sm mb-2">Open Source</h3>
|
||||
<h3 className="font-bold text-white uppercase text-sm mb-2">{t('settingsUi.about.why4Title')}</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Built in the open. Anyone can contribute or fork the code.
|
||||
{t('settingsUi.about.why4Desc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -151,13 +150,13 @@ Version: v0.1.0`;
|
|||
<div className="grid md:grid-cols-1 gap-6 max-w-md mx-auto">
|
||||
<Card className="bg-black/90 border-2 border-[#ccff00]/50">
|
||||
<CardContent className="p-6 text-center">
|
||||
<h3 className="font-black uppercase text-[#ccff00] mb-4">Get Involved</h3>
|
||||
<h3 className="font-black uppercase text-[#ccff00] mb-4">{t('settingsUi.about.getInvolved')}</h3>
|
||||
<p className="text-gray-300 mb-4 text-sm">
|
||||
SkateSpots is open source and community-driven. Contribute code, share spots, or help others get started.
|
||||
{t('settingsUi.about.getInvolvedDesc')}
|
||||
</p>
|
||||
<Link to="/onboarding">
|
||||
<Button className="gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase w-full">
|
||||
Get Started
|
||||
{t('common.getStarted')}
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
|
|
@ -170,15 +169,13 @@ Version: v0.1.0`;
|
|||
<div className="flex items-start gap-4">
|
||||
<AlertCircle className="h-6 w-6 text-yellow-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-yellow-500 uppercase mb-2">Beta Testing</h3>
|
||||
<h3 className="font-bold text-yellow-500 uppercase mb-2">{t('settingsUi.about.betaTitle')}</h3>
|
||||
<p className="text-sm text-gray-300 mb-4">
|
||||
Dear User! Thank you for beta testing SkateSpots. Please note that the app is still
|
||||
in beta, and there may be bugs or security issues. If you encounter any problems
|
||||
or want to report a bug/issue, please reach out!
|
||||
{t('settingsUi.about.betaDesc')}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
|
||||
<div className="flex-1 bg-black/30 rounded-lg p-3 border border-yellow-500/20 min-w-0">
|
||||
<p className="text-xs text-gray-400 mb-1">Give us feedback:</p>
|
||||
<p className="text-xs text-gray-400 mb-1">{t('settingsUi.about.giveFeedback')}</p>
|
||||
<code className="text-xs text-yellow-400 break-all">
|
||||
npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx
|
||||
</code>
|
||||
|
|
@ -190,7 +187,7 @@ Version: v0.1.0`;
|
|||
className="border-yellow-500/50 text-yellow-500 hover:bg-yellow-500/10"
|
||||
onClick={copyNpub}
|
||||
>
|
||||
Copy npub
|
||||
{t('settingsUi.about.copyNpub')}
|
||||
</Button>
|
||||
{user ? (
|
||||
<Link to={bugReportUrl}>
|
||||
|
|
@ -198,7 +195,7 @@ Version: v0.1.0`;
|
|||
size="sm"
|
||||
className="bg-yellow-500 text-black hover:bg-yellow-400"
|
||||
>
|
||||
Report Bug
|
||||
{t('settingsUi.about.reportBug')}
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
|
|
@ -211,7 +208,7 @@ Version: v0.1.0`;
|
|||
size="sm"
|
||||
className="bg-yellow-500 text-black hover:bg-yellow-400"
|
||||
>
|
||||
View Profile
|
||||
{t('settingsUi.about.viewProfile')}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
|
|
@ -229,10 +226,10 @@ Version: v0.1.0`;
|
|||
<div className="flex-1 text-center sm:text-left">
|
||||
<div className="flex items-center gap-2 justify-center sm:justify-start mb-2">
|
||||
<Smartphone className="h-5 w-5 text-[#ccff00]" />
|
||||
<h3 className="font-black uppercase text-[#ccff00]">Download the App</h3>
|
||||
<h3 className="font-black uppercase text-[#ccff00]">{t('settingsUi.about.downloadApp')}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300">
|
||||
Install SkateSpots on Android via Zapstore — the open-source, Bitcoin-native app store.
|
||||
{t('settingsUi.about.downloadAppDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
|
|
@ -243,7 +240,7 @@ Version: v0.1.0`;
|
|||
>
|
||||
<Button className="gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase px-6">
|
||||
<Download className="h-4 w-4" />
|
||||
Get on Zapstore
|
||||
{t('settingsUi.about.getOnZapstore')}
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -255,19 +252,19 @@ Version: v0.1.0`;
|
|||
<CardContent className="p-6">
|
||||
<h3 className="font-black uppercase text-white mb-4 flex items-center gap-2">
|
||||
<Heart className="h-5 w-5 text-[#ff0040]" />
|
||||
Credits
|
||||
{t('settingsUi.about.credits')}
|
||||
</h3>
|
||||
<div className="grid sm:grid-cols-2 gap-3 text-sm">
|
||||
{[
|
||||
{ label: 'Powered by', name: 'Nostr', url: 'https://github.com/nostr-protocol/nostr', desc: 'Decentralized protocol' },
|
||||
{ label: 'Crew chat via', name: 'Cordn', url: 'https://cordn.net/', desc: 'End-to-end encrypted group chat' },
|
||||
{ label: 'Ecash via', name: 'Cashu', url: 'https://cashu.space/', desc: 'Chaumian ecash for Lightning' },
|
||||
{ label: 'Wallet connect via', name: 'NWC', url: 'https://docs.nwc.dev/', desc: 'Nostr Wallet Connect (NIP-47)' },
|
||||
{ label: 'Signing on Android with', name: 'Amber', url: 'https://github.com/greenart7c3/Amber', desc: 'Nostr signer for Android' },
|
||||
{ label: 'Bitcoin map data from', name: 'BTCMap', url: 'https://btcmap.org/', desc: 'Places that accept Bitcoin' },
|
||||
{ label: 'Crowdfunding', name: 'Angor', url: 'https://angor.io/project/angor1qtunna00uqyx52rr0nvqa059z2gknhrmn7urd6z', desc: 'Decentralized Bitcoin crowdfunding' },
|
||||
{ label: 'Distributed on', name: 'Zapstore', url: 'https://zapstore.dev/apps/org.skatespots.app', desc: 'Open-source app store' },
|
||||
{ label: 'Trust & crew chat via', name: 'ContextVM', url: 'https://contextvm.org/s/e3564bb877adb0c99a7e4973ccb22e516d7d9eca0027d0bd62a04174ed670530', desc: 'Relatr trust scores & encrypted crew-chat coordination' },
|
||||
{ label: t('settingsUi.about.creditPoweredBy'), name: 'Nostr', url: 'https://github.com/nostr-protocol/nostr', desc: t('settingsUi.about.creditNostr') },
|
||||
{ label: t('settingsUi.about.creditCrewChat'), name: 'Cordn', url: 'https://cordn.net/', desc: t('settingsUi.about.creditCordn') },
|
||||
{ label: t('settingsUi.about.creditEcash'), name: 'Cashu', url: 'https://cashu.space/', desc: t('settingsUi.about.creditCashu') },
|
||||
{ label: t('settingsUi.about.creditWalletConnect'), name: 'NWC', url: 'https://docs.nwc.dev/', desc: t('settingsUi.about.creditNwc') },
|
||||
{ label: t('settingsUi.about.creditSigning'), name: 'Amber', url: 'https://github.com/greenart7c3/Amber', desc: t('settingsUi.about.creditAmber') },
|
||||
{ label: t('settingsUi.about.creditMapData'), name: 'BTCMap', url: 'https://btcmap.org/', desc: t('settingsUi.about.creditBtcmap') },
|
||||
{ label: t('settingsUi.about.creditCrowdfunding'), name: 'Angor', url: 'https://angor.io/project/angor1qtunna00uqyx52rr0nvqa059z2gknhrmn7urd6z', desc: t('settingsUi.about.creditAngor') },
|
||||
{ label: t('settingsUi.about.creditDistributed'), name: 'Zapstore', url: 'https://zapstore.dev/apps/org.skatespots.app', desc: t('settingsUi.about.creditZapstore') },
|
||||
{ label: t('settingsUi.about.creditTrust'), name: 'ContextVM', url: 'https://contextvm.org/s/e3564bb877adb0c99a7e4973ccb22e516d7d9eca0027d0bd62a04174ed670530', desc: t('settingsUi.about.creditContextvm') },
|
||||
].map((item) => (
|
||||
<a
|
||||
key={item.name}
|
||||
|
|
@ -293,9 +290,9 @@ Version: v0.1.0`;
|
|||
<CardContent className="p-6 flex flex-col sm:flex-row items-center gap-5">
|
||||
<div className="text-5xl flex-shrink-0">🦧</div>
|
||||
<div className="flex-1 text-center sm:text-left">
|
||||
<h3 className="text-xl font-black text-white uppercase mb-1">Still have questions?</h3>
|
||||
<h3 className="text-xl font-black text-white uppercase mb-1">{t('settingsUi.about.stillQuestions')}</h3>
|
||||
<p className="text-gray-400 text-sm">
|
||||
The Sasquatch is our AI guide — powered by live SkateSpots data. Ask him about spots, events, coaches, gear, or how anything works.
|
||||
{t('settingsUi.about.stillQuestionsDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
|
|
@ -303,23 +300,23 @@ Version: v0.1.0`;
|
|||
className="bg-amber-500 hover:bg-amber-400 text-black font-bold gap-2 flex-shrink-0 rounded-full px-6"
|
||||
>
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
Ask The Sasquatch
|
||||
{t('settingsUi.about.askSasquatch')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Powered By */}
|
||||
<div className="text-center mt-8 pb-6 px-4">
|
||||
<p className="text-gray-400 mb-3 uppercase tracking-wide text-sm">Powered by</p>
|
||||
<p className="text-gray-400 mb-3 uppercase tracking-wide text-sm">{t('settingsUi.about.poweredBy')}</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 md:gap-3">
|
||||
<a href="https://github.com/nostr-protocol/nostr" target="_blank" rel="noopener noreferrer"
|
||||
className="text-[#ccff00] hover:text-[#ccff00]/80 font-black text-lg md:text-xl uppercase">
|
||||
Nostr
|
||||
</a>
|
||||
<span className="text-gray-600">•</span>
|
||||
<span className="text-[#ff0040] font-black text-lg md:text-xl uppercase">Decentralized</span>
|
||||
<span className="text-[#ff0040] font-black text-lg md:text-xl uppercase">{t('settingsUi.about.decentralized')}</span>
|
||||
<span className="text-gray-600">•</span>
|
||||
<span className="text-orange-400 font-black text-lg md:text-xl uppercase">Community-Owned</span>
|
||||
<span className="text-orange-400 font-black text-lg md:text-xl uppercase">{t('settingsUi.about.communityOwned')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import { useAdminModeration } from '@/hooks/useAdminModeration';
|
|||
import { useNotASpotReport } from '@/hooks/useNotASpotReport';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { isAdminPubkey } from '@/lib/admins';
|
||||
import { contentFlags } from '@/lib/spotQuality';
|
||||
import {
|
||||
|
|
@ -75,15 +76,15 @@ import NotFound from '@/pages/NotFound';
|
|||
|
||||
type QueueKind = 'reports' | 'spots' | 'shops' | 'events' | 'tours' | 'gear' | 'crews' | 'lab';
|
||||
|
||||
const TABS: Array<{ id: QueueKind; label: string }> = [
|
||||
{ id: 'reports', label: 'Reports' },
|
||||
{ id: 'spots', label: 'Spots' },
|
||||
{ id: 'shops', label: 'Shops' },
|
||||
{ id: 'events', label: 'Events' },
|
||||
{ id: 'tours', label: 'Tours' },
|
||||
{ id: 'gear', label: 'Gear' },
|
||||
{ id: 'crews', label: 'Crews' },
|
||||
{ id: 'lab', label: 'Lab' },
|
||||
const TABS: Array<{ id: QueueKind; labelKey: string }> = [
|
||||
{ id: 'reports', labelKey: 'walletUi.admin.tabReports' },
|
||||
{ id: 'spots', labelKey: 'walletUi.admin.tabSpots' },
|
||||
{ id: 'shops', labelKey: 'walletUi.admin.tabShops' },
|
||||
{ id: 'events', labelKey: 'walletUi.admin.tabEvents' },
|
||||
{ id: 'tours', labelKey: 'walletUi.admin.tabTours' },
|
||||
{ id: 'gear', labelKey: 'walletUi.admin.tabGear' },
|
||||
{ id: 'crews', labelKey: 'walletUi.admin.tabCrews' },
|
||||
{ id: 'lab', labelKey: 'walletUi.admin.tabLab' },
|
||||
];
|
||||
|
||||
/** One reviewable item, flattened from whatever kind it came from. */
|
||||
|
|
@ -117,6 +118,7 @@ function QueueList({
|
|||
confirmHide?: boolean;
|
||||
confirmHideNote?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { hideContent, isPending: hiding } = useAdminHide();
|
||||
const { confirmContent, isPending: confirming } = useAdminVerify();
|
||||
const { clearVerdict, isPending: clearing } = useAdminClear();
|
||||
|
|
@ -209,7 +211,7 @@ function QueueList({
|
|||
<CardContent className="space-y-3 py-12 text-center">
|
||||
<CheckCircle2 className="mx-auto h-8 w-8 text-emerald-500" />
|
||||
<p className="text-sm text-gray-400">
|
||||
Nothing to review — nothing of this kind trips the junk signals.
|
||||
{t('walletUi.admin.nothingToReview')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -253,28 +255,27 @@ function QueueList({
|
|||
onClick={() => setShowActioned((v) => !v)}
|
||||
>
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
{showActioned ? 'Hide actioned' : `Show actioned (${actioned.length})`}
|
||||
{showActioned ? t('walletUi.admin.hideActioned') : t('walletUi.admin.showActioned', { count: actioned.length })}
|
||||
</Button>
|
||||
{actioned.length > 1 && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="outline" className="gap-1.5 border-white/20" disabled={bulkBusy}>
|
||||
{bulkBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Undo2 className="h-3.5 w-3.5" />}
|
||||
Undo all {actioned.length}
|
||||
{t('walletUi.admin.undoAll', { count: actioned.length })}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Undo every verdict in this tab?</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('walletUi.admin.undoAllTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This sets each item back to neutral — not hidden, not vouched. It publishes one
|
||||
public label per item, so with Amber you'll get {actioned.length} approval screens.
|
||||
{t('walletUi.admin.undoAllDesc', { count: actioned.length })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => void undoAll()}>
|
||||
Undo {actioned.length}
|
||||
{t('walletUi.admin.undoCount', { count: actioned.length })}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
@ -319,13 +320,13 @@ function QueueList({
|
|||
{hidden && (
|
||||
<Badge className="border border-red-500/50 bg-red-500/20 text-[10px] font-black uppercase tracking-wide text-red-300">
|
||||
<EyeOff className="mr-1 h-3 w-3" />
|
||||
Hidden
|
||||
{t('walletUi.admin.hidden')}
|
||||
</Badge>
|
||||
)}
|
||||
{verified && (
|
||||
<Badge className="border border-emerald-500/50 bg-emerald-500/20 text-[10px] font-black uppercase tracking-wide text-emerald-300">
|
||||
<BadgeCheck className="mr-1 h-3 w-3" />
|
||||
Confirmed
|
||||
{t('walletUi.admin.confirmed')}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="truncate">{row.title || '(untitled)'}</span>
|
||||
|
|
@ -401,7 +402,7 @@ function QueueList({
|
|||
}
|
||||
>
|
||||
{clearing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Undo2 className="h-3.5 w-3.5" />}
|
||||
Undo
|
||||
{t('walletUi.admin.undo')}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -418,7 +419,7 @@ function QueueList({
|
|||
}
|
||||
>
|
||||
{confirming ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <BadgeCheck className="h-3.5 w-3.5" />}
|
||||
Confirm
|
||||
{t('walletUi.admin.confirm')}
|
||||
</Button>
|
||||
{confirmHide ? (
|
||||
<AlertDialog>
|
||||
|
|
@ -430,29 +431,29 @@ function QueueList({
|
|||
disabled={hiding}
|
||||
>
|
||||
{hiding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <EyeOff className="h-3.5 w-3.5" />}
|
||||
Hide
|
||||
{t('walletUi.admin.hide')}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Hide this for everyone?</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('walletUi.admin.hideForEveryone')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{confirmHideNote ??
|
||||
'This removes the item from browse and search for every SkateSpots user. It is a public, reversible label — you can Undo it from this queue.'}
|
||||
t('walletUi.admin.hideForEveryoneDesc')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() =>
|
||||
void act(
|
||||
() => hideContent({ coord, id: row.id, reason: `auto-flagged: ${flags.join(', ')}` }),
|
||||
{ title: 'Hidden', description: 'Removed from the app via a public label. Reversible.' },
|
||||
'Could not hide',
|
||||
{ title: t('walletUi.admin.hidden'), description: t('walletUi.admin.hiddenDesc') },
|
||||
t('walletUi.admin.couldNotHide'),
|
||||
)
|
||||
}
|
||||
>
|
||||
Hide it
|
||||
{t('walletUi.admin.hideIt')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
@ -466,13 +467,13 @@ function QueueList({
|
|||
onClick={() =>
|
||||
void act(
|
||||
() => hideContent({ coord, id: row.id, reason: `auto-flagged: ${flags.join(', ')}` }),
|
||||
{ title: 'Hidden', description: 'Removed from the app via a public label. Reversible.' },
|
||||
'Could not hide',
|
||||
{ title: t('walletUi.admin.hidden'), description: t('walletUi.admin.hiddenDesc') },
|
||||
t('walletUi.admin.couldNotHide'),
|
||||
)
|
||||
}
|
||||
>
|
||||
{hiding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <EyeOff className="h-3.5 w-3.5" />}
|
||||
Hide
|
||||
{t('walletUi.admin.hide')}
|
||||
</Button>
|
||||
)}
|
||||
{row.kind === SKATE_SPOT_KIND && (
|
||||
|
|
@ -485,7 +486,7 @@ function QueueList({
|
|||
}
|
||||
>
|
||||
<Flag className="h-3.5 w-3.5" />
|
||||
Not a skate spot
|
||||
{t('walletUi.admin.notASpot')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -493,7 +494,7 @@ function QueueList({
|
|||
<Button asChild size="sm" variant="ghost" className="gap-1.5 text-gray-400">
|
||||
<Link to={row.href}>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
Look at it
|
||||
{t('walletUi.admin.lookAtIt')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -654,6 +655,7 @@ function LabQueue() {
|
|||
}
|
||||
|
||||
export default function AdminReviewPage() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const [kind, setKind] = useState<QueueKind>('reports');
|
||||
|
||||
|
|
@ -666,25 +668,25 @@ export default function AdminReviewPage() {
|
|||
<div className="container mx-auto max-w-3xl px-4 py-8 pb-24 md:pb-14">
|
||||
<PageHeader
|
||||
icon={ShieldAlert}
|
||||
title="Review queue"
|
||||
title={t('walletUi.admin.title')}
|
||||
accent="#ff6600"
|
||||
subtitle="Spots, shops, events, tours and gear that trip the junk signals. Hide and Confirm are soft, reversible labels; reports rank first. Nothing is deleted."
|
||||
subtitle={t('walletUi.admin.subtitle')}
|
||||
/>
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-1.5">
|
||||
{TABS.map((t) => (
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={t.id}
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setKind(t.id)}
|
||||
onClick={() => setKind(tab.id)}
|
||||
className={
|
||||
kind === t.id
|
||||
kind === tab.id
|
||||
? 'rounded-full border border-[#ff6600]/60 bg-[#ff6600]/15 px-3 py-1.5 text-[11px] font-black uppercase tracking-wide text-[#ff6600]'
|
||||
: 'rounded-full border border-white/12 px-3 py-1.5 text-[11px] font-bold uppercase tracking-wide text-gray-400 hover:text-white'
|
||||
}
|
||||
aria-pressed={kind === t.id}
|
||||
aria-pressed={kind === tab.id}
|
||||
>
|
||||
{t.label}
|
||||
{t(tab.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
} from 'lucide-react';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
|
||||
|
||||
/** Where "request an item" goes: an encrypted DM straight to the shop owner. */
|
||||
|
|
@ -41,35 +42,36 @@ const REQUEST_HREF = `/messages/${SHOP_OWNER_NPUB}`;
|
|||
*/
|
||||
const FREE_PRINT = [
|
||||
{
|
||||
title: 'Sticker',
|
||||
titleKey: 'walletUi.appShop.sticker',
|
||||
size: '55 × 86 mm',
|
||||
tip: 'Black die-cut. Print on sticker paper, cut along the black edge.',
|
||||
tipKey: 'walletUi.appShop.stickerTip',
|
||||
file: '/images/print/skatespots-sticker.svg',
|
||||
},
|
||||
{
|
||||
title: 'Poster',
|
||||
titleKey: 'walletUi.appShop.poster',
|
||||
size: 'A3 · 297 × 420 mm',
|
||||
tip: 'Prints on A3; shrink to A4 (50%) and it still scans fine.',
|
||||
tipKey: 'walletUi.appShop.posterTip',
|
||||
file: '/images/print/skatespots-poster.svg',
|
||||
},
|
||||
];
|
||||
|
||||
/** What we're working on. Not products, not prices — just intent. */
|
||||
const PLANNED = [
|
||||
{ icon: Shirt, label: 'Tees & hoodies' },
|
||||
{ icon: Package, label: 'Decks' },
|
||||
{ icon: Shirt, labelKey: 'walletUi.appShop.tees' },
|
||||
{ icon: Package, labelKey: 'walletUi.appShop.decks' },
|
||||
// 'Sticker packs' is rendered separately in the grid, because it is the one
|
||||
// entry that already contains something real: the free printables.
|
||||
{ icon: Hammer, label: 'Shop tools' },
|
||||
{ icon: Hammer, labelKey: 'walletUi.appShop.shopTools' },
|
||||
];
|
||||
|
||||
const PAY_CHIPS = [
|
||||
{ icon: Zap, label: 'Lightning' },
|
||||
{ icon: Coins, label: 'Cashu' },
|
||||
{ icon: MapPin, label: 'Local handover' },
|
||||
{ icon: Zap, labelKey: 'walletUi.appShop.lightning' },
|
||||
{ icon: Coins, labelKey: 'walletUi.appShop.cashu' },
|
||||
{ icon: MapPin, labelKey: 'walletUi.appShop.localHandover' },
|
||||
];
|
||||
|
||||
export default function AppShopPage() {
|
||||
const { t } = useTranslation();
|
||||
useSeoMeta({
|
||||
title: 'App Shop — SkateSpots',
|
||||
description:
|
||||
|
|
@ -80,9 +82,9 @@ export default function AppShopPage() {
|
|||
<div className="container mx-auto max-w-4xl px-4 py-8 pb-24 md:pb-14">
|
||||
<PageHeader
|
||||
icon={ShoppingBag}
|
||||
title="App Shop"
|
||||
title={t('walletUi.appShop.title')}
|
||||
accent="#ccff00"
|
||||
subtitle="SkateSpots merch for the people who keep the scene rolling."
|
||||
subtitle={t('walletUi.appShop.subtitle')}
|
||||
/>
|
||||
|
||||
{/* ── Hero ─────────────────────────────────────────────────────────── */}
|
||||
|
|
@ -93,7 +95,7 @@ export default function AppShopPage() {
|
|||
<div className="relative">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-[#ccff00]/40 bg-[#ccff00]/10 px-3 py-1 text-[10px] font-black uppercase tracking-widest text-[#ccff00]">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
Coming soon
|
||||
{t('walletUi.appShop.comingSoon')}
|
||||
</span>
|
||||
|
||||
<h2
|
||||
|
|
@ -101,19 +103,16 @@ export default function AppShopPage() {
|
|||
style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}
|
||||
>
|
||||
<span className="bg-gradient-to-r from-[#ff0040] via-[#ff6600] to-[#ccff00] bg-clip-text text-transparent">
|
||||
Wear the
|
||||
{t('walletUi.appShop.wearThe')}
|
||||
</span>
|
||||
<br />
|
||||
<span className="text-white">Scene</span>
|
||||
<span className="text-white">{t('walletUi.appShop.scene')}</span>
|
||||
</h2>
|
||||
|
||||
<p className="mt-4 max-w-xl text-sm leading-relaxed text-gray-300 sm:text-base">
|
||||
Merch for the people who keep this thing rolling — sold{' '}
|
||||
<strong className="font-bold text-white">locally, on request</strong>. You message
|
||||
me, we sort the details, and you pay in{' '}
|
||||
<strong className="font-bold text-[#f7931a]">sats</strong> — Lightning or Cashu —
|
||||
when you collect it. No cards, no accounts, no checkout robot, no middleman
|
||||
skimming the drop.
|
||||
{t('walletUi.appShop.heroDesc1')}{' '}
|
||||
<strong className="font-bold text-white">{t('walletUi.appShop.locally')}</strong>{t('walletUi.appShop.heroDesc2')}{' '}
|
||||
<strong className="font-bold text-[#f7931a]">sats</strong>{t('walletUi.appShop.heroDesc3')}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center gap-3">
|
||||
|
|
@ -123,22 +122,22 @@ export default function AppShopPage() {
|
|||
>
|
||||
<Link to={REQUEST_HREF}>
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
Request an item
|
||||
{t('walletUi.appShop.requestItem')}
|
||||
</Link>
|
||||
</Button>
|
||||
<span className="text-[11px] text-gray-500">
|
||||
Opens a private, encrypted DM with me.
|
||||
{t('walletUi.appShop.dmHint')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{PAY_CHIPS.map(({ icon: Icon, label }) => (
|
||||
{PAY_CHIPS.map(({ icon: Icon, labelKey }) => (
|
||||
<span
|
||||
key={label}
|
||||
key={labelKey}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-white/12 bg-white/[0.04] px-3 py-1.5 text-[11px] font-bold text-gray-300 backdrop-blur-sm"
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 text-[#ccff00]" />
|
||||
{label}
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -149,18 +148,18 @@ export default function AppShopPage() {
|
|||
<section className="mt-10">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-white">
|
||||
In the works
|
||||
{t('walletUi.appShop.inTheWorks')}
|
||||
</h3>
|
||||
<div className="h-px flex-1 bg-white/10" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-500">
|
||||
not for sale yet
|
||||
{t('walletUi.appShop.notForSale')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{PLANNED.slice(0, 2).map(({ icon: Icon, label }) => (
|
||||
{PLANNED.slice(0, 2).map(({ icon: Icon, labelKey }) => (
|
||||
<div
|
||||
key={label}
|
||||
key={labelKey}
|
||||
className="group relative overflow-hidden rounded-2xl border-2 border-dashed border-white/12 bg-white/[0.02] p-4 transition-colors hover:border-[#ccff00]/30"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
|
|
@ -168,11 +167,11 @@ export default function AppShopPage() {
|
|||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<p className="min-w-0 self-center font-black uppercase tracking-wide text-white">
|
||||
{label}
|
||||
{t(labelKey)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="absolute right-3 top-3 rounded-full border border-white/12 px-2 py-0.5 text-[9px] font-black uppercase tracking-widest text-gray-500">
|
||||
soon
|
||||
{t('walletUi.appShop.soon')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -186,39 +185,39 @@ export default function AppShopPage() {
|
|||
<Sticker className="h-5 w-5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-black uppercase tracking-wide text-white">Sticker packs</p>
|
||||
<p className="font-black uppercase tracking-wide text-white">{t('walletUi.appShop.stickerPacks')}</p>
|
||||
<p className="mt-0.5 text-xs leading-relaxed text-gray-400">
|
||||
The packs are coming. The designs are not — print these two right now, free.
|
||||
{t('walletUi.appShop.stickerPacksDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-[#ccff00] px-2 py-0.5 text-[9px] font-black uppercase tracking-widest text-black">
|
||||
Free
|
||||
{t('common.free')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
{FREE_PRINT.map((item) => (
|
||||
<div
|
||||
key={item.title}
|
||||
key={item.titleKey}
|
||||
className="overflow-hidden rounded-xl border border-white/10 bg-black/40"
|
||||
>
|
||||
<div className="flex h-36 items-center justify-center p-3">
|
||||
<img
|
||||
src={item.file}
|
||||
alt={`SkateSpots ${item.title} — printable`}
|
||||
alt={t('walletUi.appShop.printableAlt', { title: t(item.titleKey) })}
|
||||
className="max-h-full max-w-full drop-shadow-[0_4px_14px_rgba(0,0,0,0.6)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-white/8 p-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="font-bold uppercase tracking-wide text-white">{item.title}</p>
|
||||
<p className="font-bold uppercase tracking-wide text-white">{t(item.titleKey)}</p>
|
||||
<span className="shrink-0 text-[10px] font-bold uppercase tracking-widest text-gray-500">
|
||||
{item.size}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 flex items-start gap-1.5 text-[11px] leading-relaxed text-gray-500">
|
||||
<Scissors className="mt-0.5 h-3 w-3 shrink-0 text-gray-600" />
|
||||
{item.tip}
|
||||
{t(item.tipKey)}
|
||||
</p>
|
||||
<div className="mt-2.5 flex flex-wrap gap-2">
|
||||
<Button
|
||||
|
|
@ -227,12 +226,12 @@ export default function AppShopPage() {
|
|||
className="gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||
>
|
||||
<a href={item.file} target="_blank" rel="noopener noreferrer">
|
||||
<Printer className="h-3.5 w-3.5" /> Print
|
||||
<Printer className="h-3.5 w-3.5" /> {t('walletUi.appShop.print')}
|
||||
</a>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="outline" className="gap-1.5 border-white/15">
|
||||
<a href={item.file} download>
|
||||
<Download className="h-3.5 w-3.5" /> Save
|
||||
<Download className="h-3.5 w-3.5" /> {t('common.save')}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -242,15 +241,13 @@ export default function AppShopPage() {
|
|||
</div>
|
||||
|
||||
<p className="mt-3 text-[11px] leading-relaxed text-gray-600">
|
||||
Print at <strong className="text-gray-400">100% / “Actual size”</strong> so the size is
|
||||
exact and the QR stays sharp — it points at skate-spots.org. Vector files, so they
|
||||
scale without going fuzzy.
|
||||
{t('walletUi.appShop.printNote1')} <strong className="text-gray-400">{t('walletUi.appShop.actualSize')}</strong> {t('walletUi.appShop.printNote2')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{PLANNED.slice(2).map(({ icon: Icon, label }) => (
|
||||
{PLANNED.slice(2).map(({ icon: Icon, labelKey }) => (
|
||||
<div
|
||||
key={label}
|
||||
key={labelKey}
|
||||
className="group relative overflow-hidden rounded-2xl border-2 border-dashed border-white/12 bg-white/[0.02] p-4 transition-colors hover:border-[#ccff00]/30"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
|
|
@ -258,11 +255,11 @@ export default function AppShopPage() {
|
|||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<p className="min-w-0 self-center font-black uppercase tracking-wide text-white">
|
||||
{label}
|
||||
{t(labelKey)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="absolute right-3 top-3 rounded-full border border-white/12 px-2 py-0.5 text-[9px] font-black uppercase tracking-widest text-gray-500">
|
||||
soon
|
||||
{t('walletUi.appShop.soon')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -273,11 +270,11 @@ export default function AppShopPage() {
|
|||
<section className="mt-10">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-white">
|
||||
How buying works
|
||||
{t('walletUi.appShop.howBuying')}
|
||||
</h3>
|
||||
<div className="h-px flex-1 bg-white/10" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-500">
|
||||
it's a DM, not a cart
|
||||
{t('walletUi.appShop.dmNotCart')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -285,18 +282,18 @@ export default function AppShopPage() {
|
|||
{[
|
||||
{
|
||||
n: '1',
|
||||
t: 'Message me',
|
||||
d: 'Send a DM with what you want — item, size, colour, whatever you are after.',
|
||||
tk: 'walletUi.appShop.step1Title',
|
||||
dk: 'walletUi.appShop.step1Desc',
|
||||
},
|
||||
{
|
||||
n: '2',
|
||||
t: 'We sort it out',
|
||||
d: 'Price in sats, and where and when to meet. It is a conversation, not a checkout.',
|
||||
tk: 'walletUi.appShop.step2Title',
|
||||
dk: 'walletUi.appShop.step2Desc',
|
||||
},
|
||||
{
|
||||
n: '3',
|
||||
t: 'Pay in sats',
|
||||
d: 'Lightning or Cashu at handover, then it is yours. No shipping, no waiting rooms.',
|
||||
tk: 'walletUi.appShop.step3Title',
|
||||
dk: 'walletUi.appShop.step3Desc',
|
||||
},
|
||||
].map((s) => (
|
||||
<li
|
||||
|
|
@ -309,8 +306,8 @@ export default function AppShopPage() {
|
|||
>
|
||||
{s.n}
|
||||
</span>
|
||||
<p className="font-black uppercase tracking-wide text-white">{s.t}</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-gray-400">{s.d}</p>
|
||||
<p className="font-black uppercase tracking-wide text-white">{t(s.tk)}</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-gray-400">{t(s.dk)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
|||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useSkateShops } from '@/hooks/useSkateShops';
|
||||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||
|
|
@ -31,6 +32,7 @@ function LeaderboardEntry({
|
|||
type: 'shops' | 'spots' | 'total';
|
||||
isCurrentUser: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = metadata?.name ?? genUserName(pubkey);
|
||||
|
|
@ -52,9 +54,9 @@ function LeaderboardEntry({
|
|||
};
|
||||
|
||||
const getTypeLabel = () => {
|
||||
if (type === 'shops') return 'shops';
|
||||
if (type === 'spots') return 'spots';
|
||||
return 'contributions';
|
||||
if (type === 'shops') return t('walletUi.leaderboard.shopsLower');
|
||||
if (type === 'spots') return t('walletUi.leaderboard.spotsLower');
|
||||
return t('walletUi.leaderboard.contributions');
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -84,7 +86,7 @@ function LeaderboardEntry({
|
|||
{displayName}
|
||||
</p>
|
||||
{isCurrentUser && (
|
||||
<Badge className="bg-[#ff0040] text-white text-[10px] px-1.5 py-0">You</Badge>
|
||||
<Badge className="bg-[#ff0040] text-white text-[10px] px-1.5 py-0">{t('walletUi.leaderboard.you')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 capitalize">
|
||||
|
|
@ -120,6 +122,7 @@ function LeaderboardSkeleton() {
|
|||
}
|
||||
|
||||
export default function BountyLeaderboardPage() {
|
||||
const { t } = useTranslation();
|
||||
useSeoMeta({
|
||||
title: 'Map & Earn - SkateSpots',
|
||||
description: 'Map skate spots and shops, earn zaps from the community. See the top contributors.',
|
||||
|
|
@ -204,13 +207,13 @@ export default function BountyLeaderboardPage() {
|
|||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-[#ccff00]/10 border border-[#ccff00]/30 mb-4">
|
||||
<Zap className="h-5 w-5 text-[#ccff00]" />
|
||||
<span className="text-[#ccff00] font-bold uppercase text-sm">Map & Earn</span>
|
||||
<span className="text-[#ccff00] font-bold uppercase text-sm">{t('walletUi.leaderboard.mapEarn')}</span>
|
||||
</div>
|
||||
<h1 className="text-3xl sm:text-4xl font-black uppercase mb-3">
|
||||
<span className="neon-lime">HELP MAP THE WORLD</span>
|
||||
<span className="neon-lime">{t('walletUi.leaderboard.helpMap')}</span>
|
||||
</h1>
|
||||
<p className="text-gray-400 max-w-lg mx-auto">
|
||||
Add spots and shops, get zapped by grateful skaters. Top contributors may receive bonus rewards!
|
||||
{t('walletUi.leaderboard.intro')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -219,53 +222,53 @@ export default function BountyLeaderboardPage() {
|
|||
<CardContent className="p-6">
|
||||
<h2 className="text-lg font-black uppercase mb-4 flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-[#ccff00]" />
|
||||
How It Works
|
||||
{t('walletUi.leaderboard.howItWorks')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="p-4 rounded-xl bg-black/40 border border-gray-800">
|
||||
<div className="w-10 h-10 rounded-lg bg-[#ff0040]/20 flex items-center justify-center mb-3">
|
||||
<MapPin className="h-5 w-5 text-[#ff0040]" />
|
||||
</div>
|
||||
<h3 className="font-bold text-white mb-1">1. Map</h3>
|
||||
<h3 className="font-bold text-white mb-1">{t('walletUi.leaderboard.step1Title')}</h3>
|
||||
<p className="text-xs text-gray-400">
|
||||
Add spots & shops with photos and details
|
||||
{t('walletUi.leaderboard.step1Desc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-black/40 border border-gray-800">
|
||||
<div className="w-10 h-10 rounded-lg bg-[#ccff00]/20 flex items-center justify-center mb-3">
|
||||
<Zap className="h-5 w-5 text-[#ccff00]" />
|
||||
</div>
|
||||
<h3 className="font-bold text-white mb-1">2. Earn Zaps</h3>
|
||||
<h3 className="font-bold text-white mb-1">{t('walletUi.leaderboard.step2Title')}</h3>
|
||||
<p className="text-xs text-gray-400">
|
||||
Skaters who find your spots useful will tip you
|
||||
{t('walletUi.leaderboard.step2Desc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-black/40 border border-gray-800">
|
||||
<div className="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center mb-3">
|
||||
<Trophy className="h-5 w-5 text-orange-400" />
|
||||
</div>
|
||||
<h3 className="font-bold text-white mb-1">3. Bonus Rewards</h3>
|
||||
<h3 className="font-bold text-white mb-1">{t('walletUi.leaderboard.step3Title')}</h3>
|
||||
<p className="text-xs text-gray-400">
|
||||
Top mappers may get bonus rewards from our fund
|
||||
{t('walletUi.leaderboard.step3Desc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality Tips */}
|
||||
<div className="mt-4 p-4 rounded-xl bg-black/40 border border-[#ccff00]/20">
|
||||
<p className="text-sm font-bold text-[#ccff00] mb-2">💡 Pro Tips for More Zaps:</p>
|
||||
<p className="text-sm font-bold text-[#ccff00] mb-2">{t('walletUi.leaderboard.proTips')}</p>
|
||||
<div className="flex flex-wrap gap-3 text-xs text-gray-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Camera className="h-3 w-3" />
|
||||
Add 3+ photos
|
||||
{t('walletUi.leaderboard.tipPhotos')}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<FileText className="h-3 w-3" />
|
||||
Write detailed descriptions
|
||||
{t('walletUi.leaderboard.tipDescriptions')}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
Accurate location
|
||||
{t('walletUi.leaderboard.tipLocation')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -278,8 +281,8 @@ export default function BountyLeaderboardPage() {
|
|||
<Card className="h-full bg-[#ccff00]/10 border-2 border-[#ccff00]/30 hover:border-[#ccff00]/60 transition-all hover:scale-[1.02] cursor-pointer">
|
||||
<CardContent className="p-4 sm:p-6 text-center">
|
||||
<Store className="h-8 w-8 mx-auto mb-2 text-[#ccff00]" />
|
||||
<h3 className="font-black uppercase text-sm sm:text-base mb-1">Map a Shop</h3>
|
||||
<p className="text-xs text-gray-400 hidden sm:block">Skate shops, board stores</p>
|
||||
<h3 className="font-black uppercase text-sm sm:text-base mb-1">{t('walletUi.leaderboard.mapShop')}</h3>
|
||||
<p className="text-xs text-gray-400 hidden sm:block">{t('walletUi.leaderboard.mapShopDesc')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
|
@ -287,8 +290,8 @@ export default function BountyLeaderboardPage() {
|
|||
<Card className="h-full bg-[#ff0040]/10 border-2 border-[#ff0040]/30 hover:border-[#ff0040]/60 transition-all hover:scale-[1.02] cursor-pointer">
|
||||
<CardContent className="p-4 sm:p-6 text-center">
|
||||
<MapPin className="h-8 w-8 mx-auto mb-2 text-[#ff0040]" />
|
||||
<h3 className="font-black uppercase text-sm sm:text-base mb-1">Map a Spot</h3>
|
||||
<p className="text-xs text-gray-400 hidden sm:block">Parks, street spots, DIY</p>
|
||||
<h3 className="font-black uppercase text-sm sm:text-base mb-1">{t('walletUi.leaderboard.mapSpot')}</h3>
|
||||
<p className="text-xs text-gray-400 hidden sm:block">{t('walletUi.leaderboard.mapSpotDesc')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
|
@ -300,21 +303,21 @@ export default function BountyLeaderboardPage() {
|
|||
<CardContent className="p-3 sm:p-4 text-center">
|
||||
<Store className="h-5 w-5 mx-auto mb-1 text-[#ccff00]" />
|
||||
<p className="text-xl sm:text-2xl font-black text-white">{totalShops}</p>
|
||||
<p className="text-[10px] sm:text-xs text-gray-400 uppercase">Shops</p>
|
||||
<p className="text-[10px] sm:text-xs text-gray-400 uppercase">{t('walletUi.leaderboard.shops')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[#ff0040]/10 border-[#ff0040]/30">
|
||||
<CardContent className="p-3 sm:p-4 text-center">
|
||||
<MapPin className="h-5 w-5 mx-auto mb-1 text-[#ff0040]" />
|
||||
<p className="text-xl sm:text-2xl font-black text-white">{totalSpots}</p>
|
||||
<p className="text-[10px] sm:text-xs text-gray-400 uppercase">Spots</p>
|
||||
<p className="text-[10px] sm:text-xs text-gray-400 uppercase">{t('walletUi.stats.spots')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-orange-500/10 border-orange-500/30">
|
||||
<CardContent className="p-3 sm:p-4 text-center">
|
||||
<Users className="h-5 w-5 mx-auto mb-1 text-orange-400" />
|
||||
<p className="text-xl sm:text-2xl font-black text-white">{totalMappers}</p>
|
||||
<p className="text-[10px] sm:text-xs text-gray-400 uppercase">Mappers</p>
|
||||
<p className="text-[10px] sm:text-xs text-gray-400 uppercase">{t('walletUi.stats.mappers')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
@ -329,15 +332,15 @@ export default function BountyLeaderboardPage() {
|
|||
<span className="text-lg font-black text-[#ff0040]">#{userCombinedRank}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-white">Your Ranking</p>
|
||||
<p className="font-bold text-white">{t('walletUi.leaderboard.yourRanking')}</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
{userContributions.spots} spots • {userContributions.shops} shops
|
||||
{t('walletUi.leaderboard.spotsShops', { spots: userContributions.spots, shops: userContributions.shops })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-black text-[#ccff00]">{userContributions.total}</p>
|
||||
<p className="text-xs text-gray-500">total</p>
|
||||
<p className="text-xs text-gray-500">{t('walletUi.leaderboard.totalLower')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -348,7 +351,7 @@ export default function BountyLeaderboardPage() {
|
|||
<div className="mb-4">
|
||||
<h2 className="text-xl font-black uppercase flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5 text-yellow-400" />
|
||||
Top Mappers
|
||||
{t('walletUi.stats.topMappers')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
|
|
@ -356,15 +359,15 @@ export default function BountyLeaderboardPage() {
|
|||
<TabsList className="grid w-full grid-cols-3 bg-black/50 border border-gray-800">
|
||||
<TabsTrigger value="combined" className="gap-1.5 text-xs sm:text-sm">
|
||||
<Trophy className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Overall</span>
|
||||
<span className="hidden sm:inline">{t('walletUi.leaderboard.overall')}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="shops" className="gap-1.5 text-xs sm:text-sm">
|
||||
<Store className="h-4 w-4" />
|
||||
Shops
|
||||
{t('walletUi.leaderboard.shops')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="spots" className="gap-1.5 text-xs sm:text-sm">
|
||||
<MapPin className="h-4 w-4" />
|
||||
Spots
|
||||
{t('walletUi.stats.spots')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
|
@ -388,10 +391,10 @@ export default function BountyLeaderboardPage() {
|
|||
<Card className="border-dashed border-2 border-gray-700">
|
||||
<CardContent className="py-12 text-center">
|
||||
<Trophy className="h-12 w-12 mx-auto mb-4 text-gray-600" />
|
||||
<p className="text-gray-400 mb-4">No contributions yet. Be the first!</p>
|
||||
<p className="text-gray-400 mb-4">{t('walletUi.leaderboard.noContributions')}</p>
|
||||
<Link to="/create-spot">
|
||||
<Button className="bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold">
|
||||
Add First Spot
|
||||
{t('walletUi.leaderboard.addFirstSpot')}
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
|
|
@ -419,10 +422,10 @@ export default function BountyLeaderboardPage() {
|
|||
<Card className="border-dashed border-2 border-gray-700">
|
||||
<CardContent className="py-12 text-center">
|
||||
<Store className="h-12 w-12 mx-auto mb-4 text-gray-600" />
|
||||
<p className="text-gray-400 mb-4">No shops mapped yet</p>
|
||||
<p className="text-gray-400 mb-4">{t('walletUi.leaderboard.noShops')}</p>
|
||||
<Link to="/create-shop">
|
||||
<Button className="bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold">
|
||||
Add First Shop
|
||||
{t('walletUi.leaderboard.addFirstShop')}
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
|
|
@ -450,10 +453,10 @@ export default function BountyLeaderboardPage() {
|
|||
<Card className="border-dashed border-2 border-gray-700">
|
||||
<CardContent className="py-12 text-center">
|
||||
<MapPin className="h-12 w-12 mx-auto mb-4 text-gray-600" />
|
||||
<p className="text-gray-400 mb-4">No spots mapped yet</p>
|
||||
<p className="text-gray-400 mb-4">{t('walletUi.leaderboard.noSpots')}</p>
|
||||
<Link to="/create-spot">
|
||||
<Button className="bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold">
|
||||
Add First Spot
|
||||
{t('walletUi.leaderboard.addFirstSpot')}
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
|
|
@ -466,9 +469,9 @@ export default function BountyLeaderboardPage() {
|
|||
<Card className="mt-8 bg-gray-900/50 border-gray-800">
|
||||
<CardContent className="p-4 sm:p-6 text-center">
|
||||
<Zap className="h-8 w-8 mx-auto mb-3 text-yellow-400" />
|
||||
<h3 className="font-bold text-white mb-2">Powered by Community Zaps</h3>
|
||||
<h3 className="font-bold text-white mb-2">{t('walletUi.leaderboard.poweredBy')}</h3>
|
||||
<p className="text-sm text-gray-400 max-w-md mx-auto">
|
||||
When you find a spot helpful, zap the mapper! It's the best way to say thanks and encourage more contributions.
|
||||
{t('walletUi.leaderboard.poweredByDesc')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||
import { Badge } from '@/components/ui/badge';
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useRelays } from '@/lib/relays';
|
||||
import { useSpotCollection, useRemoveSpotFromCollection, useDeleteSpotCollection } from '@/hooks/useSpotCollections';
|
||||
|
|
@ -41,6 +42,7 @@ function CollectionDetailSkeleton() {
|
|||
}
|
||||
|
||||
export default function CollectionDetailPage() {
|
||||
const { t } = useTranslation();
|
||||
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
|
||||
const { data: collection, isLoading, error, refetch } = useSpotCollection(identifier || '', pubkey);
|
||||
const { user } = useCurrentUser();
|
||||
|
|
@ -138,13 +140,13 @@ export default function CollectionDetailPage() {
|
|||
spotIdentifier: spot.identifier,
|
||||
});
|
||||
toast({
|
||||
title: 'Spot removed',
|
||||
description: `"${spot.title}" has been removed from this collection`,
|
||||
title: t('walletUi.collection.spotRemoved'),
|
||||
description: t('walletUi.collection.spotRemovedDesc', { title: spot.title }),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to remove spot',
|
||||
description: error instanceof Error ? error.message : 'Something went wrong',
|
||||
title: t('walletUi.collection.removeFailed'),
|
||||
description: error instanceof Error ? error.message : t('walletUi.addToCollection.somethingWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -158,14 +160,14 @@ export default function CollectionDetailPage() {
|
|||
try {
|
||||
await deleteCollection(collection);
|
||||
toast({
|
||||
title: 'Collection deleted',
|
||||
description: `"${collection.title}" has been removed`,
|
||||
title: t('walletUi.collection.deleted'),
|
||||
description: t('walletUi.collection.deletedDesc', { title: collection.title }),
|
||||
});
|
||||
navigate('/collections');
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to delete collection',
|
||||
description: error instanceof Error ? error.message : 'Something went wrong',
|
||||
title: t('walletUi.collection.deleteFailed'),
|
||||
description: error instanceof Error ? error.message : t('walletUi.addToCollection.somethingWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -177,7 +179,7 @@ export default function CollectionDetailPage() {
|
|||
try {
|
||||
await navigator.share({
|
||||
title: collection?.title,
|
||||
text: collection?.description || `Check out this spot collection: ${collection?.title}`,
|
||||
text: collection?.description || t('walletUi.collection.shareText', { title: collection?.title ?? '' }),
|
||||
url: window.location.href,
|
||||
});
|
||||
} catch {
|
||||
|
|
@ -186,8 +188,8 @@ export default function CollectionDetailPage() {
|
|||
} else {
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
toast({
|
||||
title: 'Link copied!',
|
||||
description: 'Collection link has been copied to clipboard',
|
||||
title: t('walletUi.collection.linkCopied'),
|
||||
description: t('walletUi.collection.linkCopiedDesc'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -213,7 +215,7 @@ export default function CollectionDetailPage() {
|
|||
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-4 sm:mb-6 text-sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-1.5 sm:mr-2" />
|
||||
Back to Collections
|
||||
{t('walletUi.collection.back')}
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
|
|
@ -258,11 +260,11 @@ export default function CollectionDetailPage() {
|
|||
<div className="flex flex-wrap items-center gap-2 sm:gap-4 text-xs sm:text-sm text-gray-500">
|
||||
<Badge variant="outline" className="border-[#ccff00]/50 text-[#ccff00]">
|
||||
<MapPin className="h-2.5 w-2.5 sm:h-3 sm:w-3 mr-1" />
|
||||
{collection.spotRefs.length} spot{collection.spotRefs.length !== 1 ? 's' : ''}
|
||||
{t('walletUi.addToCollection.spotCount', { count: collection.spotRefs.length })}
|
||||
</Badge>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
||||
Created {new Date(collection.createdAt * 1000).toLocaleDateString()}
|
||||
{t('walletUi.collection.created', { date: new Date(collection.createdAt * 1000).toLocaleDateString() })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -273,7 +275,7 @@ export default function CollectionDetailPage() {
|
|||
<CardHeader className="p-3 sm:p-6 pb-2 sm:pb-4">
|
||||
<CardTitle className="text-base sm:text-lg flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4 sm:h-5 sm:w-5 text-[#ccff00]" />
|
||||
Collection Map
|
||||
{t('walletUi.collection.map')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3 sm:p-6 pt-0">
|
||||
|
|
@ -326,16 +328,16 @@ export default function CollectionDetailPage() {
|
|||
<Card className="border-dashed border-2 border-gray-700">
|
||||
<CardContent className="py-16 text-center">
|
||||
<MapPin className="h-16 w-16 mx-auto mb-4 text-gray-600" />
|
||||
<h2 className="text-xl font-bold mb-2">No spots in this collection</h2>
|
||||
<h2 className="text-xl font-bold mb-2">{t('walletUi.collection.empty')}</h2>
|
||||
<p className="text-gray-400 mb-6">
|
||||
{isOwner
|
||||
? 'Add spots to your collection by clicking "Save to Collection" on any spot'
|
||||
: 'This collection is empty'}
|
||||
? t('walletUi.collection.emptyOwner')
|
||||
: t('walletUi.collection.emptyOther')}
|
||||
</p>
|
||||
{isOwner && (
|
||||
<Link to="/map">
|
||||
<Button className="bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase">
|
||||
Browse Spots
|
||||
{t('walletUi.collection.browseSpots')}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
|
@ -350,14 +352,14 @@ export default function CollectionDetailPage() {
|
|||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-white flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-red-500" />
|
||||
Delete Collection?
|
||||
{t('walletUi.collection.deleteTitle')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-gray-400 text-sm">
|
||||
This will permanently delete "{collection.title}". The spots themselves won't be affected.
|
||||
{t('walletUi.collection.deleteDesc', { title: collection.title })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="flex-col sm:flex-row gap-2 sm:gap-0">
|
||||
<AlertDialogCancel className="border-gray-700 h-11 sm:h-auto">Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel className="border-gray-700 h-11 sm:h-auto">{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteCollection}
|
||||
disabled={isDeleting}
|
||||
|
|
@ -366,10 +368,10 @@ export default function CollectionDetailPage() {
|
|||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Deleting...
|
||||
{t('walletUi.collection.deleting')}
|
||||
</>
|
||||
) : (
|
||||
'Delete'
|
||||
t('common.delete')
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
|||
import { useGrowthStats } from '@/hooks/useGrowthStats';
|
||||
import { RegionBackfillCard } from '@/components/RegionBackfillCard';
|
||||
import { useDeriveRegions } from '@/hooks/useDeriveRegions';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatAge } from '@/lib/routeCache';
|
||||
|
||||
|
|
@ -76,6 +77,7 @@ function StatCard({
|
|||
/** Abbreviate big numbers (e.g. sats) so they don't overflow the card. */
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const clickable = !!to && !loading && value > 0;
|
||||
const card = (
|
||||
<Card
|
||||
|
|
@ -102,7 +104,7 @@ function StatCard({
|
|||
style={{ background: `${color}22`, color }}
|
||||
>
|
||||
<TrendingUp className="h-2.5 w-2.5" />
|
||||
+{thisMonthDelta} this month
|
||||
{t('walletUi.stats.thisMonth', { count: thisMonthDelta })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -110,14 +112,14 @@ function StatCard({
|
|||
{!loading && trustedValue !== undefined && (
|
||||
<p className="text-xs mt-1.5 flex items-center gap-1" style={{ color: '#555' }}>
|
||||
<span className="inline-flex items-center gap-0.5 text-green-500/80">
|
||||
🛡 {trustedValue.toLocaleString()} from active accounts
|
||||
🛡 {t('walletUi.stats.fromActive', { count: trustedValue.toLocaleString() })}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{sub && <p className="text-xs mt-1.5" style={{ color: '#555' }}>{sub}</p>}
|
||||
{clickable && (
|
||||
<p className="text-[10px] mt-2 font-bold uppercase tracking-wide inline-flex items-center gap-1" style={{ color }}>
|
||||
View all <ExternalLink className="h-2.5 w-2.5" />
|
||||
{t('walletUi.stats.viewAll')} <ExternalLink className="h-2.5 w-2.5" />
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -135,6 +137,7 @@ function StatCard({
|
|||
// ─── Top mapper row ──────────────────────────────────────────────────────────
|
||||
|
||||
function MapperRow({ pubkey, rank, spotCount }: { pubkey: string; rank: number; spotCount: number }) {
|
||||
const { t } = useTranslation();
|
||||
const author = useAuthor(pubkey);
|
||||
const meta = author.data?.metadata;
|
||||
const name = meta?.name ?? genUserName(pubkey);
|
||||
|
|
@ -153,7 +156,7 @@ function MapperRow({ pubkey, rank, spotCount }: { pubkey: string; rank: number;
|
|||
</Avatar>
|
||||
<span className="flex-1 text-sm font-semibold truncate">{name}</span>
|
||||
<span className="text-xs font-bold flex-shrink-0" style={{ color: '#ccff00' }}>
|
||||
{spotCount} {spotCount === 1 ? 'spot' : 'spots'}
|
||||
{t('walletUi.stats.spotCount', { count: spotCount })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -174,6 +177,7 @@ function ChartSkeleton() {
|
|||
// ─── Page ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CommunityStatsPage() {
|
||||
const { t } = useTranslation();
|
||||
useSeoMeta({
|
||||
title: 'Community Stats — SkateSpots',
|
||||
description: 'Live community metrics for SkateSpots — spots, events, coaches, filmers, editors, tours and growth over time. Pulled directly from Nostr.',
|
||||
|
|
@ -191,14 +195,14 @@ export default function CommunityStatsPage() {
|
|||
const contentMix = useMemo(() => {
|
||||
if (!stats) return [];
|
||||
return [
|
||||
{ name: 'Spots', value: stats.totalSpots, color: '#ff0040' },
|
||||
{ name: 'Events', value: stats.totalEvents, color: '#ccff00' },
|
||||
{ name: 'Lab', value: labCraftTotal(stats), color: '#ff6600' },
|
||||
{ name: 'Shops', value: stats.totalShops, color: '#4ade80' },
|
||||
{ name: 'Tours', value: stats.totalTours, color: '#c084fc' },
|
||||
{ name: 'Crews', value: stats.totalCrews, color: '#2dd4bf' },
|
||||
{ name: t('walletUi.stats.spots'), value: stats.totalSpots, color: '#ff0040' },
|
||||
{ name: t('walletUi.stats.events'), value: stats.totalEvents, color: '#ccff00' },
|
||||
{ name: t('walletUi.stats.lab'), value: labCraftTotal(stats), color: '#ff6600' },
|
||||
{ name: t('walletUi.stats.shops'), value: stats.totalShops, color: '#4ade80' },
|
||||
{ name: t('walletUi.stats.tours'), value: stats.totalTours, color: '#c084fc' },
|
||||
{ name: t('walletUi.stats.crews'), value: stats.totalCrews, color: '#2dd4bf' },
|
||||
].filter(d => d.value > 0);
|
||||
}, [stats]);
|
||||
}, [stats, t]);
|
||||
|
||||
// Only show last 12 months on charts to keep them readable
|
||||
const recentMonthly = useMemo(() => stats?.monthly.slice(-12) ?? [], [stats]);
|
||||
|
|
@ -235,15 +239,15 @@ export default function CommunityStatsPage() {
|
|||
<span className="text-white">Stats</span>
|
||||
</h1>
|
||||
<p className="text-gray-400 max-w-xl mx-auto text-sm md:text-base mb-5">
|
||||
Live metrics pulled directly from the Nostr network. Transparent, decentralized, verifiable by anyone.
|
||||
{t('walletUi.stats.subtitle')}
|
||||
</p>
|
||||
{/* Data source badge + freshness + manual refresh */}
|
||||
<div className="inline-flex flex-wrap items-center justify-center gap-2 px-4 py-2 rounded-full bg-[#ccff00]/5 border border-[#ccff00]/20 text-xs text-gray-400">
|
||||
<Radio className="h-3 w-3 text-[#ccff00]" />
|
||||
Data source:
|
||||
{t('walletUi.stats.dataSource')}
|
||||
<span className="text-[#ccff00] font-mono">{SKATE_RELAY.replace('wss://', '')}</span>
|
||||
{dataUpdatedAt > 0 && (
|
||||
<><span aria-hidden>·</span><span>updated {formatAge(Date.now() - dataUpdatedAt)}</span></>
|
||||
<><span aria-hidden>·</span><span>{t('walletUi.stats.updated', { age: formatAge(Date.now() - dataUpdatedAt) })}</span></>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -251,7 +255,7 @@ export default function CommunityStatsPage() {
|
|||
disabled={isFetching}
|
||||
className="ml-1 inline-flex items-center gap-1 rounded-full border border-white/10 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-gray-400 transition-colors hover:text-white disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={cn('h-3 w-3', isFetching && 'animate-spin')} /> Refresh
|
||||
<RefreshCw className={cn('h-3 w-3', isFetching && 'animate-spin')} /> {t('walletUi.stats.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -259,7 +263,7 @@ export default function CommunityStatsPage() {
|
|||
{stats?.partial && (
|
||||
<div className="mt-5 rounded-2xl border border-amber-500/40 bg-amber-500/10 px-4 py-3">
|
||||
<p className="text-xs font-bold text-amber-300">
|
||||
Couldn't reach every relay — these numbers may be <span className="uppercase">incomplete</span>.
|
||||
{t('walletUi.stats.partial')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -268,20 +272,20 @@ export default function CommunityStatsPage() {
|
|||
{/* ── Error state ── */}
|
||||
{isError && (
|
||||
<div className="mb-8 p-4 rounded-xl border border-[#ff0040]/30 bg-[#ff0040]/5 text-center text-sm text-gray-400">
|
||||
Could not reach the relay right now. Stats will load once the connection is restored.
|
||||
{t('walletUi.stats.error')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Section 1: Hero numbers ── */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
|
||||
<TrendingUp className="h-3.5 w-3.5" /> Community at a Glance
|
||||
<TrendingUp className="h-3.5 w-3.5" /> {t('walletUi.stats.atAGlance')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<StatCard icon={MapPin} value={stats?.totalSpots ?? 0} label="Skate Spots" color="#ff0040" loading={isLoading} trustedValue={stats?.trustedSpots} thisMonthDelta={thisMonth?.spots} to="/map" />
|
||||
<StatCard icon={Store} value={stats?.totalShops ?? 0} label="Listed Shops" color="#4ade80" loading={isLoading} thisMonthDelta={thisMonth?.shops} to="/map" />
|
||||
<StatCard icon={Calendar} value={stats?.totalEvents ?? 0} label="Events" color="#ccff00" loading={isLoading} thisMonthDelta={thisMonth?.events} to="/events" />
|
||||
<StatCard icon={Users} value={stats?.totalMappers ?? 0} label="Mappers" color="#facc15" loading={isLoading} trustedValue={stats?.trustedMappers} sub="unique contributors" thisMonthDelta={thisMonth?.activeMappers} to="/bounties" />
|
||||
<StatCard icon={MapPin} value={stats?.totalSpots ?? 0} label={t('walletUi.stats.skateSpots')} color="#ff0040" loading={isLoading} trustedValue={stats?.trustedSpots} thisMonthDelta={thisMonth?.spots} to="/map" />
|
||||
<StatCard icon={Store} value={stats?.totalShops ?? 0} label={t('walletUi.stats.listedShops')} color="#4ade80" loading={isLoading} thisMonthDelta={thisMonth?.shops} to="/map" />
|
||||
<StatCard icon={Calendar} value={stats?.totalEvents ?? 0} label={t('walletUi.stats.events')} color="#ccff00" loading={isLoading} thisMonthDelta={thisMonth?.events} to="/events" />
|
||||
<StatCard icon={Users} value={stats?.totalMappers ?? 0} label={t('walletUi.stats.mappers')} color="#facc15" loading={isLoading} trustedValue={stats?.trustedMappers} sub={t('walletUi.stats.uniqueContributors')} thisMonthDelta={thisMonth?.activeMappers} to="/bounties" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -289,32 +293,32 @@ export default function CommunityStatsPage() {
|
|||
<section className="mb-12">
|
||||
<div className="flex items-center justify-between mb-4 gap-2">
|
||||
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 flex items-center gap-2">
|
||||
<FlaskConical className="h-3.5 w-3.5 text-[#ff6600]" /> The Lab · Creators & Services
|
||||
<FlaskConical className="h-3.5 w-3.5 text-[#ff6600]" /> {t('walletUi.stats.labHeader')}
|
||||
</h2>
|
||||
{!isLoading && labTotal > 0 && (
|
||||
<span className="text-[10px] font-black uppercase tracking-wide px-2 py-0.5 rounded-full bg-[#ff6600]/15 text-[#ff6600]">
|
||||
{labTotal.toLocaleString()} total
|
||||
{t('walletUi.stats.total', { count: labTotal.toLocaleString() })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
<StatCard icon={GraduationCap} value={stats?.totalCoaches ?? 0} label="Coaches" color="#ff6600" loading={isLoading} thisMonthDelta={thisMonth?.coaches} to="/lab?craft=coaching" />
|
||||
<StatCard icon={Video} value={stats?.totalFilmers ?? 0} label="Filmers" color="#60a5fa" loading={isLoading} thisMonthDelta={thisMonth?.filmers} to="/lab?craft=filming" />
|
||||
<StatCard icon={Scissors} value={stats?.totalEditors ?? 0} label="Editors" color="#c084fc" loading={isLoading} thisMonthDelta={thisMonth?.editors} to="/lab?craft=editing" />
|
||||
<StatCard icon={Camera} value={stats?.totalPhotographers ?? 0} label="Photographers" color="#38bdf8" loading={isLoading} thisMonthDelta={thisMonth?.photographers} to="/lab?craft=photography" />
|
||||
<StatCard icon={Hammer} value={stats?.totalBuilders ?? 0} label="Builders" color="#fb923c" loading={isLoading} thisMonthDelta={thisMonth?.builders} to="/lab?craft=building" />
|
||||
<StatCard icon={Palette} value={stats?.totalArtists ?? 0} label="Artists" color="#f472b6" loading={isLoading} to="/lab?craft=art" />
|
||||
<StatCard icon={GraduationCap} value={stats?.totalCoaches ?? 0} label={t('walletUi.stats.coaches')} color="#ff6600" loading={isLoading} thisMonthDelta={thisMonth?.coaches} to="/lab?craft=coaching" />
|
||||
<StatCard icon={Video} value={stats?.totalFilmers ?? 0} label={t('walletUi.stats.filmers')} color="#60a5fa" loading={isLoading} thisMonthDelta={thisMonth?.filmers} to="/lab?craft=filming" />
|
||||
<StatCard icon={Scissors} value={stats?.totalEditors ?? 0} label={t('walletUi.stats.editors')} color="#c084fc" loading={isLoading} thisMonthDelta={thisMonth?.editors} to="/lab?craft=editing" />
|
||||
<StatCard icon={Camera} value={stats?.totalPhotographers ?? 0} label={t('walletUi.stats.photographers')} color="#38bdf8" loading={isLoading} thisMonthDelta={thisMonth?.photographers} to="/lab?craft=photography" />
|
||||
<StatCard icon={Hammer} value={stats?.totalBuilders ?? 0} label={t('walletUi.stats.builders')} color="#fb923c" loading={isLoading} thisMonthDelta={thisMonth?.builders} to="/lab?craft=building" />
|
||||
<StatCard icon={Palette} value={stats?.totalArtists ?? 0} label={t('walletUi.stats.artists')} color="#f472b6" loading={isLoading} to="/lab?craft=art" />
|
||||
{/* Extra crafts: collapsed on mobile (they were ~7 rows of scroll
|
||||
before the first chart), always visible from `lg` up. The
|
||||
`contents` wrapper keeps its children as real grid items. */}
|
||||
<div className={cn(showAllLab ? 'contents' : 'hidden lg:contents')}>
|
||||
<StatCard icon={SprayCan} value={stats?.totalGraffiti ?? 0} label="Graffiti" color="#a3e635" loading={isLoading} to="/lab?craft=graffiti" />
|
||||
<StatCard icon={PenTool} value={stats?.totalTattoo ?? 0} label="Tattoo" color="#f87171" loading={isLoading} to="/lab?craft=tattoo" />
|
||||
<StatCard icon={Dumbbell} value={stats?.totalTrainers ?? 0} label="Trainers" color="#34d399" loading={isLoading} to="/lab?craft=training" />
|
||||
<StatCard icon={Music} value={stats?.totalMusicians ?? 0} label="Musicians" color="#818cf8" loading={isLoading} to="/lab?craft=music" />
|
||||
<StatCard icon={Gavel} value={stats?.totalJudges ?? 0} label="Judges" color="#fbbf24" loading={isLoading} to="/lab?craft=judging" />
|
||||
<StatCard icon={Tent} value={stats?.totalOrganizers ?? 0} label="Organizers" color="#2dd4bf" loading={isLoading} to="/lab?craft=organizing" />
|
||||
<StatCard icon={Sparkles} value={stats?.totalBarbers ?? 0} label="Barbers" color="#22d3ee" loading={isLoading} to="/lab?craft=barbering" />
|
||||
<StatCard icon={SprayCan} value={stats?.totalGraffiti ?? 0} label={t('walletUi.stats.graffiti')} color="#a3e635" loading={isLoading} to="/lab?craft=graffiti" />
|
||||
<StatCard icon={PenTool} value={stats?.totalTattoo ?? 0} label={t('walletUi.stats.tattoo')} color="#f87171" loading={isLoading} to="/lab?craft=tattoo" />
|
||||
<StatCard icon={Dumbbell} value={stats?.totalTrainers ?? 0} label={t('walletUi.stats.trainers')} color="#34d399" loading={isLoading} to="/lab?craft=training" />
|
||||
<StatCard icon={Music} value={stats?.totalMusicians ?? 0} label={t('walletUi.stats.musicians')} color="#818cf8" loading={isLoading} to="/lab?craft=music" />
|
||||
<StatCard icon={Gavel} value={stats?.totalJudges ?? 0} label={t('walletUi.stats.judges')} color="#fbbf24" loading={isLoading} to="/lab?craft=judging" />
|
||||
<StatCard icon={Tent} value={stats?.totalOrganizers ?? 0} label={t('walletUi.stats.organizers')} color="#2dd4bf" loading={isLoading} to="/lab?craft=organizing" />
|
||||
<StatCard icon={Sparkles} value={stats?.totalBarbers ?? 0} label={t('walletUi.stats.barbers')} color="#22d3ee" loading={isLoading} to="/lab?craft=barbering" />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -322,31 +326,31 @@ export default function CommunityStatsPage() {
|
|||
onClick={() => setShowAllLab((v) => !v)}
|
||||
className="mt-3 w-full rounded-full border border-white/10 py-2 text-[11px] font-bold uppercase tracking-wide text-gray-400 transition-colors hover:text-white lg:hidden"
|
||||
>
|
||||
{showAllLab ? 'Show fewer' : 'Show all 13 crafts'}
|
||||
{showAllLab ? t('walletUi.stats.showFewer') : t('walletUi.stats.showAllCrafts')}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* ── Section 1c: Community — tours & crews ── */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
|
||||
<Users2 className="h-3.5 w-3.5 text-[#2dd4bf]" /> Community
|
||||
<Users2 className="h-3.5 w-3.5 text-[#2dd4bf]" /> {t('walletUi.stats.community')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<StatCard icon={Route} value={stats?.totalTours ?? 0} label="Tours" color="#f472b6" loading={isLoading} thisMonthDelta={thisMonth?.tours} to="/map" />
|
||||
<StatCard icon={Users2} value={stats?.totalCrews ?? 0} label="Crews" color="#2dd4bf" loading={isLoading} thisMonthDelta={thisMonth?.crews} to="/crews" />
|
||||
<StatCard icon={Route} value={stats?.totalTours ?? 0} label={t('walletUi.stats.tours')} color="#f472b6" loading={isLoading} thisMonthDelta={thisMonth?.tours} to="/map" />
|
||||
<StatCard icon={Users2} value={stats?.totalCrews ?? 0} label={t('walletUi.stats.crews')} color="#2dd4bf" loading={isLoading} thisMonthDelta={thisMonth?.crews} to="/crews" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Section 1d: Marketplace ── */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
|
||||
<ShoppingBag className="h-3.5 w-3.5 text-[#00cc66]" /> Marketplace
|
||||
<ShoppingBag className="h-3.5 w-3.5 text-[#00cc66]" /> {t('walletUi.stats.marketplace')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<StatCard icon={Tag} value={stats?.totalListings ?? 0} label="Live Listings" color="#00cc66" loading={isLoading} to="/marketplace" />
|
||||
<StatCard icon={Handshake} value={stats?.totalTrades ?? 0} label="Trades Recorded" color="#22c55e" loading={isLoading} to="/trades" />
|
||||
<StatCard icon={Zap} value={stats?.tradedSats ?? 0} label="Sats Traded" color="#facc15" loading={isLoading} compact />
|
||||
<StatCard icon={Zap} value={stats?.zapCount ?? 0} label="Zaps to Mappers" color="#f472b6" loading={isLoading} />
|
||||
<StatCard icon={Tag} value={stats?.totalListings ?? 0} label={t('walletUi.stats.liveListings')} color="#00cc66" loading={isLoading} to="/marketplace" />
|
||||
<StatCard icon={Handshake} value={stats?.totalTrades ?? 0} label={t('walletUi.stats.tradesRecorded')} color="#22c55e" loading={isLoading} to="/trades" />
|
||||
<StatCard icon={Zap} value={stats?.tradedSats ?? 0} label={t('walletUi.stats.satsTraded')} color="#facc15" loading={isLoading} compact />
|
||||
<StatCard icon={Zap} value={stats?.zapCount ?? 0} label={t('walletUi.stats.zapsToMappers')} color="#f472b6" loading={isLoading} />
|
||||
</div>
|
||||
{tradesByMethod.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
|
|
@ -361,15 +365,12 @@ export default function CommunityStatsPage() {
|
|||
</div>
|
||||
)}
|
||||
<p className="mt-3 text-[11px] leading-relaxed text-gray-600">
|
||||
<strong className="text-gray-400">{stats?.tradedCount ?? 0}</strong> mutual trade
|
||||
{(stats?.tradedCount ?? 0) === 1 ? '' : 's'} across{' '}
|
||||
<strong className="text-gray-400">{stats?.tradedTraders ?? 0}</strong> people. Sats are
|
||||
self-reported by the traders — the app can't observe an off-app payment — so the headline
|
||||
counts only <strong className="text-gray-400">mutual</strong> trades with a per-pair cap;
|
||||
the raw claim is{' '}
|
||||
<strong className="text-gray-400">{stats?.tradedCount ?? 0}</strong> {t('walletUi.stats.tradesNote1')}{' '}
|
||||
<strong className="text-gray-400">{stats?.tradedTraders ?? 0}</strong> {t('walletUi.stats.tradesNote2')}{' '}
|
||||
<strong className="text-gray-400">{t('walletUi.stats.mutual')}</strong> {t('walletUi.stats.tradesNote3')}{' '}
|
||||
<strong className="text-gray-500">{(stats?.tradedSatsClaimed ?? 0).toLocaleString()}</strong>{' '}
|
||||
sats. "Zaps to mappers" counts only receipts addressed to our top contributors — it is
|
||||
<strong className="text-gray-500"> not </strong> global zap volume.
|
||||
{t('walletUi.stats.tradesNote4')}
|
||||
<strong className="text-gray-500"> {t('walletUi.stats.not')} </strong> {t('walletUi.stats.tradesNote5')}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
|
@ -377,20 +378,19 @@ export default function CommunityStatsPage() {
|
|||
{(topCountries.length > 0 || topCities.length > 0) && (
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
|
||||
<Globe className="h-3.5 w-3.5 text-[#4ade80]" /> Spots & Shops by Region
|
||||
<Globe className="h-3.5 w-3.5 text-[#4ade80]" /> {t('walletUi.stats.byRegion')}
|
||||
</h2>
|
||||
{derivingPending > 0 && (
|
||||
<p className="-mt-2 mb-4 flex items-center gap-2 text-xs text-amber-300/90">
|
||||
<RefreshCw className="h-3.5 w-3.5 shrink-0 animate-spin" />
|
||||
Resolving {derivingPending} untagged location{derivingPending === 1 ? '' : 's'} from
|
||||
their coordinates… one-time, then cached on this device.
|
||||
{t('walletUi.stats.resolving', { count: derivingPending })}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{topCountries.length > 0 && (
|
||||
<Card className="border-white/8">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#4ade80]">Top Countries</CardTitle>
|
||||
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#4ade80]">{t('walletUi.stats.topCountries')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{topCountries.map((c) => (
|
||||
|
|
@ -407,14 +407,14 @@ export default function CommunityStatsPage() {
|
|||
))}
|
||||
{otherCountries > 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-24 truncate text-xs text-gray-500" title="Countries outside the top 6">Other</span>
|
||||
<span className="w-24 truncate text-xs text-gray-500" title={t('walletUi.stats.otherCountriesTitle')}>{t('walletUi.stats.other')}</span>
|
||||
<div className="h-2 flex-1 rounded-full bg-white/5" />
|
||||
<span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-gray-400">{otherCountries}</span>
|
||||
</div>
|
||||
)}
|
||||
{uncategorised > 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-24 truncate text-xs text-gray-500" title="No city/country tag">Uncategorised</span>
|
||||
<span className="w-24 truncate text-xs text-gray-500" title={t('walletUi.stats.uncategorisedTitle')}>{t('walletUi.stats.uncategorised')}</span>
|
||||
<div className="h-2 flex-1 rounded-full bg-white/5" />
|
||||
<span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-gray-400">{uncategorised}</span>
|
||||
</div>
|
||||
|
|
@ -425,7 +425,7 @@ export default function CommunityStatsPage() {
|
|||
{topCities.length > 0 && (
|
||||
<Card className="border-white/8">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#38bdf8]">Top Cities</CardTitle>
|
||||
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#38bdf8]">{t('walletUi.stats.topCities')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{topCities.map((c) => (
|
||||
|
|
@ -442,14 +442,14 @@ export default function CommunityStatsPage() {
|
|||
))}
|
||||
{otherCities > 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-24 truncate text-xs text-gray-500" title="Cities outside the top 6">Other</span>
|
||||
<span className="w-24 truncate text-xs text-gray-500" title={t('walletUi.stats.otherCitiesTitle')}>{t('walletUi.stats.other')}</span>
|
||||
<div className="h-2 flex-1 rounded-full bg-white/5" />
|
||||
<span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-gray-400">{otherCities}</span>
|
||||
</div>
|
||||
)}
|
||||
{uncategorised > 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-24 truncate text-xs text-gray-500" title="No city/country tag">Uncategorised</span>
|
||||
<span className="w-24 truncate text-xs text-gray-500" title={t('walletUi.stats.uncategorisedTitle')}>{t('walletUi.stats.uncategorised')}</span>
|
||||
<div className="h-2 flex-1 rounded-full bg-white/5" />
|
||||
<span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-gray-400">{uncategorised}</span>
|
||||
</div>
|
||||
|
|
@ -459,12 +459,9 @@ export default function CommunityStatsPage() {
|
|||
)}
|
||||
</div>
|
||||
<p className="mt-3 text-[11px] text-gray-600">
|
||||
Counts <strong className="text-gray-500">spots & shops</strong>, not skaters — from each
|
||||
one's own city/country tag, or derived from its pin’s coordinates when it has
|
||||
none (cached on your device; countries grouped by ISO code when present),
|
||||
community relay only, with moderation-hidden content excluded.
|
||||
{t('walletUi.stats.regionNote1')} <strong className="text-gray-500">{t('walletUi.stats.spotsAndShops')}</strong>{t('walletUi.stats.regionNote2')}
|
||||
{locationsTotal > 0 && (
|
||||
<> <strong className="text-gray-500">{uncategorised} of {locationsTotal}</strong> location{locationsTotal === 1 ? '' : 's'} have no location tag and show as “Uncategorised”.</>
|
||||
<> <strong className="text-gray-500">{t('walletUi.stats.ofTotal', { count: uncategorised, total: locationsTotal })}</strong> {t('walletUi.stats.regionNote3')}</>
|
||||
)}
|
||||
</p>
|
||||
{/* Admin-only (renders null otherwise): fix the gap right where it
|
||||
|
|
@ -476,7 +473,7 @@ export default function CommunityStatsPage() {
|
|||
{/* ── Section 2: Growth charts ── */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
|
||||
<TrendingUp className="h-3.5 w-3.5" /> Growth Over Time
|
||||
<TrendingUp className="h-3.5 w-3.5" /> {t('walletUi.stats.growth')}
|
||||
</h2>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
|
|
@ -485,9 +482,9 @@ export default function CommunityStatsPage() {
|
|||
<Card className="bg-black/80 border border-[#ff0040]/20">
|
||||
<CardHeader className="pb-2 pt-5 px-5">
|
||||
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#ff0040]">
|
||||
Spots Mapped (Cumulative)
|
||||
{t('walletUi.stats.chartCumulativeTitle')}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-gray-500">Total spots added to the map over time</p>
|
||||
<p className="text-xs text-gray-500">{t('walletUi.stats.chartCumulativeDesc')}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pb-4">
|
||||
{isLoading ? <ChartSkeleton /> : (
|
||||
|
|
@ -514,9 +511,9 @@ export default function CommunityStatsPage() {
|
|||
<Card className="bg-black/80 border border-[#ccff00]/20">
|
||||
<CardHeader className="pb-2 pt-5 px-5">
|
||||
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#ccff00]">
|
||||
Monthly Activity
|
||||
{t('walletUi.stats.chartMonthlyTitle')}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-gray-500">New content published each month</p>
|
||||
<p className="text-xs text-gray-500">{t('walletUi.stats.chartMonthlyDesc')}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pb-4">
|
||||
{isLoading ? <ChartSkeleton /> : (
|
||||
|
|
@ -541,9 +538,9 @@ export default function CommunityStatsPage() {
|
|||
<Card className="bg-black/80 border border-[#ff6600]/20">
|
||||
<CardHeader className="pb-2 pt-5 px-5">
|
||||
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#ff6600]">
|
||||
Active Mappers / Month
|
||||
{t('walletUi.stats.chartMappersTitle')}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-gray-500">Unique contributors publishing each month</p>
|
||||
<p className="text-xs text-gray-500">{t('walletUi.stats.chartMappersDesc')}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pb-4">
|
||||
{isLoading ? <ChartSkeleton /> : (
|
||||
|
|
@ -568,9 +565,9 @@ export default function CommunityStatsPage() {
|
|||
<Card className="bg-black/80 border border-[#c084fc]/20">
|
||||
<CardHeader className="pb-2 pt-5 px-5">
|
||||
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#c084fc]">
|
||||
Content Mix
|
||||
{t('walletUi.stats.chartMixTitle')}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-gray-500">Breakdown by content type (all time)</p>
|
||||
<p className="text-xs text-gray-500">{t('walletUi.stats.chartMixDesc')}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pb-4 flex items-center justify-center">
|
||||
{isLoading ? (
|
||||
|
|
@ -590,7 +587,7 @@ export default function CommunityStatsPage() {
|
|||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600 py-8">No data yet</p>
|
||||
<p className="text-sm text-gray-600 py-8">{t('walletUi.stats.noData')}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -601,8 +598,8 @@ export default function CommunityStatsPage() {
|
|||
{/* ── Section 3: Top mappers ── */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
|
||||
<Trophy className="h-3.5 w-3.5" /> Top Mappers
|
||||
<span className="text-gray-600 font-normal normal-case tracking-normal">— most spots added</span>
|
||||
<Trophy className="h-3.5 w-3.5" /> {t('walletUi.stats.topMappers')}
|
||||
<span className="text-gray-600 font-normal normal-case tracking-normal">{t('walletUi.stats.mostSpots')}</span>
|
||||
</h2>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
|
|
@ -652,7 +649,7 @@ export default function CommunityStatsPage() {
|
|||
</>
|
||||
) : (
|
||||
<div className="col-span-2 text-center py-10 text-gray-600 text-sm">
|
||||
No mapper data yet
|
||||
{t('walletUi.stats.noMapperData')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -661,17 +658,17 @@ export default function CommunityStatsPage() {
|
|||
{/* ── Section 4: Supporters ── */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
|
||||
<Zap className="h-3.5 w-3.5" /> Community Supporters
|
||||
<Zap className="h-3.5 w-3.5" /> {t('walletUi.stats.supporters')}
|
||||
</h2>
|
||||
|
||||
<div className="relative rounded-2xl overflow-hidden border border-[#ccff00]/20 bg-gradient-to-br from-[#ff0040]/5 to-[#ccff00]/5 p-6 md:p-8">
|
||||
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-[#ff0040] via-[#ff6600] to-[#ccff00]" />
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-black uppercase text-[#ccff00] mb-1">Funded by the Community</h3>
|
||||
<h3 className="text-xl font-black uppercase text-[#ccff00] mb-1">{t('walletUi.stats.fundedTitle')}</h3>
|
||||
<p className="text-sm text-gray-500 max-w-sm mb-4">
|
||||
Decentralized Bitcoin crowdfunding via Angor.<br />
|
||||
No ads, no investors, no data selling.
|
||||
{t('walletUi.stats.fundedDesc1')}<br />
|
||||
{t('walletUi.stats.fundedDesc2')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<a
|
||||
|
|
@ -680,14 +677,14 @@ export default function CommunityStatsPage() {
|
|||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-[#ff0040]/15 border border-[#ff0040]/40 text-[#ff0040] text-xs font-bold hover:bg-[#ff0040]/25 transition-colors"
|
||||
>
|
||||
<Zap className="h-3.5 w-3.5" /> Back us on Angor
|
||||
<Zap className="h-3.5 w-3.5" /> {t('walletUi.stats.backAngor')}
|
||||
<ExternalLink className="h-3 w-3 opacity-60" />
|
||||
</a>
|
||||
<Link
|
||||
to="/supporters"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-[#ccff00]/8 border border-[#ccff00]/30 text-[#ccff00] text-xs font-bold hover:bg-[#ccff00]/15 transition-colors"
|
||||
>
|
||||
<Users className="h-3.5 w-3.5" /> Community →
|
||||
<Users className="h-3.5 w-3.5" /> {t('walletUi.stats.communityLink')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -698,11 +695,11 @@ export default function CommunityStatsPage() {
|
|||
{/* ── Footer / verifiability note ── */}
|
||||
<div className="text-center p-6 rounded-xl bg-white/[0.02] border border-white/5">
|
||||
<p className="text-xs text-gray-600 leading-relaxed">
|
||||
All data is pulled live from{' '}
|
||||
{t('walletUi.stats.footer1')}{' '}
|
||||
<span className="text-[#ccff00] font-mono">{SKATE_RELAY}</span>
|
||||
{' '}— a public Nostr relay.{' '}
|
||||
Every event is signed by its creator and cryptographically verifiable.{' '}
|
||||
No numbers can be faked.
|
||||
{' '}{t('walletUi.stats.footer2')}{' '}
|
||||
{t('walletUi.stats.footer3')}{' '}
|
||||
{t('walletUi.stats.footer4')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue