From d2929a94ceeb943a28c68be52b9f2bef4c09f31c Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Sat, 14 Feb 2026 01:00:20 +0100 Subject: [PATCH] Cache unsaved profile editions for 24h --- backend/supabase/profiles.sql | 2 +- web/components/bio/editable-bio.tsx | 34 ++++++-- web/components/optional-profile-form.tsx | 98 +++++++++++++++++++++++- web/components/profile-about.tsx | 2 +- web/components/widgets/new-tab-link.tsx | 5 +- 5 files changed, 129 insertions(+), 12 deletions(-) diff --git a/backend/supabase/profiles.sql b/backend/supabase/profiles.sql index 5cde564..4cc4f82 100644 --- a/backend/supabase/profiles.sql +++ b/backend/supabase/profiles.sql @@ -25,7 +25,7 @@ CREATE TABLE IF NOT EXISTS profiles ( gender TEXT NOT NULL, geodb_city_id TEXT, has_kids INTEGER, - height_in_inches INTEGER, + height_in_inches float4, id BIGINT GENERATED ALWAYS AS IDENTITY NOT NULL, image_descriptions jsonb, is_smoker BOOLEAN, diff --git a/web/components/bio/editable-bio.tsx b/web/components/bio/editable-bio.tsx index 1bee0a8..262b899 100644 --- a/web/components/bio/editable-bio.tsx +++ b/web/components/bio/editable-bio.tsx @@ -14,8 +14,9 @@ import {MIN_BIO_LENGTH} from "common/constants"; import {ShowMore} from 'web/components/widgets/show-more' import {NewTabLink} from 'web/components/widgets/new-tab-link' import {useT} from 'web/lib/locale' +import {richTextToString} from 'common/util/parse' -export function BioTips() { +export function BioTips({onClick}: { onClick?: () => void }) { const t = useT(); const tips = t('profile.bio.tips_list', ` - Your core values, interests, and activities @@ -35,7 +36,8 @@ export function BioTips() {

{t('profile.bio.tips_intro', "Write a clear and engaging bio to help others understand who you are and the connections you seek. Include:")}

{tips} {t('profile.bio.tips_link', 'Read full tips for writing a high-quality bio')} + href="/tips-bio" + onClick={onClick}>{t('profile.bio.tips_link', 'Read full tips for writing a high-quality bio')} ) } @@ -71,6 +73,7 @@ export function EditableBio(props: { defaultValue={profile.bio} onEditor={(e) => { setEditor(e); + if (e) setTextLength(e.getText().length) e?.on('update', () => { setTextLength(e.getText().length); }); @@ -101,8 +104,23 @@ export function EditableBio(props: { export function SignupBio(props: { onChange: (e: Editor) => void profile?: ProfileWithoutUser | undefined + onClickTips?: () => void + onEditor?: (editor: any) => void }) { - const {onChange, profile} = props + const {onChange, profile, onClickTips, onEditor} = props + const [editor, setEditor] = useState(null) + + // Keep the editor content in sync with profile.bio when it becomes available + useEffect(() => { + if (!editor) return + const profileText = profile?.bio ? richTextToString(profile.bio as any) : '' + const currentText = editor?.getText?.() ?? '' + // Only update if the underlying text differs to avoid clobbering user input unnecessarily + if (profileText !== currentText) { + editor.commands.setContent(profile?.bio ?? '') + } + }, [profile?.bio, editor]) + return ( { + setEditor(e) + onEditor?.(e) + }} /> ) @@ -120,9 +143,10 @@ interface BaseBioProps { defaultValue?: any onBlur?: (editor: any) => void onEditor?: (editor: any) => void + onClickTips?: () => void } -export function BaseBio({defaultValue, onBlur, onEditor}: BaseBioProps) { +export function BaseBio({defaultValue, onBlur, onEditor, onClickTips}: BaseBioProps) { const t = useT(); const editor = useTextEditor({ // extensions: [StarterKit], @@ -147,7 +171,7 @@ export function BaseBio({defaultValue, onBlur, onEditor}: BaseBioProps) { }

} - + onBlur?.(editor)} diff --git a/web/components/optional-profile-form.tsx b/web/components/optional-profile-form.tsx index 83205af..2437250 100644 --- a/web/components/optional-profile-form.tsx +++ b/web/components/optional-profile-form.tsx @@ -1,4 +1,4 @@ -import {Fragment, useEffect, useRef, useState} from 'react' +import {Fragment, useCallback, useEffect, useRef, useState} from 'react' import {Title} from 'web/components/widgets/title' import {Col} from 'web/components/layout/col' import clsx from 'clsx' @@ -48,6 +48,7 @@ import {sleep} from "common/util/time" import {SignupBio} from "web/components/bio/editable-bio"; import {Editor} from "@tiptap/core"; import {Slider} from "web/components/widgets/slider"; +import {safeLocalStorage} from "web/lib/util/local"; export const OptionalProfileUserForm = (props: { @@ -76,6 +77,18 @@ export const OptionalProfileUserForm = (props: { : undefined ) + // Keep local feet/inches inputs in sync when profile.height_in_inches updates + // This covers cases like hydration from localStorage where setProfile is called externally + const updateHeight = (h: any) => { + if (h == null || Number.isNaN(h as any)) { + setHeightFeet(undefined) + setHeightInches(undefined) + return + } + setHeightFeet(Math.floor(h / 12)) + setHeightInches(Math.round(h % 12)) + } + const [newLinks, setNewLinks] = useState>( user.link ) @@ -87,6 +100,80 @@ export const OptionalProfileUserForm = (props: { const [workChoices, setWorkChoices] = useState({}) const {locale} = useLocale() + const KEY = `draft-profile-${user.id}` + + const clearProfileDraft = (userId: string) => { + try { + safeLocalStorage?.removeItem(`draft-profile-${userId}`) + safeLocalStorage?.removeItem(`draft-profile-${userId}-timestamp`) + } catch (error) { + console.warn('Failed to clear profile from store:', error) + } + } + + // Debounced save function + const debouncedSaveProfile = useCallback( + (() => { + let timeoutId: NodeJS.Timeout + return (profileToSave: ProfileWithoutUser) => { + clearTimeout(timeoutId) + timeoutId = setTimeout(() => { + try { + safeLocalStorage?.setItem(KEY, JSON.stringify({ + profile: profileToSave, + timestamp: Date.now().toString(), + })) + } catch (error) { + console.warn('Failed to save profile to store:', error) + } + }, 500) // 500ms debounce delay + } + })(), + [KEY] + ) + + useEffect(() => { + console.log({profile}) + if (profile && Object.keys(profile).length > 0) { + debouncedSaveProfile(profile) + } + }, [profile, user.id, debouncedSaveProfile]) + + useEffect(() => { + try { + const savedProfileString = safeLocalStorage?.getItem(KEY) + if (savedProfileString) { + const data = JSON.parse(savedProfileString) + if (data) { + const {profile: savedProfile, timestamp} = data + // Check if saved data is older than 24 hours + if (timestamp) { + const savedTime = parseInt(timestamp, 10) + const now = Date.now() + const twentyFourHoursInMs = 24 * 60 * 60 * 1000 + + if (now - savedTime > twentyFourHoursInMs) { + console.log('Skipping profile update: saved data is older than 24 hours') + return + } + } + + // Update all profile fields + Object.entries(savedProfile).forEach(([key, value]) => { + const typedKey = key as keyof ProfileWithoutUser + if (value !== profile[typedKey]) { + console.log(key, value) + setProfile(typedKey, value) + if (typedKey === 'height_in_inches') updateHeight(value) + } + }) + } + } + } catch (error) { + console.warn('Failed to load profile from store:', error) + } + }, []) // Only run once on mount + useEffect(() => { fetchChoices('interests', locale).then(setInterestChoices) fetchChoices('causes', locale).then(setCauseChoices) @@ -120,6 +207,8 @@ export const OptionalProfileUserForm = (props: { } try { await Promise.all(promises) + // Clear profile draft from Zustand store after successful submission + clearProfileDraft(user.id) } catch (error) { console.error(error) toast.error( @@ -308,6 +397,7 @@ export const OptionalProfileUserForm = (props: { }} className={'w-16'} value={typeof heightFeet === 'number' ? Math.floor(heightFeet) : ''} + min={0} /> @@ -325,6 +415,7 @@ export const OptionalProfileUserForm = (props: { }} className={'w-16'} value={typeof heightInches === 'number' ? Math.floor(heightInches) : ''} + min={0} />
@@ -348,9 +439,10 @@ export const OptionalProfileUserForm = (props: { } }} className={'w-20'} - value={heightFeet !== undefined - ? Math.round((heightFeet * 12 + (heightInches ?? 0)) * 2.54) + value={heightFeet !== undefined && profile['height_in_inches'] + ? Math.round(profile['height_in_inches'] * 2.54) : ''} + min={0} /> diff --git a/web/components/profile-about.tsx b/web/components/profile-about.tsx index aa9eefe..8dad4c6 100644 --- a/web/components/profile-about.tsx +++ b/web/components/profile-about.tsx @@ -456,7 +456,7 @@ export const formatProfileValue = (key: string, value: any) => { case 'has_pets': return value ? 'Yes' : 'No' case 'height_in_inches': - return `${Math.floor(value / 12)}' ${value % 12}"` + return `${Math.floor(value / 12)}' ${Math.round(value % 12)}"` case 'pref_age_max': case 'pref_age_min': return null // handle this in a special case diff --git a/web/components/widgets/new-tab-link.tsx b/web/components/widgets/new-tab-link.tsx index 9d04624..941c828 100644 --- a/web/components/widgets/new-tab-link.tsx +++ b/web/components/widgets/new-tab-link.tsx @@ -5,13 +5,14 @@ interface NewTabLinkProps { href: string children: React.ReactNode className?: string + onClick?: () => void } -export function NewTabLink({href, children, className}: NewTabLinkProps) { +export function NewTabLink({href, children, className, onClick}: NewTabLinkProps) { // New tabs don't work on native apps const isNative = isNativeMobile() return ( - + {children} )