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:
SkateSpots 2026-10-04 17:45:42 +07:00
parent c8778aa3c9
commit 0e405eaa00
7 changed files with 261 additions and 15 deletions

View file

@ -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

View file

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

View file

@ -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" />

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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>