SkateSpots/src/pages/CreateShopPage.tsx

533 lines
19 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useNavigate, useSearchParams } 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 { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast';
import { ShopBountyCard } from '@/components/ShopBountyCard';
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,
} from 'lucide-react';
import { Link } from 'react-router-dom';
import { useTranslation } from '@/contexts/language';
export default function CreateShopPage() {
const { t } = useTranslation();
useSeoMeta({
title: t('seoUi.createShopTitle'),
description: t('seoUi.createShopDesc'),
});
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { user } = useCurrentUser();
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 [noffer, setNoffer] = useState('');
const [nip05s, setNip05s] = useState<string[]>([]);
const [hours, setHours] = useState('');
const [images, setImages] = useState<string[]>([]);
const [isGettingLocation, setIsGettingLocation] = useState(false);
// Pre-fill coordinates from URL params (from map click)
useEffect(() => {
const lat = searchParams.get('lat');
const lng = searchParams.get('lng');
if (lat && lng) {
setLatitude(lat);
setLongitude(lng);
}
}, [searchParams]);
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',
});
}
// Clear the input
e.target.value = '';
};
const removeImage = (url: string) => {
setImages((prev) => prev.filter((img) => img !== url));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
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);
// Create unique identifier for addressable event
const identifier = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 50) + '-' + Date.now().toString(36);
// Best-effort reverse geocode → city/country/country_code, so shops show
// up in the /stats region breakdown (same helper as spots; never throws).
const geo = await reverseGeocodeRegions(lat, lng);
// Build tags according to NIP-15 and custom extensions
const tags: string[][] = [
['d', identifier], // Required for addressable events
['name', name.trim()],
['g', geohash],
['t', 'skateshop'], // Primary identifier for shop events
['lat', lat.toString()],
['lon', lng.toString()],
['location', location.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 handle of nip05s) tags.push(['nip05', handle]);
for (const imageUrl of images) {
// Use imeta tag for NIP-92 compatibility
tags.push(['imeta', `url ${imageUrl}`, 'm image/jpeg']);
}
await createEvent({
kind: SKATE_SHOP_KIND,
content: description.trim(),
tags,
});
toast({
title: t('placesUi.createShop.created'),
description: t('placesUi.createShop.createdDesc'),
});
// Navigate to shop detail using pubkey and identifier
navigate(`/shop/${user?.pubkey}/${identifier}`);
} catch (error) {
console.error('Failed to create shop:', error);
toast({
title: t('placesUi.createEvent.publishFailed'),
description: error instanceof Error ? error.message : t('placesUi.createShop.publishFailedDesc'),
variant: 'destructive',
});
}
};
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.createShop.loginToAdd')}
</p>
<LoginArea className="justify-center" />
</CardContent>
</Card>
</div>
</>
);
}
return (
<>
<div className="container px-4 sm:px-6 py-6 sm:py-8 max-w-2xl">
<div className="mb-6">
<Link to="/map" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4">
<ArrowLeft className="h-4 w-4 mr-2" />
{t('placesUi.shop.backToMap')}
</Link>
<h1 className="text-3xl font-black uppercase neon-lime" style={{
fontFamily: 'Impact, "Arial Black", sans-serif'
}}>{t('shops.addShop')}</h1>
<p className="text-gray-400 mt-1">
{t('placesUi.createShop.subtitle')}
</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.createShop.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} />
{/* Shop NIP-05 handle(s) — optional, verified against the owner key */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
{t('shops.nip05')}
<span className="text-xs font-normal text-muted-foreground">{t('socialUi.form.optional')}</span>
</Label>
<p className="text-xs text-muted-foreground">{t('shops.nip05Hint')}</p>
<Nip05HandlesEditor handles={nip05s} onChange={setNip05s} pubkey={user?.pubkey} inputId="shop-nip05" />
</div>
{/* 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>
{/* Quality Meter */}
<ShopBountyCard
hasName={!!name.trim()}
hasAddress={!!location.trim()}
hasCoordinates={!!latitude && !!longitude}
hasDescription={!!description.trim()}
hasPhone={!!phone.trim()}
hasWebsite={!!website.trim()}
hasHours={!!hours.trim()}
photoCount={images.length}
/>
{/* 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('createEvent.publishing')}
</>
) : (
<>
<Store className="h-4 w-4 mr-2" />
{t('shops.addShop')}
</>
)}
</Button>
</CardContent>
</Card>
</form>
</div>
</>
);
}