Autosave Lab profile drafts so long forms survive nav/reload

- useLabDraft persists the form snapshot to localStorage (content-debounced)
  and only offers a saved draft as resumable when it differs from the current
  state, so opening an existing profile never shows a spurious resume banner and
  a draft can't silently clobber a freshly loaded profile.
- LabDraftBanner offers Resume / Discard; wired into CraftProfileForm (8 crafts)
  and the coach + filmer/editor/photographer/builder pages.
- The draft clears on a successful publish.
This commit is contained in:
SkateSpots 2026-10-04 11:35:41 +07:00
parent c794d5fd18
commit 3e84310384
10 changed files with 330 additions and 0 deletions

View file

@ -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<string[][]>([]);
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 }) {
</p>
)}
{draft.resumable && <LabDraftBanner onResume={draft.resume} onDiscard={draft.discard} />}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Image + Bio */}
<Card className="bg-black/60 border-white/10">

View file

@ -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 (
<div className="mb-4 flex items-center gap-3 rounded-xl border border-[#ccff00]/40 bg-[#ccff00]/5 p-3">
<History className="h-5 w-5 flex-shrink-0 text-[#ccff00]" />
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-white">{t('labUi.draft.title')}</p>
<p className="text-xs text-gray-400">{t('labUi.draft.body')}</p>
</div>
<button
type="button"
onClick={onResume}
className="min-h-0 flex-shrink-0 rounded-lg bg-[#ccff00] px-3 py-1.5 text-xs font-black uppercase text-black hover:bg-[#ccff00]/90"
>
{t('labUi.draft.resume')}
</button>
<button
type="button"
onClick={onDiscard}
aria-label={t('labUi.draft.discard')}
className="min-h-0 min-w-0 flex-shrink-0 rounded-full p-1 text-gray-500 hover:bg-white/10 hover:text-white"
>
<X className="h-4 w-4" />
</button>
</div>
);
}

62
src/hooks/useLabDraft.ts Normal file
View file

@ -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<T>(key: string, snapshot: T, apply: (draft: T) => void) {
const storageKey = PREFIX + key;
const [loaded, setLoaded] = useState<T | null>(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 };
}

View file

@ -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',

View file

@ -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',

View file

@ -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<string[][]>([]);
const [previewOpen, setPreviewOpen] = useState(false);
const [lead_time, setLeadTime] = useState('');
const [warranty, setWarranty] = useState('');
const [existingGeohash, setExistingGeohash] = useState<string | undefined>(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() {
</Card>
</div>
{draft.resumable && <LabDraftBanner onResume={draft.resume} onDiscard={draft.discard} />}
<form onSubmit={handleSubmit}>
<Card className="bg-gray-900 border-gray-700">
<CardHeader>

View file

@ -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<string | undefined>(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() {
</Card>
</div>
{draft.resumable && <LabDraftBanner onResume={draft.resume} onDiscard={draft.discard} />}
<form onSubmit={handleSubmit}>
<Card className="bg-gray-900 border-gray-700">
<CardHeader>

View file

@ -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<string[][]>([]);
const [previewOpen, setPreviewOpen] = useState(false);
const [turnaround, setTurnaround] = useState('');
const [deliverables, setDeliverables] = useState('');
const [existingGeohash, setExistingGeohash] = useState<string | undefined>(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() {
</Card>
</div>
{draft.resumable && <LabDraftBanner onResume={draft.resume} onDiscard={draft.discard} />}
<form onSubmit={handleSubmit}>
<Card className="bg-gray-900 border-gray-700">
<CardHeader>

View file

@ -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<string[][]>([]);
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() {
</Card>
</div>
{draft.resumable && <LabDraftBanner onResume={draft.resume} onDiscard={draft.discard} />}
<form onSubmit={handleSubmit}>
<Card className="bg-gray-900 border-gray-700">
<CardHeader>

View file

@ -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<string[][]>([]);
const [previewOpen, setPreviewOpen] = useState(false);
const [turnaround, setTurnaround] = useState('');
const [deliverables, setDeliverables] = useState('');
const [existingGeohash, setExistingGeohash] = useState<string | undefined>(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() {
</Card>
</div>
{draft.resumable && <LabDraftBanner onResume={draft.resume} onDiscard={draft.discard} />}
<form onSubmit={handleSubmit}>
<Card className="bg-gray-900 border-gray-700">
<CardHeader>