Skip to content

Commit 02e7aa0

Browse files
authored
Save Draft / restore flow for Create Contest page (#14371)
1 parent f5b09c6 commit 02e7aa0

2 files changed

Lines changed: 263 additions & 84 deletions

File tree

packages/web/src/pages/host-remix-contest-page/HostRemixContestPage.tsx

Lines changed: 168 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
Box,
2525
Button,
2626
Flex,
27+
Hint,
2728
IconClose,
2829
IconCloudUpload,
2930
IconKebabHorizontal,
@@ -58,6 +59,7 @@ import { AttachVideoModal } from '../fan-club-detail-page/components/AttachVideo
5859

5960
import { AddSourceTrackModal } from './AddSourceTrackModal'
6061
import { ManageStemsModal } from './ManageStemsModal'
62+
import { useContestDraft } from './useContestDraft'
6163
import { useUploadContestCover } from './useUploadContestCover'
6264

6365
const { CONTESTS_PAGE } = route
@@ -95,6 +97,8 @@ const messages = {
9597
sourceTracksSectionLabel: 'SOURCE TRACK',
9698
addTrack: '+ Add Track',
9799
cancel: 'Cancel',
100+
saveDraft: 'Save Draft',
101+
draftSaved: 'Draft saved',
98102
launch: 'Launch',
99103
save: 'Save',
100104
turnOff: 'Delete Contest',
@@ -108,7 +112,11 @@ const messages = {
108112
manageStems: 'Manage Stems',
109113
remove: 'Remove',
110114
noStems: 'No Stems',
111-
stemsCount: (n: number) => `${n} Stem${n === 1 ? '' : 's'}`
115+
stemsCount: (n: number) => `${n} Stem${n === 1 ? '' : 's'}`,
116+
draftRestoreTitle: (savedAt: string) =>
117+
`You have an unsaved draft from ${savedAt}.`,
118+
restoreDraft: 'Restore Draft',
119+
discardDraft: 'Discard'
112120
}
113121

114122
const SOURCE_TRACK_ROW_HEIGHT = 56
@@ -177,74 +185,50 @@ export const HostRemixContestPage = () => {
177185
const primaryPermalink = primaryTrack?.permalink ?? ''
178186

179187
// ---------------------------------------------------------------------------
180-
// Draft persistence — keyed by handle/slug (or 'new' for the trackless
181-
// route) so a user can navigate to edit a source track and return without
182-
// losing in-flight form state. Cleared on launch / cancel / delete.
188+
// Draft persistence
183189
// ---------------------------------------------------------------------------
184-
const draftStorageKey = useMemo(
185-
() =>
186-
handle && slug
187-
? `host-remix-contest-draft:${handle}/${slug}`
188-
: 'host-remix-contest-draft:new',
189-
[handle, slug]
190-
)
191-
const draft = useMemo(() => {
192-
if (typeof window === 'undefined') return null
193-
try {
194-
const raw = window.sessionStorage.getItem(draftStorageKey)
195-
return raw ? JSON.parse(raw) : null
196-
} catch {
197-
return null
198-
}
199-
// Read once on mount; ignore deps churn.
200-
// eslint-disable-next-line react-hooks/exhaustive-deps
201-
}, [draftStorageKey])
190+
// Drafts only apply to the create flow — edit mode is driven by the
191+
// live event. Scope per user and per primary track (or "trackless" for
192+
// the /host-contest entry).
193+
const { existingDraft, saveDraft, clearDraft } = useContestDraft({
194+
userId: currentUserId,
195+
primaryTrackId,
196+
enabled: !isEdit
197+
})
198+
const [showDraftBanner, setShowDraftBanner] = useState(!!existingDraft)
199+
const [draftSavedAt, setDraftSavedAt] = useState<string | null>(null)
202200

203201
// ---------------------------------------------------------------------------
204202
// Form state
205203
// ---------------------------------------------------------------------------
206-
const [title, setTitle] = useState(
207-
draft?.title ?? existingEventData.title ?? ''
208-
)
204+
const [title, setTitle] = useState(existingEventData.title ?? '')
209205
const [description, setDescription] = useState(
210-
draft?.description ?? existingEventData.description ?? ''
206+
existingEventData.description ?? ''
211207
)
212208
const [descriptionError, setDescriptionError] = useState(false)
213-
const [videoUrl, setVideoUrl] = useState(
214-
draft?.videoUrl ?? existingEventData.videoUrl ?? ''
215-
)
209+
const [videoUrl, setVideoUrl] = useState(existingEventData.videoUrl ?? '')
216210
const [showAttachVideoModal, setShowAttachVideoModal] = useState(false)
217211
const [coverPhotoUrl, setCoverPhotoUrl] = useState(
218-
draft?.coverPhotoUrl ?? existingEventData.coverPhotoUrl ?? ''
219-
)
220-
const [prizeInfo, setPrizeInfo] = useState(
221-
draft?.prizeInfo ?? existingEventData.prizeInfo ?? ''
212+
existingEventData.coverPhotoUrl ?? ''
222213
)
214+
const [prizeInfo, setPrizeInfo] = useState(existingEventData.prizeInfo ?? '')
223215

224-
const initialEndDate = draft?.contestEndDate
225-
? dayjs(draft.contestEndDate)
226-
: remixContest
227-
? dayjs(remixContest.endDate)
228-
: null
216+
const initialEndDate = remixContest ? dayjs(remixContest.endDate) : null
229217
const [contestEndDate, setContestEndDate] = useState<dayjs.Dayjs | null>(
230218
initialEndDate
231219
)
232220
const [endDateTouched, setEndDateTouched] = useState(false)
233221
const [endDateError, setEndDateError] = useState(false)
234222
const [timeValue, setTimeValue] = useState(
235-
draft?.timeValue ??
236-
(initialEndDate ? dayjs(initialEndDate).format('hh:mm') : '')
223+
initialEndDate ? dayjs(initialEndDate).format('hh:mm') : ''
237224
)
238225
const [timeError, setTimeError] = useState(false)
239226
const [meridianValue, setMeridianValue] = useState(
240-
draft?.meridianValue ??
241-
(initialEndDate ? dayjs(initialEndDate).format('A') : '')
227+
initialEndDate ? dayjs(initialEndDate).format('A') : ''
242228
)
243229

244230
const [sourceTrackIds, setSourceTrackIds] = useState<number[]>(
245-
draft?.sourceTrackIds ??
246-
existingEventData.sourceTrackIds ??
247-
(primaryTrackId ? [primaryTrackId] : [])
231+
existingEventData.sourceTrackIds ?? (primaryTrackId ? [primaryTrackId] : [])
248232
)
249233

250234
// Hydrate form state once `remixContest` resolves.
@@ -253,15 +237,12 @@ export const HostRemixContestPage = () => {
253237
// the form mounts before the React Query fetch finishes, so the initial
254238
// state captures empty values and never picks up the resolved contest
255239
// data — Title, Description, Prizes, Video Link, etc. all rendered blank
256-
// even though the backend had them. Skip if a draft exists (the user
257-
// already started typing) so we don't overwrite in-flight edits.
240+
// even though the backend had them. Drafts are disabled in edit mode
241+
// (the useContestDraft hook is gated on `!isEdit`), so there's no
242+
// in-flight draft to worry about clobbering here.
258243
const hasHydratedRef = useRef(false)
259244
useEffect(() => {
260245
if (hasHydratedRef.current) return
261-
if (draft) {
262-
hasHydratedRef.current = true
263-
return
264-
}
265246
if (!remixContest) return
266247
const data = remixContest.eventData as ContestEventData
267248
if (data.title) setTitle(data.title)
@@ -279,7 +260,7 @@ export const HostRemixContestPage = () => {
279260
setSourceTrackIds(data.sourceTrackIds)
280261
}
281262
hasHydratedRef.current = true
282-
}, [remixContest, draft])
263+
}, [remixContest])
283264

284265
// On the track-scoped route (/:handle/:slug/host-contest) the URL
285266
// identifies the source track, but `useTrackByPermalink` is async — by
@@ -319,30 +300,42 @@ export const HostRemixContestPage = () => {
319300
)
320301
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false)
321302

322-
// Persist form state on every change so the user can leave (e.g. to
323-
// edit a source track) and return without losing what they typed.
303+
// Debounced auto-save: while the user is editing in create mode, persist
304+
// the draft a couple of seconds after they stop typing. Skipped while the
305+
// restore banner is up (the user hasn't decided what to do with their
306+
// existing draft yet) and when the form is entirely empty (no point
307+
// overwriting an existing localStorage entry — discarding it should
308+
// mean discarded).
309+
const hasFormContent =
310+
!!title ||
311+
!!description ||
312+
!!videoUrl ||
313+
!!coverPhotoUrl ||
314+
!!prizeInfo ||
315+
!!contestEndDate ||
316+
sourceTrackIds.length > 0
317+
324318
useEffect(() => {
325-
if (typeof window === 'undefined') return
326-
try {
327-
window.sessionStorage.setItem(
328-
draftStorageKey,
329-
JSON.stringify({
330-
title,
331-
description,
332-
videoUrl,
333-
coverPhotoUrl,
334-
prizeInfo,
335-
contestEndDate: contestEndDate?.toISOString() ?? null,
336-
timeValue,
337-
meridianValue,
338-
sourceTrackIds
339-
})
340-
)
341-
} catch {
342-
/* ignore quota errors */
343-
}
319+
if (isEdit || showDraftBanner || !hasFormContent) return
320+
const timer = window.setTimeout(() => {
321+
saveDraft({
322+
title,
323+
description,
324+
videoUrl,
325+
coverPhotoUrl,
326+
prizeInfo,
327+
contestEndDate: contestEndDate?.toISOString(),
328+
timeValue,
329+
meridianValue,
330+
sourceTrackIds
331+
})
332+
}, 1500)
333+
return () => window.clearTimeout(timer)
344334
}, [
345-
draftStorageKey,
335+
isEdit,
336+
showDraftBanner,
337+
hasFormContent,
338+
saveDraft,
346339
title,
347340
description,
348341
videoUrl,
@@ -354,15 +347,6 @@ export const HostRemixContestPage = () => {
354347
sourceTrackIds
355348
])
356349

357-
const clearDraft = useCallback(() => {
358-
if (typeof window === 'undefined') return
359-
try {
360-
window.sessionStorage.removeItem(draftStorageKey)
361-
} catch {
362-
/* ignore */
363-
}
364-
}, [draftStorageKey])
365-
366350
// ---------------------------------------------------------------------------
367351
// Cover-photo upload + fallback to track artwork
368352
// ---------------------------------------------------------------------------
@@ -435,6 +419,70 @@ export const HostRemixContestPage = () => {
435419
})
436420
}, [])
437421

