~3,470 new keys (5,770 total, EN=ES), ~115 more files wired. - nav + chromeUi: header, More menu, burger menu, footer, map toolbar widgets (sort/trust/weather), spot-type label helper. - placesUi: events, shops, tours, food. socialUi: crews, DMs, notifications, collectives, crew chat/coordinator. settingsUi: settings, auth/signup, onboarding, profile edit, legal. walletUi: wallet/Cashu, stash, mints, app shop, newsletter, community stats, leaderboards, profile. - Fixed 738 usages where an agent dropped the socialUi. prefix (caught by check:i18n).
602 lines
21 KiB
TypeScript
602 lines
21 KiB
TypeScript
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 (
|
|
<>
|
|
<div className="container px-4 py-8 max-w-2xl">
|
|
<Skeleton className="h-6 w-32 mb-4" />
|
|
<Skeleton className="h-10 w-64 mb-2" />
|
|
<Skeleton className="h-5 w-48 mb-6" />
|
|
<Card>
|
|
<CardHeader>
|
|
<Skeleton className="h-6 w-32" />
|
|
<Skeleton className="h-4 w-64" />
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
<Skeleton className="h-10 w-full" />
|
|
<Skeleton className="h-24 w-full" />
|
|
<Skeleton className="h-10 w-full" />
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<Skeleton className="h-10 w-full" />
|
|
<Skeleton className="h-10 w-full" />
|
|
</div>
|
|
<Skeleton className="h-10 w-full" />
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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<string[]>([]);
|
|
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<HTMLInputElement>) => {
|
|
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 <EditShopSkeleton />;
|
|
}
|
|
|
|
if (error || !shop) {
|
|
return <NotFound />;
|
|
}
|
|
|
|
// Check if user is the owner
|
|
if (!user) {
|
|
return (
|
|
<>
|
|
<div className="container px-4 py-8">
|
|
<Card className="max-w-xl mx-auto border-dashed">
|
|
<CardContent className="py-12 text-center">
|
|
<Store className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
|
|
<h2 className="text-xl font-bold mb-2">{t('errors.loginRequired')}</h2>
|
|
<p className="text-muted-foreground mb-6">
|
|
{t('placesUi.editShop.loginToEdit')}
|
|
</p>
|
|
<LoginArea className="justify-center" />
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (user.pubkey !== shop.pubkey) {
|
|
return (
|
|
<>
|
|
<div className="container px-4 py-8">
|
|
<Card className="max-w-xl mx-auto border-destructive">
|
|
<CardContent className="py-12 text-center">
|
|
<AlertTriangle className="h-12 w-12 mx-auto mb-4 text-destructive" />
|
|
<h2 className="text-xl font-bold mb-2">{t('placesUi.editEvent.accessDenied')}</h2>
|
|
<p className="text-muted-foreground mb-6">
|
|
{t('placesUi.editShop.accessDeniedDesc')}
|
|
</p>
|
|
<Link to={`/shop/${shop.pubkey}/${shop.identifier}`}>
|
|
<Button variant="outline">{t('placesUi.editShop.backToShop')}</Button>
|
|
</Link>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="container px-4 py-8 max-w-2xl">
|
|
<div className="mb-6">
|
|
<Link
|
|
to={`/shop/${shop.pubkey}/${shop.identifier}`}
|
|
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-4"
|
|
>
|
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
{t('placesUi.editShop.backToShop')}
|
|
</Link>
|
|
<h1 className="text-3xl font-black uppercase neon-lime" style={{
|
|
fontFamily: 'Impact, "Arial Black", sans-serif'
|
|
}}>{t('shops.editShop')}</h1>
|
|
<p className="text-muted-foreground mt-1">
|
|
{t('placesUi.editShop.updateDetails', { name: shop.name })}
|
|
</p>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit}>
|
|
<Card className="bg-black/90 border-2 border-[#ccff00]/30">
|
|
<CardHeader>
|
|
<CardTitle className="text-[#ccff00] uppercase font-black">{t('shops.shopDetails')}</CardTitle>
|
|
<CardDescription className="text-gray-400">
|
|
{t('placesUi.editShop.detailsDesc')}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{/* Shop Name */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="name">{t('shops.shopName')} *</Label>
|
|
<Input
|
|
id="name"
|
|
placeholder={t('placesUi.createShop.namePlaceholder')}
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="description">{t('spots.description')}</Label>
|
|
<Textarea
|
|
id="description"
|
|
placeholder={t('placesUi.createShop.descriptionPlaceholder')}
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
rows={4}
|
|
/>
|
|
</div>
|
|
|
|
{/* Location Address */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="location">{t('placesUi.createShop.address')} *</Label>
|
|
<Input
|
|
id="location"
|
|
placeholder={t('placesUi.createShop.addressPlaceholder')}
|
|
value={location}
|
|
onChange={(e) => setLocation(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
{/* Coordinates */}
|
|
<div className="space-y-2">
|
|
<Label>{t('createSpot.coordinates')} *</Label>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<Input
|
|
placeholder={t('placesUi.createShop.latPlaceholder')}
|
|
value={latitude}
|
|
onChange={(e) => setLatitude(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Input
|
|
placeholder={t('placesUi.createShop.lngPlaceholder')}
|
|
value={longitude}
|
|
onChange={(e) => setLongitude(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={getCurrentLocation}
|
|
disabled={isGettingLocation}
|
|
className="mt-2"
|
|
>
|
|
{isGettingLocation ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
{t('createSpot.gettingLocation')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<MapPin className="h-4 w-4 mr-2" />
|
|
{t('createSpot.useCurrentLocation')}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Contact Information */}
|
|
<div className="space-y-3 p-4 rounded-lg border-2 border-[#ccff00]/30 bg-[#ccff00]/5">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<span className="text-2xl">📞</span>
|
|
<Label className="text-base font-bold text-[#ccff00]">{t('placesUi.createShop.contactInfo')}</Label>
|
|
</div>
|
|
<p className="text-sm text-gray-400 mb-3">
|
|
{t('placesUi.createShop.contactHint')}
|
|
</p>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="phone">{t('placesUi.createShop.phoneNumber')}</Label>
|
|
<Input
|
|
id="phone"
|
|
type="tel"
|
|
placeholder="+1 234 567 8900"
|
|
value={phone}
|
|
onChange={(e) => setPhone(e.target.value)}
|
|
className="bg-black border-[#ccff00]/30"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="email">{t('placesUi.createShop.email')}</Label>
|
|
<Input
|
|
id="email"
|
|
type="email"
|
|
placeholder="shop@example.com"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
className="bg-black border-[#ccff00]/30"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="website">{t('shops.website')}</Label>
|
|
<Input
|
|
id="website"
|
|
type="url"
|
|
placeholder="https://myshop.com"
|
|
value={website}
|
|
onChange={(e) => setWebsite(e.target.value)}
|
|
className="bg-black border-[#ccff00]/30"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="instagram">{t('placesUi.createShop.instagram')}</Label>
|
|
<Input
|
|
id="instagram"
|
|
placeholder="@shopname"
|
|
value={instagram}
|
|
onChange={(e) => setInstagram(e.target.value)}
|
|
className="bg-black border-[#ccff00]/30"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Hours of Operation */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="hours">{t('placesUi.createShop.hoursOfOperation')}</Label>
|
|
<Input
|
|
id="hours"
|
|
placeholder={t('placesUi.createShop.hoursPlaceholder')}
|
|
value={hours}
|
|
onChange={(e) => setHours(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* CLINK self-custodial payments (optional) */}
|
|
<NofferField value={noffer} onChange={setNoffer} />
|
|
|
|
{/* Images */}
|
|
<div className="space-y-2">
|
|
<Label>{t('placesUi.createShop.shopPhotos')}</Label>
|
|
<div className="grid grid-cols-3 gap-4">
|
|
{images.map((url) => (
|
|
<div key={url} className="relative aspect-video rounded-lg overflow-hidden bg-muted">
|
|
<img src={url} alt={t('placesUi.createShop.shopPhotoAlt')} className="w-full h-full object-cover" />
|
|
<button
|
|
type="button"
|
|
onClick={() => removeImage(url)}
|
|
className="absolute top-1 right-1 p-1 rounded-full bg-background/80 hover:bg-destructive hover:text-destructive-foreground transition-colors"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<label className="aspect-video rounded-lg border-2 border-dashed flex flex-col items-center justify-center cursor-pointer hover:bg-muted/50 transition-colors">
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={handleImageUpload}
|
|
disabled={isUploading}
|
|
/>
|
|
{isUploading ? (
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
) : (
|
|
<>
|
|
<Upload className="h-6 w-6 text-muted-foreground mb-1" />
|
|
<span className="text-xs text-muted-foreground">{t('placesUi.createShop.upload')}</span>
|
|
</>
|
|
)}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Submit */}
|
|
<Button
|
|
type="submit"
|
|
className="w-full bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-black uppercase"
|
|
disabled={isPublishing || isUploading}
|
|
>
|
|
{isPublishing ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
{t('placesUi.editEvent.saving')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Store className="h-4 w-4 mr-2" />
|
|
{t('placesUi.editEvent.saveChanges')}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</form>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|