From 9d069cb2f0c3bf9ef9ffa45a08e0d268f7cce80d Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Mon, 7 Sep 2026 22:42:34 +0700 Subject: [PATCH] =?UTF-8?q?Add=20tour=20editing=20=E2=80=94=20the=20gap=20?= =?UTF-8?q?flagged=20in=20the=20addressable-events=20audit=20(1.2.33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tours (kind 30823) were the only major addressable entity with no edit path: an organizer had to delete and recreate (losing the URL/identifier) just to fix a typo or price. Rather than duplicate the enormous multi-category tour form (and risk schema drift / the exact tag-clobber bugs fixed earlier this session), I made the existing CreateTourPage do double duty: - CreateTourPage now accepts an optional `existing?: SkateTour`. When present it prefills every field (all four categories: local/national/international/ destruction β€” including challenges, prize distribution, film-release, partner shop, cities/countries, coords, dates), reuses the same `d` identifier, and adapts copy (title, submit button, toast). - On save it PRESERVES unmanaged tags (sponsors, tags from other clients) and keeps any existing paid `featured` window β€” same "preserve unmanaged tags" discipline as the event/shop edit fixes, since 30823 is addressable and re-publishing replaces the whole tag set. - Guarded the categoryβ†’duration reset effect so it no longer clobbers the prefilled duration on the first render in edit mode. - The paid FeaturedUpgrade upsell is hidden when editing (existing status is carried through automatically). New EditTourPage wrapper loads the tour, gates to the owner (login / access- denied states), and renders . Added the /edit-tour/:pubkey/:identifier route and an owner-only Edit button on TourDetailPage beside the existing Delete control. This closes the one concrete gap the addressable-events audit surfaced: every entity now has both a working edit path (preserving unmanaged tags) and owner management. Co-authored-by: shakespeare.diy --- package.json | 2 +- src/AppRouter.tsx | 2 + src/pages/CreateTourPage.tsx | 205 +++++++++++++++++++++++------------ src/pages/EditTourPage.tsx | 74 +++++++++++++ src/pages/TourDetailPage.tsx | 27 ++++- 5 files changed, 233 insertions(+), 77 deletions(-) create mode 100644 src/pages/EditTourPage.tsx diff --git a/package.json b/package.json index 37a2185..2e16387 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "skate-spots", "private": true, - "version": "1.2.32", + "version": "1.2.33", "type": "module", "homepage": "https://skate-spots.com", "scripts": { diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 8878b32..069e62b 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -20,6 +20,7 @@ const CreateTourPage = lazy(() => import("./pages/CreateTourPage")); const EditSpotPage = lazy(() => import("./pages/EditSpotPage")); const EditShopPage = lazy(() => import("./pages/EditShopPage")); const EditEventPage = lazy(() => import("./pages/EditEventPage")); +const EditTourPage = lazy(() => import("./pages/EditTourPage")); const GearSwapPage = lazy(() => import("./pages/GearSwapPage")); const SpotDetailPage = lazy(() => import("./pages/SpotDetailPage")); const ShopDetailPage = lazy(() => import("./pages/ShopDetailPage")); @@ -129,6 +130,7 @@ export function AppRouter() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/pages/CreateTourPage.tsx b/src/pages/CreateTourPage.tsx index 5834e50..d88ec17 100644 --- a/src/pages/CreateTourPage.tsx +++ b/src/pages/CreateTourPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useSeoMeta } from '@unhead/react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import ngeohash from 'ngeohash'; @@ -19,7 +19,7 @@ import { useToast } from '@/hooks/useToast'; import { LoginArea } from '@/components/auth/LoginArea'; import { FeaturedUpgrade } from '@/components/FeaturedEventUpgrade'; import { usePlatformFee } from '@/hooks/usePlatformFee'; -import { SKATE_TOUR_KIND, TOUR_SKILL_LEVELS, TOUR_SPOT_TYPES, TOUR_CATEGORIES, type TourCategory, type DestructionChallenge } from '@/lib/types'; +import { SKATE_TOUR_KIND, TOUR_SKILL_LEVELS, TOUR_SPOT_TYPES, TOUR_CATEGORIES, type TourCategory, type DestructionChallenge, type SkateTour } from '@/lib/types'; import { Route, Upload, @@ -185,9 +185,11 @@ const ALL_LANGUAGES = [ { code: 'jm', label: 'Jamaican Patois', flag: 'πŸ‡―πŸ‡²' }, ]; -export default function CreateTourPage() { +export default function CreateTourPage({ existing }: { existing?: SkateTour } = {}) { + const isEditing = !!existing; + useSeoMeta({ - title: 'Create a Tour - SkateSpots', + title: isEditing ? 'Edit Tour - SkateSpots' : 'Create a Tour - SkateSpots', description: 'Organize skate tours - local hidden gem tours, national road trips, international adventures, or KOTR-style destruction competitions.', }); @@ -202,69 +204,96 @@ export default function CreateTourPage() { const { toast } = useToast(); // Tour category state - const [tourCategory, setTourCategory] = useState('local'); + // Convert a unix-seconds timestamp to a value the datetime-local input accepts. + const toLocalInput = (secs?: number) => { + if (!secs) return ''; + const d = new Date(secs * 1000); + // Adjust for the local timezone offset so the picker shows the same wall time. + const off = d.getTimezoneOffset() * 60000; + return new Date(d.getTime() - off).toISOString().slice(0, 16); + }; - // Form state - const [title, setTitle] = useState(''); - const [description, setDescription] = useState(''); - const [location, setLocation] = useState(''); // City/region - const [meetingPoint, setMeetingPoint] = useState(''); - const [meetingLat, setMeetingLat] = useState(''); - const [meetingLng, setMeetingLng] = useState(''); - const [duration, setDuration] = useState([3]); // Hours/Days depending on category - const [durationUnit, setDurationUnit] = useState<'hours' | 'days' | 'weeks'>('hours'); - const [skillLevel, setSkillLevel] = useState('all'); - const [spotTypes, setSpotTypes] = useState(['street']); - const [maxGroupSize, setMaxGroupSize] = useState([4]); - const [price, setPrice] = useState(''); - const [availability, setAvailability] = useState(''); - const [languages, setLanguages] = useState(['en']); + const [tourCategory, setTourCategory] = useState(existing?.category ?? 'local'); + + // Form state β€” prefilled from `existing` when editing. + const [title, setTitle] = useState(existing?.title ?? ''); + const [description, setDescription] = useState(existing?.description ?? ''); + const [location, setLocation] = useState(existing?.location ?? ''); // City/region + const [meetingPoint, setMeetingPoint] = useState(existing?.meetingPoint ?? ''); + const [meetingLat, setMeetingLat] = useState(existing?.meetingPointCoords ? String(existing.meetingPointCoords.lat) : ''); + const [meetingLng, setMeetingLng] = useState(existing?.meetingPointCoords ? String(existing.meetingPointCoords.lon) : ''); + const [duration, setDuration] = useState([existing?.duration ?? 3]); // Hours/Days depending on category + const [durationUnit, setDurationUnit] = useState<'hours' | 'days' | 'weeks'>(existing?.durationUnit ?? 'hours'); + const [skillLevel, setSkillLevel] = useState(existing?.skillLevel ?? 'all'); + const [spotTypes, setSpotTypes] = useState(existing?.spotTypes?.length ? existing.spotTypes : ['street']); + const [maxGroupSize, setMaxGroupSize] = useState([existing?.maxGroupSize ?? 4]); + const [price, setPrice] = useState(existing?.price != null ? String(existing.price) : ''); + const [availability, setAvailability] = useState(existing?.availability ?? ''); + const [languages, setLanguages] = useState(existing?.languages?.length ? existing.languages : ['en']); const [languagesExpanded, setLanguagesExpanded] = useState(false); - const [image, setImage] = useState(null); + const [image, setImage] = useState(existing?.image ?? null); const [isGettingLocation, setIsGettingLocation] = useState(false); // Partner shop state - const [partnerShopId, setPartnerShopId] = useState(''); - const [shopDiscount, setShopDiscount] = useState([10]); // Default 10% - const [shopExpanded, setShopExpanded] = useState(false); + const [partnerShopId, setPartnerShopId] = useState(() => { + // shopRef is stored as "30017:pubkey:identifier"; the picker keys on "pubkey:identifier". + const parts = existing?.shopRef?.split(':'); + return parts && parts.length >= 3 ? `${parts[1]}:${parts[2]}` : ''; + }); + const [shopDiscount, setShopDiscount] = useState([existing?.shopDiscount ?? 10]); // Default 10% + const [shopExpanded, setShopExpanded] = useState(!!existing?.shopRef); // National/International specific - const [cities, setCities] = useState([]); + const [cities, setCities] = useState(existing?.cities ?? []); const [newCity, setNewCity] = useState(''); - const [countries, setCountries] = useState([]); + const [countries, setCountries] = useState(existing?.countries ?? []); const [newCountry, setNewCountry] = useState(''); - const [transportInfo, setTransportInfo] = useState(''); - const [accommodationInfo, setAccommodationInfo] = useState(''); + const [transportInfo, setTransportInfo] = useState(existing?.transportInfo ?? ''); + const [accommodationInfo, setAccommodationInfo] = useState(existing?.accommodationInfo ?? ''); // World Destruction Tour specific - const [startDate, setStartDate] = useState(''); - const [endDate, setEndDate] = useState(''); - const [entryFee, setEntryFee] = useState(''); - const [prizePool, setPrizePool] = useState(''); - const [prizeDistribution, setPrizeDistribution] = useState([ - { place: 1, percentage: 50 }, - { place: 2, percentage: 30 }, - { place: 3, percentage: 20 }, - ]); - const [maxTeams, setMaxTeams] = useState([4]); - const [minTeamSize, setMinTeamSize] = useState([4]); - const [maxTeamSize, setMaxTeamSize] = useState([6]); - const [challenges, setChallenges] = useState([]); - const [acceptedClipSources, setAcceptedClipSources] = useState(['blossom', 'youtube', 'instagram']); - const [organizerContact, setOrganizerContact] = useState(''); // Organizer contact details + const [startDate, setStartDate] = useState(toLocalInput(existing?.startDate)); + const [endDate, setEndDate] = useState(toLocalInput(existing?.endDate)); + const [entryFee, setEntryFee] = useState(existing?.entryFee != null ? String(existing.entryFee) : ''); + const [prizePool, setPrizePool] = useState(existing?.prizePool != null ? String(existing.prizePool) : ''); + const [prizeDistribution, setPrizeDistribution] = useState( + existing?.prizeDistribution?.length + ? existing.prizeDistribution + : [ + { place: 1, percentage: 50 }, + { place: 2, percentage: 30 }, + { place: 3, percentage: 20 }, + ] + ); + const [maxTeams, setMaxTeams] = useState([existing?.maxTeams ?? 4]); + const [minTeamSize, setMinTeamSize] = useState([existing?.minTeamSize ?? 4]); + const [maxTeamSize, setMaxTeamSize] = useState([existing?.maxTeamSize ?? 6]); + const [challenges, setChallenges] = useState(existing?.challenges ?? []); + const [acceptedClipSources, setAcceptedClipSources] = useState( + existing?.acceptedClipSources?.length ? existing.acceptedClipSources : ['blossom', 'youtube', 'instagram'] + ); + const [organizerContact, setOrganizerContact] = useState(existing?.organizerContact ?? ''); // Organizer contact details // Film release options for World Destruction Tours - const [enableFilmRelease, setEnableFilmRelease] = useState(false); - const [filmReleaseAtShops, setFilmReleaseAtShops] = useState(true); // Release at partner skate shops - const [filmReleaseOnline, setFilmReleaseOnline] = useState(true); // Also release online - const [filmReleaseDate, setFilmReleaseDate] = useState(''); // Optional specific date for film release + const [enableFilmRelease, setEnableFilmRelease] = useState(!!existing?.filmRelease); + const [filmReleaseAtShops, setFilmReleaseAtShops] = useState(existing?.filmRelease?.atShops ?? true); // Release at partner skate shops + const [filmReleaseOnline, setFilmReleaseOnline] = useState(existing?.filmRelease?.online ?? true); // Also release online + const [filmReleaseDate, setFilmReleaseDate] = useState(toLocalInput(existing?.filmRelease?.releaseDate)); // Optional specific date for film release const [showAvailableShops, setShowAvailableShops] = useState(false); // Toggle to show available shops for premieres // Fetch shops for partner selection const { data: shops, isLoading: shopsLoading } = useShops(100); - // Update duration unit based on category + // When editing, keep the tour's saved duration on first render instead of + // resetting it to the category default (the effect below fires on mount). + const skipDurationReset = useRef(isEditing); + + // Update duration unit based on category (skips the initial edit-mode render). useEffect(() => { + if (skipDurationReset.current) { + skipDurationReset.current = false; + return; + } if (tourCategory === 'local') { setDurationUnit('hours'); setDuration([3]); @@ -515,12 +544,13 @@ export default function CreateTourPage() { // Generate low-precision geohash for city-level location (4 chars = ~39km) const geohash = hasCoords ? ngeohash.encode(lat, lng, 4) : ''; - // Create unique identifier - const identifier = title + // Reuse the identifier when editing (keeps the same addressable event & + // URL); otherwise mint a fresh one. + const identifier = existing?.identifier ?? (title .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, '') - .slice(0, 50) + '-' + Date.now().toString(36); + .slice(0, 50) + '-' + Date.now().toString(36)); // Build tags const tags: string[][] = [ @@ -707,9 +737,32 @@ export default function CreateTourPage() { : `Skate Tour: ${title.trim()} in ${locationText}`; tags.push(['alt', altText]); - // Featured listing (optional, paid) + // Featured listing (optional, paid). When editing, keep whatever featured + // window the tour already has so an edit never silently drops paid status. + const existingFeaturedTag = existing?.event.tags.find(([n]) => n === 'featured'); if (isFeatured && featuredUntilSeconds) { tags.push(['featured', String(featuredUntilSeconds)]); + } else if (isEditing && existingFeaturedTag) { + tags.push(existingFeaturedTag); + } + + // Preserve any tag this form doesn't manage (e.g. sponsors, or tags set by + // other clients) so an edit never strips them β€” kind 30823 is addressable, + // so re-publishing REPLACES the whole tag set. + if (isEditing && existing) { + const managed = new Set([ + 'd', 'title', 't', 'tour-category', 'location', 'meeting-point', + 'meeting-point-coords', 'g', 'duration', 'skill-level', 'max-group', + 'price', 'available', 'languages', 'image', 'shop', 'shop-name', + 'shop-discount', 'cities', 'countries', 'transport', 'accommodation', + 'organizer-contact', 'start-date', 'end-date', 'entry-fee', + 'prize-pool', 'prize-dist', 'max-teams', 'min-team-size', + 'max-team-size', 'challenge', 'clip-sources', 'film-release', + 'film-release-date', 'zap-split', 'alt', 'featured', + ]); + for (const tag of existing.event.tags) { + if (!managed.has(tag[0])) tags.push(tag); + } } await createEvent({ @@ -718,13 +771,17 @@ export default function CreateTourPage() { tags, }); - const toastMessages = { - local: { title: '🏠 Local Tour Created!', description: 'Your tour is now visible. Skaters can book via DM!' }, - national: { title: '🚐 National Tour Created!', description: 'Your road trip is live! Start coordinating with interested skaters.' }, - international: { title: '✈️ International Tour Created!', description: 'Your global adventure is listed! Connect with skaters worldwide.' }, - destruction: { title: 'πŸ’€ WORLD DESTRUCTION TOUR CREATED!', description: 'Teams can register. Challenges revealed on start date!' }, - }; - toast(toastMessages[tourCategory]); + if (isEditing) { + toast({ title: 'Tour updated!', description: 'Your changes have been published.' }); + } else { + const toastMessages = { + local: { title: '🏠 Local Tour Created!', description: 'Your tour is now visible. Skaters can book via DM!' }, + national: { title: '🚐 National Tour Created!', description: 'Your road trip is live! Start coordinating with interested skaters.' }, + international: { title: '✈️ International Tour Created!', description: 'Your global adventure is listed! Connect with skaters worldwide.' }, + destruction: { title: 'πŸ’€ WORLD DESTRUCTION TOUR CREATED!', description: 'Teams can register. Challenges revealed on start date!' }, + }; + toast(toastMessages[tourCategory]); + } navigate(`/tour/${user?.pubkey}/${identifier}`); } catch (error) { @@ -845,7 +902,7 @@ export default function CreateTourPage() { fontFamily: 'Impact, "Arial Black", sans-serif', color: TOUR_CATEGORIES[tourCategory].color, textShadow: `0 0 10px ${TOUR_CATEGORIES[tourCategory].color}50`, - }}>Create a Tour + }}>{isEditing ? 'Edit Tour' : 'Create a Tour'}

{TOUR_CATEGORIES[tourCategory].description}

@@ -2193,14 +2250,18 @@ export default function CreateTourPage() { - {/* Optional featured upgrade β€” tour posts for free regardless */} - + {/* Optional featured upgrade β€” tour posts for free regardless. + Hidden while editing: an existing featured window is preserved + automatically, and the paid upsell doesn't belong in an edit. */} + {!isEditing && ( + + )} {/* Submit */} diff --git a/src/pages/EditTourPage.tsx b/src/pages/EditTourPage.tsx new file mode 100644 index 0000000..29fba52 --- /dev/null +++ b/src/pages/EditTourPage.tsx @@ -0,0 +1,74 @@ +import { useParams } from 'react-router-dom'; +import { Layout } from '@/components/Layout'; +import { Card, CardContent } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { LoginArea } from '@/components/auth/LoginArea'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useTour } from '@/hooks/useTours'; +import { Route, AlertTriangle } from 'lucide-react'; +import CreateTourPage from './CreateTourPage'; +import NotFound from './NotFound'; + +/** + * Edit an existing tour. Loads the tour by pubkey + identifier and hands it to + * CreateTourPage as `existing`, which prefills every field, reuses the same + * addressable identifier, and preserves unmanaged tags on save. Only the tour's + * owner may edit. + */ +export default function EditTourPage() { + const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>(); + const { user } = useCurrentUser(); + const { data: tour, isLoading, error } = useTour(identifier || '', pubkey); + + if (isLoading) { + return ( + +
+ + + + +
+
+ ); + } + + if (error || !tour) { + return ; + } + + if (!user) { + return ( + +
+ + + +

Login Required

+

You need to log in to edit this tour.

+ +
+
+
+
+ ); + } + + if (user.pubkey !== tour.pubkey) { + return ( + +
+ + + +

Access Denied

+

You can only edit tours you created.

+
+
+
+
+ ); + } + + return ; +} diff --git a/src/pages/TourDetailPage.tsx b/src/pages/TourDetailPage.tsx index 9d0616e..9c46453 100644 --- a/src/pages/TourDetailPage.tsx +++ b/src/pages/TourDetailPage.tsx @@ -30,6 +30,7 @@ import { Flag, Store, Percent, + Pencil, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { SponsorSlot } from '@/components/SponsorSlot'; @@ -132,12 +133,26 @@ export default function TourDetailPage() { Back to Map - +
+ {user?.pubkey === tour.pubkey && ( + + )} + +
{/* Main card */}