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
|
||||
kind; the constants live in `src/lib/types.ts`. Highlights:
|
||||
- `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
|
||||
- `30403` marketplace offer (+ seller response) — see `NIP.md`
|
||||
- `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
|
||||
pulses forever — the `animate-ping` halo shows **only when an unread tip
|
||||
exists**, is `motion-reduce:hidden`, and is suppressed on **full-bleed routes**
|
||||
(`isFullBleed` passed from `Layout`), where the bubble also runs **compact**
|
||||
(smaller, no glow) so it stops competing with the map. A ✕ on the FAB hides
|
||||
it (`useSasquatchHidden`, localStorage `sasquatch-hidden`, same-tab synced via
|
||||
a `sasquatch:hiddenchange` event), and **Settings → Appearance** has a "Show
|
||||
Sasquatch assistant" toggle to bring it back.
|
||||
(`isFullBleed` passed from `Layout`). The bubble keeps its **full size/glow**
|
||||
on every route (do NOT shrink it into a dull compact circle — that looked
|
||||
bad); the map only loses the pulse. **Hide** is a small ✕ shown on
|
||||
**desktop hover**, plus an **EyeOff "Hide assistant"** button in the panel
|
||||
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
|
||||
endpoint for *external* agents. Runs on this machine as a **system** service
|
||||
(`/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/* →
|
||||
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.
|
||||
**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` →
|
||||
`https://skate-spots.org` and `/api/ai` → `https://ai.skate-spots.org`
|
||||
(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 { 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 { useSasquatchHidden } from '@/hooks/useSasquatchHidden';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
|
|
@ -1607,37 +1607,36 @@ Instructions:
|
|||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative group flex items-center justify-center 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',
|
||||
'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',
|
||||
isDragging ? 'scale-110 border-[#ccff00] cursor-grabbing' : 'hover:border-[#ccff00] hover:scale-110 active:scale-95 cursor-grab',
|
||||
)}
|
||||
style={{
|
||||
background: 'radial-gradient(circle at 40% 35%, #2d4a1e 0%, #1a2e0f 50%, #0d1a07 100%)',
|
||||
boxShadow: compact
|
||||
? '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)',
|
||||
boxShadow: '0 0 20px rgba(204,255,0,0.2), 0 4px 20px rgba(0,0,0,0.8)',
|
||||
}}
|
||||
aria-label={t('sasquatchUi.openGuideAria')}
|
||||
>
|
||||
<img
|
||||
src="/images/sasquatch-guide.webp"
|
||||
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
|
||||
under prefers-reduced-motion (the mount animation is the nuisance). */}
|
||||
under prefers-reduced-motion (the motion is the nuisance). */}
|
||||
{hasUnread && !compact && (
|
||||
<span className="absolute inset-0 rounded-full animate-ping opacity-20 border-2 border-[#ccff00] motion-reduce:hidden" />
|
||||
)}
|
||||
{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]" />
|
||||
)}
|
||||
{/* Hide is desktop-only hover here; mobile hides via the open panel
|
||||
(or Settings → Appearance) so the bubble stays uncluttered. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('sasquatchUi.hideAria')}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
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" />
|
||||
</button>
|
||||
|
|
@ -1733,6 +1732,16 @@ Instructions:
|
|||
</div>
|
||||
</div>
|
||||
<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 */}
|
||||
<button
|
||||
onClick={() => { if (recentlyOpened()) return; setIsMinimized(true); }}
|
||||
|
|
|
|||
|
|
@ -18,11 +18,13 @@ import {
|
|||
ChevronUp,
|
||||
Star,
|
||||
Globe,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { isNativePlatform } from '@/lib/platform';
|
||||
import { useMintDirectory } from '@/hooks/useMintDirectory';
|
||||
|
||||
interface Mint {
|
||||
url: string;
|
||||
|
|
@ -89,8 +91,18 @@ export function MintPrivacyManager({
|
|||
const [mintUrl, setMintUrl] = useState('');
|
||||
const [isAddingMint, setIsAddingMint] = useState(false);
|
||||
const [mintToRemove, setMintToRemove] = useState<Mint | null>(null);
|
||||
const [mintQuery, setMintQuery] = useState('');
|
||||
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
|
||||
const mintDistribution = mints.map(mint => ({
|
||||
mint,
|
||||
|
|
@ -447,6 +459,67 @@ export function MintPrivacyManager({
|
|||
})}
|
||||
</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 */}
|
||||
<Alert className="border-amber-500/30 bg-amber-500/5">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ const sasquatchUi = {
|
|||
bubbleTitle: 'Press and drag to move · Tap to open',
|
||||
openGuideAria: 'Open Sasquatch guide',
|
||||
hideAria: 'Hide the Sasquatch assistant',
|
||||
hide: 'Hide assistant',
|
||||
activeUntil: 'Active until {{date}}',
|
||||
platformOwner: 'Platform owner',
|
||||
upgradeTitle: 'Upgrade to Sasquatch Pro',
|
||||
|
|
@ -162,6 +163,7 @@ const sasquatchUi = {
|
|||
bubbleTitle: 'Pulsa y arrastra para mover · Toca para abrir',
|
||||
openGuideAria: 'Abrir la guía del Sasquatch',
|
||||
hideAria: 'Ocultar el asistente Sasquatch',
|
||||
hide: 'Ocultar asistente',
|
||||
activeUntil: 'Activo hasta el {{date}}',
|
||||
platformOwner: 'Propietario de la plataforma',
|
||||
upgradeTitle: 'Mejora a Sasquatch Pro',
|
||||
|
|
|
|||
|
|
@ -2964,6 +2964,15 @@ placesUi: {
|
|||
invalidEndDesc: 'End time must be after start time.',
|
||||
linkedTo: 'Linked to: {{title}}',
|
||||
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)',
|
||||
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...',
|
||||
|
|
@ -5797,6 +5806,10 @@ walletUi: {
|
|||
adding: 'Adding...',
|
||||
orChoose: 'Or choose from list',
|
||||
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',
|
||||
soon: 'Soon',
|
||||
autoDistribute: 'Auto-distribute funds',
|
||||
|
|
|
|||
|
|
@ -2964,6 +2964,15 @@ placesUi: {
|
|||
invalidEndDesc: 'La hora de fin debe ser posterior a la de inicio.',
|
||||
linkedTo: 'Vinculado a: {{title}}',
|
||||
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)',
|
||||
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...',
|
||||
|
|
@ -5797,6 +5806,10 @@ walletUi: {
|
|||
adding: 'Añadiendo...',
|
||||
orChoose: 'O elige de la lista',
|
||||
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',
|
||||
soon: 'Pronto',
|
||||
autoDistribute: 'Distribuir fondos automáticamente',
|
||||
|
|
|
|||
|
|
@ -15,10 +15,11 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { useTours } from '@/hooks/useTours';
|
||||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||
import { useSkateShops } from '@/hooks/useSkateShops';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
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 { useLabProfile } from '@/hooks/useLabProfiles';
|
||||
import { Users } from 'lucide-react';
|
||||
|
|
@ -40,6 +41,7 @@ import {
|
|||
Music,
|
||||
Sparkles,
|
||||
Plus,
|
||||
Store,
|
||||
} from 'lucide-react';
|
||||
import { FeaturedEventUpgrade } from '@/components/FeaturedEventUpgrade';
|
||||
import { usePlatformFee } from '@/hooks/usePlatformFee';
|
||||
|
|
@ -90,6 +92,8 @@ export default function CreateEventPage() {
|
|||
|
||||
// Fetch available spots for linking
|
||||
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
|
||||
const [title, setTitle] = useState('');
|
||||
|
|
@ -114,6 +118,11 @@ export default function CreateEventPage() {
|
|||
const [showSpotSelector, setShowSpotSelector] = useState(false);
|
||||
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
|
||||
const [entryFee, setEntryFee] = useState('');
|
||||
const isPaidEvent = entryFee.trim().length > 0 && entryFee.trim() !== '0';
|
||||
|
|
@ -137,6 +146,13 @@ export default function CreateEventPage() {
|
|||
|
||||
// Get the selected spot (if any)
|
||||
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
|
||||
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
|
||||
// the crew page and on the event as "Hosted by [crew]".
|
||||
if (hostCrew) {
|
||||
|
|
@ -829,6 +851,111 @@ export default function CreateEventPage() {
|
|||
)}
|
||||
</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 */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('events.eventType')}</Label>
|
||||
|
|
|
|||
Loading…
Reference in a new issue