import { useState, useEffect } from 'react'; import { useSeoMeta } from '@unhead/react'; import { useNavigate, useParams } from 'react-router-dom'; import ngeohash from 'ngeohash'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; import { Skeleton } from '@/components/ui/skeleton'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useUploadFile } from '@/hooks/useUploadFile'; import { useToast } from '@/hooks/useToast'; import { useShop } from '@/hooks/useShops'; import { LoginArea } from '@/components/auth/LoginArea'; import { SKATE_SHOP_KIND, isValidNoffer } from '@/lib/types'; import { reverseGeocodeRegions, pushRegionTags } from '@/lib/geocodeRegions'; import { NofferField } from '@/components/NofferField'; import { Store, Upload, X, Loader2, ArrowLeft, MapPin, AlertTriangle, } from 'lucide-react'; import { Link } from 'react-router-dom'; import { useTranslation } from '@/contexts/language'; import NotFound from './NotFound'; function EditShopSkeleton() { return ( <>
); } export default function EditShopPage() { const { t } = useTranslation(); const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>(); const navigate = useNavigate(); const { user } = useCurrentUser(); const { data: shop, isLoading, error } = useShop(identifier || '', pubkey); const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); const { toast } = useToast(); // Form state const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [location, setLocation] = useState(''); const [latitude, setLatitude] = useState(''); const [longitude, setLongitude] = useState(''); const [phone, setPhone] = useState(''); const [email, setEmail] = useState(''); const [website, setWebsite] = useState(''); const [instagram, setInstagram] = useState(''); const [hours, setHours] = useState(''); const [noffer, setNoffer] = useState(''); const [images, setImages] = useState([]); const [isGettingLocation, setIsGettingLocation] = useState(false); const [initialized, setInitialized] = useState(false); useSeoMeta({ title: shop ? `Edit ${shop.name} - SkateSpots` : 'Edit Shop - SkateSpots', description: 'Edit your skate shop.', }); // Initialize form with existing data useEffect(() => { if (shop && !initialized) { setName(shop.name); setDescription(shop.description || ''); setLocation(shop.location || ''); setPhone(shop.phone || ''); setEmail(shop.email || ''); setWebsite(shop.website || ''); setInstagram(shop.instagram || ''); setHours(shop.hours || ''); setNoffer(shop.noffer || ''); setImages(shop.images); // Decode geohash to get coordinates if (shop.geohash) { const { latitude: lat, longitude: lng } = ngeohash.decode(shop.geohash); setLatitude(lat.toFixed(6)); setLongitude(lng.toFixed(6)); } setInitialized(true); } }, [shop, initialized]); const getCurrentLocation = () => { if (!navigator.geolocation) { toast({ title: t('placesUi.createEvent.geoNotSupported'), description: t('placesUi.createEvent.geoNotSupportedDesc'), variant: 'destructive', }); return; } setIsGettingLocation(true); navigator.geolocation.getCurrentPosition( (position) => { setLatitude(position.coords.latitude.toFixed(6)); setLongitude(position.coords.longitude.toFixed(6)); setIsGettingLocation(false); toast({ title: t('placesUi.createEvent.locationFound'), description: t('placesUi.createEvent.locationFoundDesc'), }); }, (error) => { setIsGettingLocation(false); toast({ title: t('placesUi.createEvent.locationError'), description: error.message, variant: 'destructive', }); } ); }; const handleImageUpload = async (e: React.ChangeEvent) => { const files = e.target.files; if (!files || files.length === 0) return; try { const file = files[0]; const tags = await uploadFile(file); const url = tags[0]?.[1]; if (url) { setImages((prev) => [...prev, url]); toast({ title: t('placesUi.createEvent.imageUploaded'), description: t('placesUi.createEvent.imageUploadedDesc'), }); } } catch (error) { toast({ title: t('placesUi.createEvent.uploadFailed'), description: error instanceof Error ? error.message : t('placesUi.createEvent.uploadFailedDesc'), variant: 'destructive', }); } e.target.value = ''; }; const removeImage = (url: string) => { setImages((prev) => prev.filter((img) => img !== url)); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!shop) return; if (!name.trim()) { toast({ title: t('placesUi.createShop.nameRequired'), description: t('placesUi.createShop.nameRequiredDesc'), variant: 'destructive', }); return; } if (!location.trim()) { toast({ title: t('placesUi.createShop.addressRequired'), description: t('placesUi.createShop.addressRequiredDesc'), variant: 'destructive', }); return; } if (!latitude || !longitude) { toast({ title: t('placesUi.createShop.locationRequired'), description: t('placesUi.createShop.locationRequiredDesc'), variant: 'destructive', }); return; } const lat = parseFloat(latitude); const lng = parseFloat(longitude); if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) { toast({ title: t('placesUi.createShop.invalidCoords'), description: t('placesUi.createShop.invalidCoordsDesc'), variant: 'destructive', }); return; } try { // Generate geohash from coordinates (7 chars = ~153m precision) const geohash = ngeohash.encode(lat, lng, 7); // Best-effort reverse geocode → city/country/country_code (same helper as // spot/shop creation; never throws). Re-runs on edit so a moved shop's // region tags stay correct. const geo = await reverseGeocodeRegions(lat, lng); // Build tags - use the same identifier to update the addressable event const tags: string[][] = [ ['d', shop.identifier], ['name', name.trim()], ['g', geohash], ['t', 'skateshop'], ['lat', lat.toString()], ['lon', lng.toString()], ['location', location.trim()], ['alt', `Skate shop: ${name.trim()}`], ]; pushRegionTags(tags, geo); if (phone.trim()) { tags.push(['phone', phone.trim()]); } if (email.trim()) { tags.push(['email', email.trim()]); } if (website.trim()) { tags.push(['website', website.trim()]); } if (instagram.trim()) { tags.push(['instagram', instagram.trim()]); } if (hours.trim()) { tags.push(['hours', hours.trim()]); } if (isValidNoffer(noffer.trim())) { tags.push(['noffer', noffer.trim().toLowerCase()]); } for (const imageUrl of images) { // Use imeta tag for NIP-92 compatibility tags.push(['imeta', `url ${imageUrl}`, 'm image/jpeg']); } // Carry through any tag this form doesn't manage, untouched. A shop is an // addressable NIP-15 stall, so re-publishing REPLACES the whole tag set — // rebuilding only the fields on this form would silently strip anything // else the stall carries (e.g. currency/shipping from another client, or // future tags), just like the event-edit fix. `t` is special: this form // owns the 'skateshop' marker, so keep every OTHER topic tag. const MANAGED_TAGS = new Set([ 'd', 'name', 'g', 'lat', 'lon', 'location', 'alt', 'phone', 'email', 'website', 'instagram', 'hours', 'noffer', 'imeta', ]); for (const tag of shop.event.tags) { const [tagName, tagValue] = tag; if (MANAGED_TAGS.has(tagName)) continue; if (tagName === 't') { if (tagValue !== 'skateshop') tags.push(tag); // keep foreign topics continue; } tags.push(tag); } await createEvent({ kind: SKATE_SHOP_KIND, content: description.trim(), tags, }); toast({ title: t('placesUi.editShop.updated'), description: t('placesUi.editEvent.updatedDesc'), }); navigate(`/shop/${user?.pubkey}/${shop.identifier}`); } catch (error) { toast({ title: t('placesUi.editEvent.updateFailed'), description: error instanceof Error ? error.message : t('placesUi.createEvent.somethingWrong'), variant: 'destructive', }); } }; if (isLoading) { return ; } if (error || !shop) { return ; } // Check if user is the owner if (!user) { return ( <>

{t('errors.loginRequired')}

{t('placesUi.editShop.loginToEdit')}

); } if (user.pubkey !== shop.pubkey) { return ( <>

{t('placesUi.editEvent.accessDenied')}

{t('placesUi.editShop.accessDeniedDesc')}

); } return ( <>
{t('placesUi.editShop.backToShop')}

{t('shops.editShop')}

{t('placesUi.editShop.updateDetails', { name: shop.name })}

{t('shops.shopDetails')} {t('placesUi.editShop.detailsDesc')} {/* Shop Name */}
setName(e.target.value)} required />
{/* Description */}