Polish Sasquatch bubble; full mint list; link shops to events
Sasquatch (the look regressed): I over-corrected last pass by shrinking the bubble on the map and bolting an always-visible ✕ over the avatar on mobile. Restored the full-size bubble + glow on every route (the map only loses the pulse), moved Hide to a desktop-hover ✕ and an EyeOff "Hide assistant" button in the panel header (Settings → Appearance still re-enables it). Mints: the add-mint dialog only offered a hand-picked "Recommended" shortlist. It now also lists the full searchable MintRadar directory (useMintDirectory) below the recommendations. Events: CreateEventPage could only link a skate spot. Added an optional "Link to a Shop" picker (useSkateShops, search, location autofill) that writes ['a','30017:…'] + ['venue-name', …].
This commit is contained in:
parent
c8778aa3c9
commit
0e405eaa00
7 changed files with 261 additions and 15 deletions
19
AGENTS.md
19
AGENTS.md
|
|
@ -38,6 +38,8 @@ session with **no other memory of this project**. So:
|
||||||
- **Custom protocol**: `NIP.md` is the source of truth for every custom/adopted
|
- **Custom protocol**: `NIP.md` is the source of truth for every custom/adopted
|
||||||
kind; the constants live in `src/lib/types.ts`. Highlights:
|
kind; the constants live in `src/lib/types.ts`. Highlights:
|
||||||
- `30818` skate spot · `30017` shop · `31923`/`31925` events + RSVP · `30823` tours
|
- `30818` skate spot · `30017` shop · `31923`/`31925` events + RSVP · `30823` tours
|
||||||
|
- `CreateEventPage` can link a **shop** as the venue too (not just a spot):
|
||||||
|
`['a','30017:<pk>:<d>']` + `['venue-name', <name>]` (2026-10-04).
|
||||||
- `30402` NIP-99 listing = marketplace item, and the **Assertion Event** for trades
|
- `30402` NIP-99 listing = marketplace item, and the **Assertion Event** for trades
|
||||||
- `30403` marketplace offer (+ seller response) — see `NIP.md`
|
- `30403` marketplace offer (+ seller response) — see `NIP.md`
|
||||||
- `31871` trade attestation (sale/purchase) · `31872` trade confirmation request
|
- `31871` trade attestation (sale/purchase) · `31872` trade confirmation request
|
||||||
|
|
@ -588,11 +590,14 @@ session with **no other memory of this project**. So:
|
||||||
**Bubble visibility (2026-10-04):** the floating Sasquatch FAB no longer
|
**Bubble visibility (2026-10-04):** the floating Sasquatch FAB no longer
|
||||||
pulses forever — the `animate-ping` halo shows **only when an unread tip
|
pulses forever — the `animate-ping` halo shows **only when an unread tip
|
||||||
exists**, is `motion-reduce:hidden`, and is suppressed on **full-bleed routes**
|
exists**, is `motion-reduce:hidden`, and is suppressed on **full-bleed routes**
|
||||||
(`isFullBleed` passed from `Layout`), where the bubble also runs **compact**
|
(`isFullBleed` passed from `Layout`). The bubble keeps its **full size/glow**
|
||||||
(smaller, no glow) so it stops competing with the map. A ✕ on the FAB hides
|
on every route (do NOT shrink it into a dull compact circle — that looked
|
||||||
it (`useSasquatchHidden`, localStorage `sasquatch-hidden`, same-tab synced via
|
bad); the map only loses the pulse. **Hide** is a small ✕ shown on
|
||||||
a `sasquatch:hiddenchange` event), and **Settings → Appearance** has a "Show
|
**desktop hover**, plus an **EyeOff "Hide assistant"** button in the panel
|
||||||
Sasquatch assistant" toggle to bring it back.
|
header, plus `useSasquatchHidden` (`sasquatch-hidden`, same-tab synced via a
|
||||||
|
`sasquatch:hiddenchange` event) and a **Settings → Appearance** "Show
|
||||||
|
Sasquatch assistant" toggle. (Earlier iteration put an always-visible ✕ over
|
||||||
|
the avatar on mobile — removed; it was ugly.)
|
||||||
- **ContextVM (CVM) server** (`cvm-server/`, Bun): standalone MCP-over-Nostr
|
- **ContextVM (CVM) server** (`cvm-server/`, Bun): standalone MCP-over-Nostr
|
||||||
endpoint for *external* agents. Runs on this machine as a **system** service
|
endpoint for *external* agents. Runs on this machine as a **system** service
|
||||||
(`/etc/systemd/system/skatespots-cvm.service`, `sudo systemctl restart
|
(`/etc/systemd/system/skatespots-cvm.service`, `sudo systemctl restart
|
||||||
|
|
@ -1555,6 +1560,10 @@ session with **no other memory of this project**. So:
|
||||||
at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* →
|
at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* →
|
||||||
https://mintradar.org/api/mints/:splat 200`), dev via `server.proxy` in
|
https://mintradar.org/api/mints/:splat 200`), dev via `server.proxy` in
|
||||||
`vite.config.ts`. Data is unofficial/rate-limited; adding a mint is native-only.
|
`vite.config.ts`. Data is unofficial/rate-limited; adding a mint is native-only.
|
||||||
|
**Add-mint dialog (2026-10-04):** `MintPrivacyManager`'s add dialog now also
|
||||||
|
lists the **full MintRadar directory** (searchable, `useMintDirectory`) below
|
||||||
|
the hand-picked "Recommended" shortlist — previously it only showed the
|
||||||
|
recommendations.
|
||||||
⚠️ **`vite.config.ts`'s `server.proxy` also covers `/api/nip05` →
|
⚠️ **`vite.config.ts`'s `server.proxy` also covers `/api/nip05` →
|
||||||
`https://skate-spots.org` and `/api/ai` → `https://ai.skate-spots.org`
|
`https://skate-spots.org` and `/api/ai` → `https://ai.skate-spots.org`
|
||||||
(2026-09-28).** Without them the dev server answers those production-only
|
(2026-09-28).** Without them the dev server answers those production-only
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { X, Minimize2, HelpCircle, Send, Loader2, Wifi, WifiOff } from 'lucide-react';
|
import { X, Minimize2, HelpCircle, Send, Loader2, Wifi, WifiOff, EyeOff } from 'lucide-react';
|
||||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||||
import { useSasquatchHidden } from '@/hooks/useSasquatchHidden';
|
import { useSasquatchHidden } from '@/hooks/useSasquatchHidden';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
|
@ -1607,37 +1607,36 @@ Instructions:
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative group flex items-center justify-center rounded-full shadow-2xl border-2 border-[#ccff00]/60 transition-all duration-200',
|
'relative group flex items-center justify-center w-12 h-12 md:w-14 md:h-14 rounded-full shadow-2xl border-2 border-[#ccff00]/60 transition-all duration-200',
|
||||||
compact ? 'w-9 h-9' : 'w-12 h-12 md:w-14 md:h-14',
|
|
||||||
isDragging ? 'scale-110 border-[#ccff00] cursor-grabbing' : 'hover:border-[#ccff00] hover:scale-110 active:scale-95 cursor-grab',
|
isDragging ? 'scale-110 border-[#ccff00] cursor-grabbing' : 'hover:border-[#ccff00] hover:scale-110 active:scale-95 cursor-grab',
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
background: 'radial-gradient(circle at 40% 35%, #2d4a1e 0%, #1a2e0f 50%, #0d1a07 100%)',
|
background: 'radial-gradient(circle at 40% 35%, #2d4a1e 0%, #1a2e0f 50%, #0d1a07 100%)',
|
||||||
boxShadow: compact
|
boxShadow: '0 0 20px rgba(204,255,0,0.2), 0 4px 20px rgba(0,0,0,0.8)',
|
||||||
? '0 2px 10px rgba(0,0,0,0.8)'
|
|
||||||
: '0 0 20px rgba(204,255,0,0.2), 0 4px 20px rgba(0,0,0,0.8)',
|
|
||||||
}}
|
}}
|
||||||
aria-label={t('sasquatchUi.openGuideAria')}
|
aria-label={t('sasquatchUi.openGuideAria')}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/images/sasquatch-guide.webp"
|
src="/images/sasquatch-guide.webp"
|
||||||
alt="The Sasquatch"
|
alt="The Sasquatch"
|
||||||
className={cn('rounded-full object-cover pointer-events-none', compact ? 'w-6 h-6' : 'w-8 h-8 md:w-9 md:h-9')}
|
className="w-8 h-8 md:w-9 md:h-9 rounded-full object-cover pointer-events-none"
|
||||||
/>
|
/>
|
||||||
{/* Pulse only when there's an unread tip, never on the map, and never
|
{/* Pulse only when there's an unread tip, never on the map, and never
|
||||||
under prefers-reduced-motion (the mount animation is the nuisance). */}
|
under prefers-reduced-motion (the motion is the nuisance). */}
|
||||||
{hasUnread && !compact && (
|
{hasUnread && !compact && (
|
||||||
<span className="absolute inset-0 rounded-full animate-ping opacity-20 border-2 border-[#ccff00] motion-reduce:hidden" />
|
<span className="absolute inset-0 rounded-full animate-ping opacity-20 border-2 border-[#ccff00] motion-reduce:hidden" />
|
||||||
)}
|
)}
|
||||||
{hasUnread && (
|
{hasUnread && (
|
||||||
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 bg-[#ccff00] rounded-full border-2 border-black shadow-[0_0_6px_#ccff00]" />
|
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 bg-[#ccff00] rounded-full border-2 border-black shadow-[0_0_6px_#ccff00]" />
|
||||||
)}
|
)}
|
||||||
|
{/* Hide is desktop-only hover here; mobile hides via the open panel
|
||||||
|
(or Settings → Appearance) so the bubble stays uncluttered. */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t('sasquatchUi.hideAria')}
|
aria-label={t('sasquatchUi.hideAria')}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => { e.stopPropagation(); setHidden(true); }}
|
onClick={(e) => { e.stopPropagation(); setHidden(true); }}
|
||||||
className="absolute -top-1.5 -left-1.5 z-10 flex h-5 w-5 items-center justify-center rounded-full border border-white/20 bg-black/80 text-gray-400 opacity-100 transition-opacity hover:text-white md:opacity-0 md:group-hover:opacity-100"
|
className="absolute -top-1.5 -left-1.5 z-10 hidden h-5 w-5 items-center justify-center rounded-full border border-white/20 bg-black/80 text-gray-400 opacity-0 transition-opacity hover:text-white group-hover:opacity-100 focus:opacity-100 md:flex"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -1733,6 +1732,16 @@ Instructions:
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 flex-shrink-0">
|
<div className="flex gap-1 flex-shrink-0">
|
||||||
|
{/* Hide — removes the floating assistant (bring it back from
|
||||||
|
Settings → Appearance, or the desktop hover ✕ on the bubble). */}
|
||||||
|
<button
|
||||||
|
onClick={() => { setHidden(true); setIsVisible(false); }}
|
||||||
|
className="w-6 h-6 flex items-center justify-center rounded text-[#ccff00]/40 hover:text-[#ccff00] hover:bg-[#ccff00]/10 transition-colors"
|
||||||
|
aria-label={t('sasquatchUi.hideAria')}
|
||||||
|
title={t('sasquatchUi.hide')}
|
||||||
|
>
|
||||||
|
<EyeOff className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
{/* Minimize — shrinks to draggable bubble */}
|
{/* Minimize — shrinks to draggable bubble */}
|
||||||
<button
|
<button
|
||||||
onClick={() => { if (recentlyOpened()) return; setIsMinimized(true); }}
|
onClick={() => { if (recentlyOpened()) return; setIsMinimized(true); }}
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,13 @@ import {
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Star,
|
Star,
|
||||||
Globe,
|
Globe,
|
||||||
|
Search,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { isNativePlatform } from '@/lib/platform';
|
import { isNativePlatform } from '@/lib/platform';
|
||||||
|
import { useMintDirectory } from '@/hooks/useMintDirectory';
|
||||||
|
|
||||||
interface Mint {
|
interface Mint {
|
||||||
url: string;
|
url: string;
|
||||||
|
|
@ -89,8 +91,18 @@ export function MintPrivacyManager({
|
||||||
const [mintUrl, setMintUrl] = useState('');
|
const [mintUrl, setMintUrl] = useState('');
|
||||||
const [isAddingMint, setIsAddingMint] = useState(false);
|
const [isAddingMint, setIsAddingMint] = useState(false);
|
||||||
const [mintToRemove, setMintToRemove] = useState<Mint | null>(null);
|
const [mintToRemove, setMintToRemove] = useState<Mint | null>(null);
|
||||||
|
const [mintQuery, setMintQuery] = useState('');
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// The full MintRadar community directory, so the add dialog isn't limited to a
|
||||||
|
// handful of hand-picked recommendations.
|
||||||
|
const { data: directory, isLoading: directoryLoading } = useMintDirectory();
|
||||||
|
const directoryMints = (directory ?? []).filter((m) => {
|
||||||
|
if (!mintQuery) return true;
|
||||||
|
const q = mintQuery.toLowerCase();
|
||||||
|
return (m.name?.toLowerCase().includes(q) ?? false) || m.url.toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
|
||||||
// Calculate distribution across mints
|
// Calculate distribution across mints
|
||||||
const mintDistribution = mints.map(mint => ({
|
const mintDistribution = mints.map(mint => ({
|
||||||
mint,
|
mint,
|
||||||
|
|
@ -447,6 +459,67 @@ export function MintPrivacyManager({
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Full directory (MintRadar) — the recommended list above is just a
|
||||||
|
shortlist; the whole community directory is searchable here. */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">{t('walletUi.mintPrivacy.allMints')}</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={mintQuery}
|
||||||
|
onChange={(e) => setMintQuery(e.target.value)}
|
||||||
|
placeholder={t('walletUi.mintPrivacy.searchMints')}
|
||||||
|
className="pl-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-56 space-y-1 overflow-y-auto rounded-lg border p-1.5">
|
||||||
|
{directoryLoading ? (
|
||||||
|
<p className="py-4 text-center text-xs text-muted-foreground">{t('common.loading')}</p>
|
||||||
|
) : directoryMints.length === 0 ? (
|
||||||
|
<p className="py-4 text-center text-xs text-muted-foreground">{t('walletUi.mintPrivacy.noDirectoryMints')}</p>
|
||||||
|
) : (
|
||||||
|
directoryMints.map((mint) => {
|
||||||
|
const isAdded = mints.some((m) => m.url.toLowerCase() === mint.url.toLowerCase());
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={mint.url}
|
||||||
|
className={cn('flex items-center justify-between gap-2 rounded-lg px-2 py-1.5', isAdded ? 'bg-green-500/5' : 'bg-muted/40')}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-xs font-medium">{mint.name || getCleanMintLabel({ url: mint.url })}</p>
|
||||||
|
<p className="truncate font-mono text-[10px] text-muted-foreground">{mint.url.replace(/^https?:\/\//, '')}</p>
|
||||||
|
</div>
|
||||||
|
{isAdded ? (
|
||||||
|
<Badge variant="secondary" className="shrink-0 text-[10px]">{t('walletUi.mintPrivacy.added')}</Badge>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="shrink-0"
|
||||||
|
disabled={isAddingMint}
|
||||||
|
onClick={async () => {
|
||||||
|
setIsAddingMint(true);
|
||||||
|
try {
|
||||||
|
await onAddMint(mint.url);
|
||||||
|
toast({ title: t('walletUi.mintPrivacy.mintAdded'), description: t('walletUi.mintPrivacy.mintAddedNamed', { name: mint.name || mint.url }) });
|
||||||
|
} catch (err) {
|
||||||
|
toast({ variant: 'destructive', title: t('walletUi.mintPrivacy.failed'), description: err instanceof Error ? err.message : t('walletUi.cashu.unknownError') });
|
||||||
|
} finally {
|
||||||
|
setIsAddingMint(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('walletUi.mintPrivacy.add')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">{t('walletUi.mintPrivacy.directoryHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Trust Warning */}
|
{/* Trust Warning */}
|
||||||
<Alert className="border-amber-500/30 bg-amber-500/5">
|
<Alert className="border-amber-500/30 bg-amber-500/5">
|
||||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ const sasquatchUi = {
|
||||||
bubbleTitle: 'Press and drag to move · Tap to open',
|
bubbleTitle: 'Press and drag to move · Tap to open',
|
||||||
openGuideAria: 'Open Sasquatch guide',
|
openGuideAria: 'Open Sasquatch guide',
|
||||||
hideAria: 'Hide the Sasquatch assistant',
|
hideAria: 'Hide the Sasquatch assistant',
|
||||||
|
hide: 'Hide assistant',
|
||||||
activeUntil: 'Active until {{date}}',
|
activeUntil: 'Active until {{date}}',
|
||||||
platformOwner: 'Platform owner',
|
platformOwner: 'Platform owner',
|
||||||
upgradeTitle: 'Upgrade to Sasquatch Pro',
|
upgradeTitle: 'Upgrade to Sasquatch Pro',
|
||||||
|
|
@ -162,6 +163,7 @@ const sasquatchUi = {
|
||||||
bubbleTitle: 'Pulsa y arrastra para mover · Toca para abrir',
|
bubbleTitle: 'Pulsa y arrastra para mover · Toca para abrir',
|
||||||
openGuideAria: 'Abrir la guía del Sasquatch',
|
openGuideAria: 'Abrir la guía del Sasquatch',
|
||||||
hideAria: 'Ocultar el asistente Sasquatch',
|
hideAria: 'Ocultar el asistente Sasquatch',
|
||||||
|
hide: 'Ocultar asistente',
|
||||||
activeUntil: 'Activo hasta el {{date}}',
|
activeUntil: 'Activo hasta el {{date}}',
|
||||||
platformOwner: 'Propietario de la plataforma',
|
platformOwner: 'Propietario de la plataforma',
|
||||||
upgradeTitle: 'Mejora a Sasquatch Pro',
|
upgradeTitle: 'Mejora a Sasquatch Pro',
|
||||||
|
|
|
||||||
|
|
@ -2964,6 +2964,15 @@ placesUi: {
|
||||||
invalidEndDesc: 'End time must be after start time.',
|
invalidEndDesc: 'End time must be after start time.',
|
||||||
linkedTo: 'Linked to: {{title}}',
|
linkedTo: 'Linked to: {{title}}',
|
||||||
linkToSpot: 'Link to a Skate Spot',
|
linkToSpot: 'Link to a Skate Spot',
|
||||||
|
linkToShop: 'Link to a Shop',
|
||||||
|
selectedShop: 'Linked to {{name}}',
|
||||||
|
selectShopHint: 'Optional — held at or hosted by a skate shop.',
|
||||||
|
selectShop: 'Select shop',
|
||||||
|
changeShop: 'Change shop',
|
||||||
|
hideShops: 'Hide shops',
|
||||||
|
searchShops: 'Search shops…',
|
||||||
|
noShopsMatch: 'No shops match your search.',
|
||||||
|
noShopsAvailable: 'No shops available yet.',
|
||||||
linkToTour: 'Link to a Tour (Optional)',
|
linkToTour: 'Link to a Tour (Optional)',
|
||||||
linkTourDesc: "Link this event to a tour to show it's part of a larger series. Great for national tours, international adventures, or World Destruction Tour challenge days.",
|
linkTourDesc: "Link this event to a tour to show it's part of a larger series. Great for national tours, international adventures, or World Destruction Tour challenge days.",
|
||||||
loadingSpots: 'Loading spots...',
|
loadingSpots: 'Loading spots...',
|
||||||
|
|
@ -5797,6 +5806,10 @@ walletUi: {
|
||||||
adding: 'Adding...',
|
adding: 'Adding...',
|
||||||
orChoose: 'Or choose from list',
|
orChoose: 'Or choose from list',
|
||||||
recommended: 'Recommended Mints',
|
recommended: 'Recommended Mints',
|
||||||
|
allMints: 'All mints (directory)',
|
||||||
|
searchMints: 'Search mints…',
|
||||||
|
noDirectoryMints: 'No mints match your search.',
|
||||||
|
directoryHint: 'From the community MintRadar directory — unofficial and may be stale.',
|
||||||
added: 'Added',
|
added: 'Added',
|
||||||
soon: 'Soon',
|
soon: 'Soon',
|
||||||
autoDistribute: 'Auto-distribute funds',
|
autoDistribute: 'Auto-distribute funds',
|
||||||
|
|
|
||||||
|
|
@ -2964,6 +2964,15 @@ placesUi: {
|
||||||
invalidEndDesc: 'La hora de fin debe ser posterior a la de inicio.',
|
invalidEndDesc: 'La hora de fin debe ser posterior a la de inicio.',
|
||||||
linkedTo: 'Vinculado a: {{title}}',
|
linkedTo: 'Vinculado a: {{title}}',
|
||||||
linkToSpot: 'Vincular a un spot',
|
linkToSpot: 'Vincular a un spot',
|
||||||
|
linkToShop: 'Vincular a una tienda',
|
||||||
|
selectedShop: 'Vinculado a {{name}}',
|
||||||
|
selectShopHint: 'Opcional — se celebra en o lo organiza una tienda de skate.',
|
||||||
|
selectShop: 'Elegir tienda',
|
||||||
|
changeShop: 'Cambiar tienda',
|
||||||
|
hideShops: 'Ocultar tiendas',
|
||||||
|
searchShops: 'Buscar tiendas…',
|
||||||
|
noShopsMatch: 'Ninguna tienda coincide con tu búsqueda.',
|
||||||
|
noShopsAvailable: 'Aún no hay tiendas disponibles.',
|
||||||
linkToTour: 'Vincular a un tour (opcional)',
|
linkToTour: 'Vincular a un tour (opcional)',
|
||||||
linkTourDesc: 'Vincula este evento a un tour para mostrar que forma parte de una serie más amplia. Ideal para tours nacionales, aventuras internacionales o días de retos del World Destruction Tour.',
|
linkTourDesc: 'Vincula este evento a un tour para mostrar que forma parte de una serie más amplia. Ideal para tours nacionales, aventuras internacionales o días de retos del World Destruction Tour.',
|
||||||
loadingSpots: 'Cargando spots...',
|
loadingSpots: 'Cargando spots...',
|
||||||
|
|
@ -5797,6 +5806,10 @@ walletUi: {
|
||||||
adding: 'Añadiendo...',
|
adding: 'Añadiendo...',
|
||||||
orChoose: 'O elige de la lista',
|
orChoose: 'O elige de la lista',
|
||||||
recommended: 'Mints recomendados',
|
recommended: 'Mints recomendados',
|
||||||
|
allMints: 'Todos los mints (directorio)',
|
||||||
|
searchMints: 'Buscar mints…',
|
||||||
|
noDirectoryMints: 'Ningún mint coincide con tu búsqueda.',
|
||||||
|
directoryHint: 'Del directorio comunitario MintRadar — no oficial y puede estar desactualizado.',
|
||||||
added: 'Añadido',
|
added: 'Añadido',
|
||||||
soon: 'Pronto',
|
soon: 'Pronto',
|
||||||
autoDistribute: 'Distribuir fondos automáticamente',
|
autoDistribute: 'Distribuir fondos automáticamente',
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,11 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||||
import { useTours } from '@/hooks/useTours';
|
import { useTours } from '@/hooks/useTours';
|
||||||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||||
|
import { useSkateShops } from '@/hooks/useSkateShops';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { LoginArea } from '@/components/auth/LoginArea';
|
import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
import { CALENDAR_EVENT_KIND, EVENT_TYPE_LABELS, TOUR_CATEGORIES, SKATE_TOUR_KIND, SKATE_SPOT_KIND, SKATE_CREW_KIND, SKATE_MUSICIAN_KIND, SPOT_CATEGORIES, EVENT_SPONSOR_TIERS, eventSponsorTags, type SkateTour, type SkateSpot, type EventSponsor } from '@/lib/types';
|
import { CALENDAR_EVENT_KIND, EVENT_TYPE_LABELS, TOUR_CATEGORIES, SKATE_TOUR_KIND, SKATE_SPOT_KIND, SKATE_SHOP_KIND, SKATE_CREW_KIND, SKATE_MUSICIAN_KIND, SPOT_CATEGORIES, EVENT_SPONSOR_TIERS, eventSponsorTags, type SkateTour, type SkateSpot, type EventSponsor } from '@/lib/types';
|
||||||
import { useCrew } from '@/hooks/useCrews';
|
import { useCrew } from '@/hooks/useCrews';
|
||||||
import { useLabProfile } from '@/hooks/useLabProfiles';
|
import { useLabProfile } from '@/hooks/useLabProfiles';
|
||||||
import { Users } from 'lucide-react';
|
import { Users } from 'lucide-react';
|
||||||
|
|
@ -40,6 +41,7 @@ import {
|
||||||
Music,
|
Music,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Plus,
|
Plus,
|
||||||
|
Store,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { FeaturedEventUpgrade } from '@/components/FeaturedEventUpgrade';
|
import { FeaturedEventUpgrade } from '@/components/FeaturedEventUpgrade';
|
||||||
import { usePlatformFee } from '@/hooks/usePlatformFee';
|
import { usePlatformFee } from '@/hooks/usePlatformFee';
|
||||||
|
|
@ -90,6 +92,8 @@ export default function CreateEventPage() {
|
||||||
|
|
||||||
// Fetch available spots for linking
|
// Fetch available spots for linking
|
||||||
const { data: spots, isLoading: spotsLoading } = useSkateSpots(200);
|
const { data: spots, isLoading: spotsLoading } = useSkateSpots(200);
|
||||||
|
// Fetch available shops for linking (an event can be held at / hosted by a shop)
|
||||||
|
const { data: shops, isLoading: shopsLoading } = useSkateShops(200);
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
|
|
@ -114,6 +118,11 @@ export default function CreateEventPage() {
|
||||||
const [showSpotSelector, setShowSpotSelector] = useState(false);
|
const [showSpotSelector, setShowSpotSelector] = useState(false);
|
||||||
const [spotSearchQuery, setSpotSearchQuery] = useState('');
|
const [spotSearchQuery, setSpotSearchQuery] = useState('');
|
||||||
|
|
||||||
|
// Optional shop link — an event can be held at / hosted by a skate shop.
|
||||||
|
const [linkedShopId, setLinkedShopId] = useState<string>('none');
|
||||||
|
const [showShopSelector, setShowShopSelector] = useState(false);
|
||||||
|
const [shopSearchQuery, setShopSearchQuery] = useState('');
|
||||||
|
|
||||||
// Paid event / entry fee
|
// Paid event / entry fee
|
||||||
const [entryFee, setEntryFee] = useState('');
|
const [entryFee, setEntryFee] = useState('');
|
||||||
const isPaidEvent = entryFee.trim().length > 0 && entryFee.trim() !== '0';
|
const isPaidEvent = entryFee.trim().length > 0 && entryFee.trim() !== '0';
|
||||||
|
|
@ -137,6 +146,13 @@ export default function CreateEventPage() {
|
||||||
|
|
||||||
// Get the selected spot (if any)
|
// Get the selected spot (if any)
|
||||||
const selectedSpot = spots?.find((s) => `${s.pubkey}:${s.identifier}` === linkedSpotId);
|
const selectedSpot = spots?.find((s) => `${s.pubkey}:${s.identifier}` === linkedSpotId);
|
||||||
|
// Get the selected shop (if any)
|
||||||
|
const selectedShop = shops?.find((s) => `${s.pubkey}:${s.identifier}` === linkedShopId);
|
||||||
|
const filteredShops = shops?.filter((shop) => {
|
||||||
|
if (!shopSearchQuery) return true;
|
||||||
|
const q = shopSearchQuery.toLowerCase();
|
||||||
|
return shop.name.toLowerCase().includes(q) || (shop.location?.toLowerCase().includes(q) ?? false);
|
||||||
|
});
|
||||||
|
|
||||||
// Filter spots by search query
|
// Filter spots by search query
|
||||||
const filteredSpots = spots?.filter((spot) => {
|
const filteredSpots = spots?.filter((spot) => {
|
||||||
|
|
@ -396,6 +412,12 @@ export default function CreateEventPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optional shop link (venue / host shop) — same addressable ref pattern.
|
||||||
|
if (selectedShop && linkedShopId !== 'none') {
|
||||||
|
tags.push(['a', `${SKATE_SHOP_KIND}:${selectedShop.pubkey}:${selectedShop.identifier}`]);
|
||||||
|
tags.push(['venue-name', selectedShop.name]);
|
||||||
|
}
|
||||||
|
|
||||||
// Crew host — link the event back to the crew coordinate so it shows on
|
// Crew host — link the event back to the crew coordinate so it shows on
|
||||||
// the crew page and on the event as "Hosted by [crew]".
|
// the crew page and on the event as "Hosted by [crew]".
|
||||||
if (hostCrew) {
|
if (hostCrew) {
|
||||||
|
|
@ -829,6 +851,111 @@ export default function CreateEventPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Optional shop link — an event can be held at / hosted by a shop */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-base font-bold text-[#ff0040]">{t('placesUi.createEvent.linkToShop')}</Label>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{selectedShop ? t('placesUi.createEvent.selectedShop', { name: selectedShop.name }) : t('placesUi.createEvent.selectShopHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowShopSelector(!showShopSelector)}
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 border-[#ff0040]/50',
|
||||||
|
selectedShop ? 'bg-[#ff0040] text-white hover:bg-[#ff0040]/90' : 'text-[#ff0040] hover:bg-[#ff0040]/10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{showShopSelector ? t('placesUi.createEvent.hideShops') : selectedShop ? t('placesUi.createEvent.changeShop') : t('placesUi.createEvent.selectShop')}
|
||||||
|
<ChevronDown className={cn('h-4 w-4 ml-1 transition-transform', showShopSelector && 'rotate-180')} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedShop && !showShopSelector && (
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border border-[#ff0040]/30 bg-black/40 p-3">
|
||||||
|
{selectedShop.images[0] ? (
|
||||||
|
<img src={selectedShop.images[0]} alt="" className="h-12 w-12 flex-shrink-0 rounded-lg object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-lg bg-[#ff0040]/20">
|
||||||
|
<Store className="h-5 w-5 text-[#ff0040]" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h4 className="truncate text-sm font-bold text-white">{selectedShop.name}</h4>
|
||||||
|
{selectedShop.location && <p className="truncate text-xs text-gray-400">{selectedShop.location}</p>}
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={() => setLinkedShopId('none')} className="h-8 w-8 p-0 text-gray-400 hover:text-red-400">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showShopSelector && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input placeholder={t('placesUi.createEvent.searchShops')} value={shopSearchQuery} onChange={(e) => setShopSearchQuery(e.target.value)} className="border-[#ff0040]/30 bg-black" />
|
||||||
|
<div className="max-h-64 space-y-2 overflow-y-auto rounded-lg border border-[#ff0040]/20 p-2">
|
||||||
|
{shopsLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-[#ff0040]" />
|
||||||
|
</div>
|
||||||
|
) : filteredShops && filteredShops.length > 0 ? (
|
||||||
|
filteredShops.map((shop) => (
|
||||||
|
<button
|
||||||
|
key={shop.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setLinkedShopId(`${shop.pubkey}:${shop.identifier}`);
|
||||||
|
if (shop.location) setLocation(shop.location);
|
||||||
|
if (shop.geohash) {
|
||||||
|
const coords = ngeohash.decode(shop.geohash);
|
||||||
|
setLatitude(coords.latitude.toFixed(6));
|
||||||
|
setLongitude(coords.longitude.toFixed(6));
|
||||||
|
}
|
||||||
|
setShowShopSelector(false);
|
||||||
|
setShopSearchQuery('');
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-lg p-3 text-left transition-colors',
|
||||||
|
linkedShopId === `${shop.pubkey}:${shop.identifier}`
|
||||||
|
? 'border-2 border-[#ff0040] bg-[#ff0040]/20'
|
||||||
|
: 'border border-gray-800 bg-black/50 hover:border-[#ff0040]/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{shop.images[0] ? (
|
||||||
|
<img src={shop.images[0]} alt="" className="h-12 w-12 flex-shrink-0 rounded-lg object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-lg bg-[#ff0040]/20">
|
||||||
|
<Store className="h-5 w-5 text-[#ff0040]" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h4 className="truncate text-sm font-bold text-white">{shop.name}</h4>
|
||||||
|
{shop.location && (
|
||||||
|
<p className="flex items-center gap-1 truncate text-xs text-gray-400">
|
||||||
|
<MapPin className="h-3 w-3 flex-shrink-0" />
|
||||||
|
<span className="truncate">{shop.location}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{linkedShopId === `${shop.pubkey}:${shop.identifier}` && <div className="text-[#ff0040]">✓</div>}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="py-4 text-center text-sm text-gray-400">
|
||||||
|
{shopSearchQuery ? t('placesUi.createEvent.noShopsMatch') : t('placesUi.createEvent.noShopsAvailable')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Event Types */}
|
{/* Event Types */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('events.eventType')}</Label>
|
<Label>{t('events.eventType')}</Label>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue