diff --git a/src/components/CraftProfileForm.tsx b/src/components/CraftProfileForm.tsx index 57704e2..a2ef469 100644 --- a/src/components/CraftProfileForm.tsx +++ b/src/components/CraftProfileForm.tsx @@ -23,6 +23,8 @@ import { CrewLinkField } from '@/components/CrewLinkField'; import { LabProUpgrade } from '@/components/LabProUpgrade'; import { usePlatformFee } from '@/hooks/usePlatformFee'; import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile'; +import { useLabDraft } from '@/hooks/useLabDraft'; +import { LabDraftBanner } from '@/components/LabDraftBanner'; import { isValidNoffer, type EntityType } from '@/lib/types'; import type { LabOption } from '@/lib/labExtras'; import { labProfilePathFromKind } from '@/lib/labRoutes'; @@ -137,6 +139,20 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) { const [extraTags, setExtraTags] = useState([]); const [previewOpen, setPreviewOpen] = useState(false); + const draft = useLabDraft( + `craft:${kind}:${identifier}`, + { titleInput, bio, location, latitude, longitude, selected, image, experience, rate, phone, email, website, portfolio, social, noffer, entityType, members, crewRef, extras, extraTags }, + (d) => { + setTitleInput(d.titleInput); setBio(d.bio); setLocation(d.location); + setLatitude(d.latitude); setLongitude(d.longitude); setSelected(d.selected); + setImage(d.image); setExperience(d.experience); setRate(d.rate); + setPhone(d.phone); setEmail(d.email); setWebsite(d.website); + setPortfolio(d.portfolio); setSocial(d.social); setNoffer(d.noffer); + setEntityType(d.entityType); setMembers(d.members); setCrewRef(d.crewRef); + setExtras(d.extras); setExtraTags(d.extraTags); + }, + ); + // Load + prefill an existing profile so this is an EDIT, not a blind overwrite. const { data: existingProfile } = useMyLabProfileEvent(kind, identifier); useEffect(() => { @@ -310,6 +326,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) { if (isPro && goPro && featuredUntilSeconds) tags.push(['featured', String(featuredUntilSeconds)]); await createEvent({ kind, content: bio.trim(), tags }); + draft.clear(); toast({ title: t(existingProfile ? 'labUi.craftForm.updated' : 'labUi.craftForm.created'), description: t('labUi.become.profilePublished') }); navigate(`${labProfilePathFromKind(kind, user?.pubkey ?? '', identifier)}${existingProfile ? '' : '?published=1'}`); } catch (error) { @@ -392,6 +409,8 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {

)} + {draft.resumable && } +
{/* Image + Bio */} diff --git a/src/components/LabDraftBanner.tsx b/src/components/LabDraftBanner.tsx new file mode 100644 index 0000000..f77ebc0 --- /dev/null +++ b/src/components/LabDraftBanner.tsx @@ -0,0 +1,34 @@ +import { History, X } from 'lucide-react'; +import { useTranslation } from '@/contexts/language'; + +/** + * "You have an unsaved draft" banner shown at the top of a Lab profile form when + * `useLabDraft` found a saved draft. Resume restores it; Discard throws it away. + */ +export function LabDraftBanner({ onResume, onDiscard }: { onResume: () => void; onDiscard: () => void }) { + const { t } = useTranslation(); + return ( +
+ +
+

{t('labUi.draft.title')}

+

{t('labUi.draft.body')}

+
+ + +
+ ); +} diff --git a/src/hooks/useLabDraft.ts b/src/hooks/useLabDraft.ts new file mode 100644 index 0000000..28d99d1 --- /dev/null +++ b/src/hooks/useLabDraft.ts @@ -0,0 +1,62 @@ +import { useEffect, useRef, useState } from 'react'; + +const PREFIX = 'skatespots:lab-draft:'; + +/** + * Autosave a Lab profile form so a long create/edit isn't lost on nav or reload. + * + * - Persists `snapshot` to localStorage (content-debounced: the serialized + * string only changes when the draft actually changes). + * - A saved draft is only offered as **resumable** when it *differs* from the + * current form state — so merely opening an existing profile (whose prefill + * matches the saved draft) never shows a spurious "resume" banner, and a + * draft can never silently clobber a freshly loaded profile. + * - Call `clear()` after a successful publish. + */ +export function useLabDraft(key: string, snapshot: T, apply: (draft: T) => void) { + const storageKey = PREFIX + key; + const [loaded, setLoaded] = useState(null); + const firstRun = useRef(true); + const serialized = JSON.stringify(snapshot); + + // Load any saved draft once. + useEffect(() => { + try { + const raw = localStorage.getItem(storageKey); + if (raw) setLoaded(JSON.parse(raw) as T); + } catch { + // ignore malformed/blocked storage + } + }, [storageKey]); + + // Persist on change (skip the initial empty paint). + useEffect(() => { + if (firstRun.current) { + firstRun.current = false; + return; + } + try { + localStorage.setItem(storageKey, serialized); + } catch { + // storage may be full/blocked — a lost draft is not fatal + } + }, [serialized, storageKey]); + + const resume = () => { + if (!loaded) return; + apply(loaded); + setLoaded(null); + }; + + const discard = () => { + setLoaded(null); + try { localStorage.removeItem(storageKey); } catch { /* ignore */ } + }; + + const clear = () => { + try { localStorage.removeItem(storageKey); } catch { /* ignore */ } + }; + + const resumable = loaded !== null && JSON.stringify(loaded) !== serialized; + return { resumable, resume, discard, clear }; +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index ec6ab23..ec7f0ce 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -2005,6 +2005,12 @@ labUi: { portfolio: 'Portfolio', note: 'Preview only — nothing is published yet.', }, + draft: { + title: 'You have an unsaved draft', + body: 'Pick up where you left off — nothing is published until you press Publish.', + resume: 'Resume draft', + discard: 'Discard draft', + }, offerTattoo: 'Offer Tattoo', offerTraining: 'Offer Training / Physio', organizeEvents: 'Organize Events', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 2652cfb..3078fb3 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -2005,6 +2005,12 @@ labUi: { portfolio: 'Portafolio', note: 'Solo vista previa — aún no se ha publicado nada.', }, + draft: { + title: 'Tienes un borrador sin guardar', + body: 'Continúa donde lo dejaste — nada se publica hasta que pulses Publicar.', + resume: 'Reanudar borrador', + discard: 'Descartar borrador', + }, offerTattoo: 'Ofrecer tatuajes', offerTraining: 'Ofrecer entrenamiento / fisio', organizeEvents: 'Organizar eventos', diff --git a/src/pages/BecomeBuilderPage.tsx b/src/pages/BecomeBuilderPage.tsx index c996bc6..4075ced 100644 --- a/src/pages/BecomeBuilderPage.tsx +++ b/src/pages/BecomeBuilderPage.tsx @@ -48,6 +48,8 @@ import { usePlatformFee } from '@/hooks/usePlatformFee'; import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile'; import { LabExtrasFields } from '@/components/LabExtrasFields'; import { LabProfilePreview } from '@/components/LabProfilePreview'; +import { LabDraftBanner } from '@/components/LabDraftBanner'; +import { useLabDraft } from '@/hooks/useLabDraft'; import { labProfilePath } from '@/lib/labRoutes'; import { pickRateUnits } from '@/lib/labExtras'; @@ -135,12 +137,47 @@ export default function BecomeBuilderPage() { const [prefilled, setPrefilled] = useState(false); const [extraTags, setExtraTags] = useState([]); const [previewOpen, setPreviewOpen] = useState(false); + const [lead_time, setLeadTime] = useState(''); const [warranty, setWarranty] = useState(''); const [existingGeohash, setExistingGeohash] = useState(undefined); // Prefill from an existing profile so this is an edit, not a blind overwrite. const { data: existingProfile } = useMyLabProfileEvent(SKATE_BUILDER_KIND, 'builder-profile'); + const draft = useLabDraft( + `craft:builder`, + { titleInput, bio, location, latitude, longitude, selectedServices, selectedSkills, selectedScale, gear, serviceArea, insured, image, experience, rate, phone, email, website, portfolio, social, noffer, entityType, members, crewRef, selectedSpots, lead_time, warranty, extraTags }, + (d) => { + setTitleInput(d.titleInput); + setBio(d.bio); + setLocation(d.location); + setLatitude(d.latitude); + setLongitude(d.longitude); + setSelectedServices(d.selectedServices); + setSelectedSkills(d.selectedSkills); + setSelectedScale(d.selectedScale); + setGear(d.gear); + setServiceArea(d.serviceArea); + setInsured(d.insured); + setImage(d.image); + setExperience(d.experience); + setRate(d.rate); + setPhone(d.phone); + setEmail(d.email); + setWebsite(d.website); + setPortfolio(d.portfolio); + setSocial(d.social); + setNoffer(d.noffer); + setEntityType(d.entityType); + setMembers(d.members); + setCrewRef(d.crewRef); + setSelectedSpots(d.selectedSpots); + setLeadTime(d.lead_time); + setWarranty(d.warranty); + setExtraTags(d.extraTags); + }, + ); + useEffect(() => { if (prefilled || !existingProfile) return; setPrefilled(true); @@ -325,6 +362,7 @@ export default function BecomeBuilderPage() { content: bio.trim(), tags, }); + draft.clear(); toast({ title: existingProfile ? t('labUi.builderForm.updated') : t('labUi.builderForm.created'), description: t('labUi.become.profilePublished') }); navigate(`${labProfilePath('builder', user?.pubkey ?? '', identifier)}${existingProfile ? '' : '?published=1'}`); @@ -426,6 +464,8 @@ export default function BecomeBuilderPage() {
+ {draft.resumable && } + diff --git a/src/pages/BecomeCoachPage.tsx b/src/pages/BecomeCoachPage.tsx index 12fe671..d0da735 100644 --- a/src/pages/BecomeCoachPage.tsx +++ b/src/pages/BecomeCoachPage.tsx @@ -33,6 +33,8 @@ import { cn } from '@/lib/utils'; import { GraduationCap, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Check, Eye } from 'lucide-react'; import { LabProUpgrade } from '@/components/LabProUpgrade'; import { LabProfilePreview } from '@/components/LabProfilePreview'; +import { LabDraftBanner } from '@/components/LabDraftBanner'; +import { useLabDraft } from '@/hooks/useLabDraft'; import { labProfilePath } from '@/lib/labRoutes'; import { usePlatformFee } from '@/hooks/usePlatformFee'; import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile'; @@ -157,6 +159,49 @@ export default function BecomeCoachPage() { const [titleInput, setTitleInput] = useState(''); const [prefilled, setPrefilled] = useState(false); const [previewOpen, setPreviewOpen] = useState(false); + + const draft = useLabDraft( + `craft:coach`, + { titleInput, bio, location, latitude, longitude, selectedSkills, image, experience, rate, rateAmount, rateCurrency, rateUnit, phone, email, website, instagram, noffer, entityType, members, crewRef, selectedSpots, formats, levels, ageGroups, credentials, certNote, languages, availability, booking, paymentMethods, paymentNote, bookingFee, bookingFeeCurrency, payWhatYouCan, photos, video }, + (d) => { + setTitleInput(d.titleInput); + setBio(d.bio); + setLocation(d.location); + setLatitude(d.latitude); + setLongitude(d.longitude); + setSelectedSkills(d.selectedSkills); + setImage(d.image); + setExperience(d.experience); + setRate(d.rate); + setRateAmount(d.rateAmount); + setRateCurrency(d.rateCurrency); + setRateUnit(d.rateUnit); + setPhone(d.phone); + setEmail(d.email); + setWebsite(d.website); + setInstagram(d.instagram); + setNoffer(d.noffer); + setEntityType(d.entityType); + setMembers(d.members); + setCrewRef(d.crewRef); + setSelectedSpots(d.selectedSpots); + setFormats(d.formats); + setLevels(d.levels); + setAgeGroups(d.ageGroups); + setCredentials(d.credentials); + setCertNote(d.certNote); + setLanguages(d.languages); + setAvailability(d.availability); + setBooking(d.booking); + setPaymentMethods(d.paymentMethods); + setPaymentNote(d.paymentNote); + setBookingFee(d.bookingFee); + setBookingFeeCurrency(d.bookingFeeCurrency); + setPayWhatYouCan(d.payWhatYouCan); + setPhotos(d.photos); + setVideo(d.video); + }, + ); const [existingGeohash, setExistingGeohash] = useState(undefined); // Prefill from an existing profile so this is an edit, not a blind overwrite. @@ -412,6 +457,7 @@ export default function BecomeCoachPage() { content: bio.trim(), tags, }); + draft.clear(); toast({ title: existingProfile ? t('labUi.coachForm.updated') : t('labUi.coachForm.created'), description: t('labUi.become.profilePublished') }); navigate(`${labProfilePath('coach', user?.pubkey ?? '', identifier)}${existingProfile ? '' : '?published=1'}`); @@ -523,6 +569,8 @@ export default function BecomeCoachPage() { + {draft.resumable && } + diff --git a/src/pages/BecomeEditorPage.tsx b/src/pages/BecomeEditorPage.tsx index 07d0b0a..4b301f3 100644 --- a/src/pages/BecomeEditorPage.tsx +++ b/src/pages/BecomeEditorPage.tsx @@ -39,6 +39,8 @@ import { usePlatformFee } from '@/hooks/usePlatformFee'; import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile'; import { LabExtrasFields } from '@/components/LabExtrasFields'; import { LabProfilePreview } from '@/components/LabProfilePreview'; +import { LabDraftBanner } from '@/components/LabDraftBanner'; +import { useLabDraft } from '@/hooks/useLabDraft'; import { labProfilePath } from '@/lib/labRoutes'; import { pickRateUnits } from '@/lib/labExtras'; @@ -91,12 +93,44 @@ export default function BecomeEditorPage() { const [prefilled, setPrefilled] = useState(false); const [extraTags, setExtraTags] = useState([]); const [previewOpen, setPreviewOpen] = useState(false); + const [turnaround, setTurnaround] = useState(''); const [deliverables, setDeliverables] = useState(''); const [existingGeohash, setExistingGeohash] = useState(undefined); // Prefill from an existing profile so this is an edit, not a blind overwrite. const { data: existingProfile } = useMyLabProfileEvent(SKATE_EDITOR_KIND, 'editor-profile'); + const draft = useLabDraft( + `craft:editor`, + { titleInput, bio, location, latitude, longitude, selectedServices, selectedSkills, image, experience, rate, phone, email, website, portfolio, social, noffer, entityType, members, crewRef, hardware, software, turnaround, deliverables, extraTags }, + (d) => { + setTitleInput(d.titleInput); + setBio(d.bio); + setLocation(d.location); + setLatitude(d.latitude); + setLongitude(d.longitude); + setSelectedServices(d.selectedServices); + setSelectedSkills(d.selectedSkills); + setImage(d.image); + setExperience(d.experience); + setRate(d.rate); + setPhone(d.phone); + setEmail(d.email); + setWebsite(d.website); + setPortfolio(d.portfolio); + setSocial(d.social); + setNoffer(d.noffer); + setEntityType(d.entityType); + setMembers(d.members); + setCrewRef(d.crewRef); + setHardware(d.hardware); + setSoftware(d.software); + setTurnaround(d.turnaround); + setDeliverables(d.deliverables); + setExtraTags(d.extraTags); + }, + ); + useEffect(() => { if (prefilled || !existingProfile) return; setPrefilled(true); @@ -250,6 +284,7 @@ export default function BecomeEditorPage() { } await createEvent({ kind: SKATE_EDITOR_KIND, content: bio.trim(), tags }); + draft.clear(); toast({ title: existingProfile ? t('labUi.editorForm.updated') : t('labUi.editorForm.created'), description: t('labUi.editorForm.published') }); navigate(`${labProfilePath('editor', user?.pubkey ?? '', identifier)}${existingProfile ? '' : '?published=1'}`); @@ -344,6 +379,8 @@ export default function BecomeEditorPage() { + {draft.resumable && } + diff --git a/src/pages/BecomeFilmerPage.tsx b/src/pages/BecomeFilmerPage.tsx index 0a80174..6b18039 100644 --- a/src/pages/BecomeFilmerPage.tsx +++ b/src/pages/BecomeFilmerPage.tsx @@ -33,6 +33,8 @@ import { usePlatformFee } from '@/hooks/usePlatformFee'; import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile'; import { LabExtrasFields } from '@/components/LabExtrasFields'; import { LabProfilePreview } from '@/components/LabProfilePreview'; +import { LabDraftBanner } from '@/components/LabDraftBanner'; +import { useLabDraft } from '@/hooks/useLabDraft'; import { labProfilePath } from '@/lib/labRoutes'; import { pickRateUnits } from '@/lib/labExtras'; @@ -86,6 +88,7 @@ export default function BecomeFilmerPage() { const [prefilled, setPrefilled] = useState(false); const [extraTags, setExtraTags] = useState([]); const [previewOpen, setPreviewOpen] = useState(false); + const [turnaround, setTurnaround] = useState(''); const [deliverables, setDeliverables] = useState(''); const [drone, setDrone] = useState(''); @@ -93,6 +96,38 @@ export default function BecomeFilmerPage() { // Prefill from an existing profile so this is an edit, not a blind overwrite. const { data: existingProfile } = useMyLabProfileEvent(SKATE_FILMER_KIND, 'filming-profile'); + const draft = useLabDraft( + `craft:filmer`, + { titleInput, bio, location, latitude, longitude, selectedServices, selectedSkills, image, experience, rate, phone, email, website, portfolio, social, noffer, entityType, members, crewRef, gear, selectedSpots, turnaround, deliverables, drone, extraTags }, + (d) => { + setTitleInput(d.titleInput); + setBio(d.bio); + setLocation(d.location); + setLatitude(d.latitude); + setLongitude(d.longitude); + setSelectedServices(d.selectedServices); + setSelectedSkills(d.selectedSkills); + setImage(d.image); + setExperience(d.experience); + setRate(d.rate); + setPhone(d.phone); + setEmail(d.email); + setWebsite(d.website); + setPortfolio(d.portfolio); + setSocial(d.social); + setNoffer(d.noffer); + setEntityType(d.entityType); + setMembers(d.members); + setCrewRef(d.crewRef); + setGear(d.gear); + setSelectedSpots(d.selectedSpots); + setTurnaround(d.turnaround); + setDeliverables(d.deliverables); + setDrone(d.drone); + setExtraTags(d.extraTags); + }, + ); + useEffect(() => { if (prefilled || !existingProfile) return; setPrefilled(true); @@ -282,6 +317,7 @@ export default function BecomeFilmerPage() { content: bio.trim(), tags, }); + draft.clear(); toast({ title: existingProfile ? t('labUi.filmerForm.updated') : t('labUi.filmerForm.created'), description: t('labUi.become.profilePublished') }); navigate(`${labProfilePath('filmer', user?.pubkey ?? '', identifier)}${existingProfile ? '' : '?published=1'}`); @@ -393,6 +429,8 @@ export default function BecomeFilmerPage() { + {draft.resumable && } + diff --git a/src/pages/BecomePhotographerPage.tsx b/src/pages/BecomePhotographerPage.tsx index b3a1932..27339bb 100644 --- a/src/pages/BecomePhotographerPage.tsx +++ b/src/pages/BecomePhotographerPage.tsx @@ -53,6 +53,8 @@ import { usePlatformFee } from '@/hooks/usePlatformFee'; import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile'; import { LabExtrasFields } from '@/components/LabExtrasFields'; import { LabProfilePreview } from '@/components/LabProfilePreview'; +import { LabDraftBanner } from '@/components/LabDraftBanner'; +import { useLabDraft } from '@/hooks/useLabDraft'; import { labProfilePath } from '@/lib/labRoutes'; import { pickRateUnits } from '@/lib/labExtras'; @@ -145,12 +147,47 @@ export default function BecomePhotographerPage() { const [prefilled, setPrefilled] = useState(false); const [extraTags, setExtraTags] = useState([]); const [previewOpen, setPreviewOpen] = useState(false); + const [turnaround, setTurnaround] = useState(''); const [deliverables, setDeliverables] = useState(''); const [existingGeohash, setExistingGeohash] = useState(undefined); // Prefill from an existing profile so this is an edit, not a blind overwrite. const { data: existingProfile } = useMyLabProfileEvent(SKATE_PHOTOGRAPHER_KIND, 'photography-profile'); + const draft = useLabDraft( + `craft:photographer`, + { titleInput, bio, location, latitude, longitude, selectedServices, selectedCameras, selectedLenses, selectedLighting, selectedSupport, selectedStyles, gear, image, experience, rate, phone, email, website, portfolio, social, noffer, entityType, members, crewRef, turnaround, deliverables, extraTags }, + (d) => { + setTitleInput(d.titleInput); + setBio(d.bio); + setLocation(d.location); + setLatitude(d.latitude); + setLongitude(d.longitude); + setSelectedServices(d.selectedServices); + setSelectedCameras(d.selectedCameras); + setSelectedLenses(d.selectedLenses); + setSelectedLighting(d.selectedLighting); + setSelectedSupport(d.selectedSupport); + setSelectedStyles(d.selectedStyles); + setGear(d.gear); + setImage(d.image); + setExperience(d.experience); + setRate(d.rate); + setPhone(d.phone); + setEmail(d.email); + setWebsite(d.website); + setPortfolio(d.portfolio); + setSocial(d.social); + setNoffer(d.noffer); + setEntityType(d.entityType); + setMembers(d.members); + setCrewRef(d.crewRef); + setTurnaround(d.turnaround); + setDeliverables(d.deliverables); + setExtraTags(d.extraTags); + }, + ); + useEffect(() => { if (prefilled || !existingProfile) return; setPrefilled(true); @@ -349,6 +386,7 @@ export default function BecomePhotographerPage() { content: bio.trim(), tags, }); + draft.clear(); toast({ title: existingProfile ? t('labUi.photoForm.updated') : t('labUi.photoForm.created'), description: t('labUi.become.profilePublished') }); navigate(`${labProfilePath('photographer', user?.pubkey ?? '', identifier)}${existingProfile ? '' : '?published=1'}`); @@ -450,6 +488,8 @@ export default function BecomePhotographerPage() { + {draft.resumable && } +