422+
const handleRestoreDraft = useCallback(() => {
423+
if (!existingDraft) return
424+
if (existingDraft.title !== undefined) setTitle(existingDraft.title)
425+
if (existingDraft.description !== undefined) {
426+
setDescription(existingDraft.description)
427+
}
428+
if (existingDraft.videoUrl !== undefined) {
429+
setVideoUrl(existingDraft.videoUrl)
430+
}
431+
if (existingDraft.coverPhotoUrl !== undefined) {
432+
setCoverPhotoUrl(existingDraft.coverPhotoUrl)
433+
}
434+
if (existingDraft.prizeInfo !== undefined) {
435+
setPrizeInfo(existingDraft.prizeInfo)
436+
}
437+
if (existingDraft.contestEndDate) {
438+
setContestEndDate(dayjs(existingDraft.contestEndDate))
439+
}
440+
if (existingDraft.timeValue !== undefined) {
441+
setTimeValue(existingDraft.timeValue)
442+
}
443+
if (existingDraft.meridianValue !== undefined) {
444+
setMeridianValue(existingDraft.meridianValue)
445+
}
446+
if (existingDraft.sourceTrackIds !== undefined) {
447+
setSourceTrackIds(existingDraft.sourceTrackIds)
448+
}
449+
setShowDraftBanner(false)
450+
}, [existingDraft])
451+
452+
const handleDiscardDraft = useCallback(() => {
453+
clearDraft()
454+
setShowDraftBanner(false)
455+
}, [clearDraft])
456+
457+
const handleSaveDraft = useCallback(() => {
458+
saveDraft({
459+
title,
460+
description,
461+
videoUrl,
462+
coverPhotoUrl,
463+
prizeInfo,
464+
contestEndDate: contestEndDate?.toISOString(),
465+
timeValue,
466+
meridianValue,
467+
sourceTrackIds
468+
})
469+
setDraftSavedAt(new Date().toISOString())
470+
// After saving, the banner is no longer offering to restore — the
471+
// user is already working with that state.
472+
setShowDraftBanner(false)
473+
}, [
474+
saveDraft,
475+
title,
476+
description,
477+
videoUrl,
478+
coverPhotoUrl,
479+
prizeInfo,
480+
contestEndDate,
481+
timeValue,
482+
meridianValue,
483+
sourceTrackIds
484+
])
485+
438486
const handleCancel = useCallback(() => {
439487
clearDraft()
440488
// Track-less flow: fall back to the contests discovery page.
@@ -594,6 +642,33 @@ export const HostRemixContestPage = () => {
594642
{isEdit ? messages.editPageTitle : messages.pageTitle}
595643
</Text>
596644

645+
{showDraftBanner && existingDraft ? (
646+
<Hint
647+
actions={
648+
<>
649+
<Button
650+
variant='primary'
651+
size='small'
652+
onClick={handleRestoreDraft}
653+
>
654+
{messages.restoreDraft}
655+
</Button>
656+
<Button
657+
variant='secondary'
658+
size='small'
659+
onClick={handleDiscardDraft}
660+
>
661+
{messages.discardDraft}
662+
</Button>
663+
</>
664+
}
665+
>
666+
{messages.draftRestoreTitle(
667+
dayjs(existingDraft.savedAt).format('MMM D, h:mm A')
668+
)}
669+
</Hint>
670+
) : null}
671+
597672
{/* Section: Contest Title */}
598673
<Paper
599674
direction='column'
@@ -976,6 +1051,15 @@ export const HostRemixContestPage = () => {
9761051
{messages.turnOff}
9771052
</Button>
9781053
) : null}
1054+
{/* Manual draft save: persists the full form to localStorage
1055+
for the current user + scope until the user discards it
1056+
or launches the contest. Hidden in edit mode since the
1057+
live event is the source of truth. */}
1058+
{!isEdit ? (
1059+
<Button variant='secondary' onClick={handleSaveDraft}>
1060+
{draftSavedAt ? messages.draftSaved : messages.saveDraft}
1061+
</Button>
1062+
) : null}
9791063
<Button
9801064
variant='primary'
9811065
onClick={handleSubmit}
@@ -1034,7 +1118,7 @@ type SourceTrackRowProps = {
10341118
/**
10351119
* Path the track-edit page should return to when the user clicks
10361120
* Save / Back. Encoded into a `returnTo` query param so the in-flight
1037-
* contest-creation form is restored from sessionStorage on landing.
1121+
* contest-creation form can be picked back up from its draft on landing.
10381122
*/
10391123
editReturnTo: string
10401124
}

0 commit comments

Comments
 (0)