11 changed files with 752 additions and 210 deletions
-
11src/app/providers.tsx
-
21src/app/questions-list/[slug]/loading.tsx
-
69src/app/questions-list/[slug]/question-detail-client.tsx
-
172src/app/questions-list/questions-list-client.tsx
-
99src/components/Componentes/auth-data-boundary.tsx
-
57src/components/Componentes/dev-tap-instrumentation.tsx
-
217src/components/Componentes/question-answer-storage.tsx
-
1src/components/Componentes/question-card.tsx
-
18src/components/Componentes/test-questions-flow.tsx
-
12src/hooks/use-current-profile-id.ts
-
285src/lib/user-scoped-storage.ts
@ -0,0 +1,21 @@ |
|||||
|
export default function Loading() { |
||||
|
return ( |
||||
|
<main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]"> |
||||
|
{/* Header placeholder */} |
||||
|
<div |
||||
|
style={{ paddingTop: "max(12px, calc(var(--safe-top) + 4px))" }} |
||||
|
className="shrink-0 px-[17px] pb-3" |
||||
|
> |
||||
|
<div className="h-10" /> |
||||
|
</div> |
||||
|
{/* Center spinner */} |
||||
|
<div className="flex min-h-0 flex-1 items-center justify-center"> |
||||
|
<span |
||||
|
role="status" |
||||
|
aria-label="Loading section" |
||||
|
className="size-5 animate-spin rounded-full border-2 border-[#E03950]/25 border-t-[#E03950] motion-reduce:animate-none" |
||||
|
/> |
||||
|
</div> |
||||
|
</main> |
||||
|
); |
||||
|
} |
||||
@ -0,0 +1,99 @@ |
|||||
|
"use client"; |
||||
|
|
||||
|
import { useQueryClient } from "@tanstack/react-query"; |
||||
|
import { useEffect, useRef, type ReactNode } from "react"; |
||||
|
import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; |
||||
|
import { HABIB_AUTH_TOKEN_CHANGED_EVENT } from "@/lib/auth-bridge"; |
||||
|
import { |
||||
|
isLegacyMigrationDone, |
||||
|
migrateLegacyStorageKeys, |
||||
|
} from "@/lib/user-scoped-storage"; |
||||
|
|
||||
|
/** |
||||
|
* Central auth-data boundary that resets user-specific React Query caches |
||||
|
* and localStorage drafts when the authenticated identity changes. |
||||
|
* |
||||
|
* Mount this inside (or near) the QueryClientProvider. |
||||
|
* |
||||
|
* On `habib:auth-token-changed`: |
||||
|
* 1. Cancels all in-flight user queries (profile, sections, assessments) |
||||
|
* 2. Removes previous-user data from the query cache |
||||
|
* 3. Keeps config/public queries intact |
||||
|
* |
||||
|
* This prevents Account A's cached data from being served to Account B |
||||
|
* after a token switch in the same WebView. |
||||
|
*/ |
||||
|
export default function AuthDataBoundary({ |
||||
|
children, |
||||
|
}: { |
||||
|
children: ReactNode; |
||||
|
}) { |
||||
|
const queryClient = useQueryClient(); |
||||
|
const queryClientRef = useRef(queryClient); |
||||
|
queryClientRef.current = queryClient; |
||||
|
|
||||
|
// Run V3 legacy migration once on mount
|
||||
|
useEffect(() => { |
||||
|
if (typeof window === "undefined") return; |
||||
|
if (!isLegacyMigrationDone()) { |
||||
|
migrateLegacyStorageKeys(); |
||||
|
} |
||||
|
}, []); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
if (typeof window === "undefined") return; |
||||
|
|
||||
|
const handleAuthChange = () => { |
||||
|
const qc = queryClientRef.current; |
||||
|
|
||||
|
// 1. Cancel all in-flight user-specific queries
|
||||
|
void qc.cancelQueries({ queryKey: marriageQueryKeys.profile() }); |
||||
|
void qc.cancelQueries({ queryKey: marriageQueryKeys.sections() }); |
||||
|
void qc.cancelQueries({ |
||||
|
queryKey: marriageQueryKeys.all, |
||||
|
predicate: (query) => { |
||||
|
const key = query.queryKey; |
||||
|
// Preserve config queries — they're user-independent
|
||||
|
if ( |
||||
|
Array.isArray(key) && |
||||
|
key[0] === "marriage" && |
||||
|
key[1] === "config" |
||||
|
) { |
||||
|
return false; |
||||
|
} |
||||
|
return true; |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
// 2. Remove all user-owned query data from cache
|
||||
|
// Remove profile
|
||||
|
qc.removeQueries({ queryKey: marriageQueryKeys.profile() }); |
||||
|
|
||||
|
// Remove form overview + sections
|
||||
|
qc.removeQueries({ |
||||
|
queryKey: marriageQueryKeys.all, |
||||
|
predicate: (query) => { |
||||
|
const key = query.queryKey; |
||||
|
if (!Array.isArray(key) || key[0] !== "marriage") return false; |
||||
|
|
||||
|
const kind = key[1]; |
||||
|
// Remove: profile, form-overview, form-section, sections,
|
||||
|
// cattell, glasser, advisors, cases (contact-info)
|
||||
|
// Keep: config
|
||||
|
return kind !== "config"; |
||||
|
}, |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
window.addEventListener(HABIB_AUTH_TOKEN_CHANGED_EVENT, handleAuthChange); |
||||
|
|
||||
|
return () => { |
||||
|
window.removeEventListener( |
||||
|
HABIB_AUTH_TOKEN_CHANGED_EVENT, |
||||
|
handleAuthChange, |
||||
|
); |
||||
|
}; |
||||
|
}, []); |
||||
|
|
||||
|
return <>{children}</>; |
||||
|
} |
||||
@ -0,0 +1,57 @@ |
|||||
|
"use client"; |
||||
|
|
||||
|
import { useEffect } from "react"; |
||||
|
|
||||
|
/** |
||||
|
* Development-only capture-phase instrumentation for debugging section-card |
||||
|
* tap responsiveness. Records pointerdown → pointerup → click timing and |
||||
|
* whether the event reaches the DOM at all (vs being swallowed by a native |
||||
|
* overlay such as Flutter's loading cover). |
||||
|
* |
||||
|
* Mount this inside the questions-list page during development. Remove or |
||||
|
* gate behind process.env.NODE_ENV check for production. |
||||
|
*/ |
||||
|
export default function DevTapInstrumentation() { |
||||
|
useEffect(() => { |
||||
|
if (process.env.NODE_ENV !== "development") return; |
||||
|
|
||||
|
const events = ["pointerdown", "pointerup", "pointercancel", "click"] as const; |
||||
|
const startTime = performance.now(); |
||||
|
let sequenceId = 0; |
||||
|
|
||||
|
const handler = (event: Event) => { |
||||
|
const e = event as PointerEvent; |
||||
|
const target = e.target as HTMLElement | null; |
||||
|
const anchor = target?.closest?.("a"); |
||||
|
const elapsed = (performance.now() - startTime).toFixed(1); |
||||
|
sequenceId += 1; |
||||
|
|
||||
|
console.debug( |
||||
|
`[tap-debug #${sequenceId}] %c${e.type}%c @ ${elapsed}ms`, |
||||
|
"color: #E03950; font-weight: bold", |
||||
|
"color: inherit", |
||||
|
{ |
||||
|
pointerType: (e as PointerEvent).pointerType || "n/a", |
||||
|
target: target?.tagName, |
||||
|
targetId: target?.id, |
||||
|
closestAnchorHref: anchor?.getAttribute("href") || null, |
||||
|
defaultPrevented: e.defaultPrevented, |
||||
|
pathname: window.location.pathname, |
||||
|
timestamp: performance.now(), |
||||
|
}, |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
for (const eventName of events) { |
||||
|
document.addEventListener(eventName, handler, { capture: true }); |
||||
|
} |
||||
|
|
||||
|
return () => { |
||||
|
for (const eventName of events) { |
||||
|
document.removeEventListener(eventName, handler, { capture: true }); |
||||
|
} |
||||
|
}; |
||||
|
}, []); |
||||
|
|
||||
|
return null; |
||||
|
} |
||||
@ -0,0 +1,12 @@ |
|||||
|
"use client"; |
||||
|
|
||||
|
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; |
||||
|
|
||||
|
/** |
||||
|
* Returns the current authenticated user's profile ID. |
||||
|
* Returns null when profile hasn't loaded yet or user is unauthenticated. |
||||
|
*/ |
||||
|
export function useCurrentProfileId(): number | null { |
||||
|
const { data: profile } = useMarriageProfileQuery(); |
||||
|
return profile?.id ?? null; |
||||
|
} |
||||
@ -0,0 +1,285 @@ |
|||||
|
/** |
||||
|
* User-scoped localStorage utilities. |
||||
|
* |
||||
|
* All draft/answer data is keyed by the authenticated user's profile ID |
||||
|
* so that Account A's unsynced edits can never be displayed or synced |
||||
|
* for Account B. |
||||
|
* |
||||
|
* The raw auth token is NEVER used as a key component. |
||||
|
*/ |
||||
|
|
||||
|
export const SCOPED_STORAGE_VERSION = 3; |
||||
|
|
||||
|
// ─── Types ───────────────────────────────────────────────────────────
|
||||
|
|
||||
|
export interface ScopedSectionDraft { |
||||
|
version: typeof SCOPED_STORAGE_VERSION; |
||||
|
ownerProfileId: number; |
||||
|
slug: string; |
||||
|
pending: Record<string, ScopedPendingField>; |
||||
|
updatedAt: string; |
||||
|
} |
||||
|
|
||||
|
export interface ScopedPendingField { |
||||
|
key: string; |
||||
|
label: string; |
||||
|
type: string; |
||||
|
value: unknown; |
||||
|
option_id?: string | string[] | null; |
||||
|
private?: boolean; |
||||
|
} |
||||
|
|
||||
|
export interface ScopedAssessmentDraft { |
||||
|
version: typeof SCOPED_STORAGE_VERSION; |
||||
|
ownerProfileId: number; |
||||
|
slug: string; |
||||
|
answers: Record<number, string | number>; |
||||
|
currentIndex: number; |
||||
|
totalQuestions: number; |
||||
|
} |
||||
|
|
||||
|
// ─── Key Generators ──────────────────────────────────────────────────
|
||||
|
|
||||
|
export function getScopedSectionDraftKey( |
||||
|
profileId: number, |
||||
|
slug: string, |
||||
|
): string { |
||||
|
return `marriage:user:${profileId}:sections:${slug}:draft:v${SCOPED_STORAGE_VERSION}`; |
||||
|
} |
||||
|
|
||||
|
export function getScopedAssessmentDraftKey( |
||||
|
profileId: number, |
||||
|
slug: string, |
||||
|
): string { |
||||
|
return `marriage:user:${profileId}:tests:${slug}:draft:v${SCOPED_STORAGE_VERSION}`; |
||||
|
} |
||||
|
|
||||
|
// ─── Ownership Verification ──────────────────────────────────────────
|
||||
|
|
||||
|
export function isDraftOwnedBy( |
||||
|
draft: { ownerProfileId?: number } | null | undefined, |
||||
|
profileId: number, |
||||
|
): boolean { |
||||
|
if (!draft || typeof draft !== "object") return false; |
||||
|
return draft.ownerProfileId === profileId; |
||||
|
} |
||||
|
|
||||
|
// ─── Reading ─────────────────────────────────────────────────────────
|
||||
|
|
||||
|
export function readScopedSectionDraft( |
||||
|
profileId: number, |
||||
|
slug: string, |
||||
|
): ScopedSectionDraft | null { |
||||
|
try { |
||||
|
const key = getScopedSectionDraftKey(profileId, slug); |
||||
|
const raw = window.localStorage.getItem(key); |
||||
|
if (!raw) return null; |
||||
|
|
||||
|
const parsed = JSON.parse(raw); |
||||
|
if ( |
||||
|
!parsed || |
||||
|
parsed.version !== SCOPED_STORAGE_VERSION || |
||||
|
parsed.ownerProfileId !== profileId || |
||||
|
parsed.slug !== slug |
||||
|
) { |
||||
|
// Corrupted or mismatched — remove it
|
||||
|
window.localStorage.removeItem(key); |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
return parsed as ScopedSectionDraft; |
||||
|
} catch { |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export function readScopedAssessmentDraft( |
||||
|
profileId: number, |
||||
|
slug: string, |
||||
|
): ScopedAssessmentDraft | null { |
||||
|
try { |
||||
|
const key = getScopedAssessmentDraftKey(profileId, slug); |
||||
|
const raw = window.localStorage.getItem(key); |
||||
|
if (!raw) return null; |
||||
|
|
||||
|
const parsed = JSON.parse(raw); |
||||
|
if ( |
||||
|
!parsed || |
||||
|
parsed.version !== SCOPED_STORAGE_VERSION || |
||||
|
parsed.ownerProfileId !== profileId |
||||
|
) { |
||||
|
window.localStorage.removeItem(key); |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
return parsed as ScopedAssessmentDraft; |
||||
|
} catch { |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ─── Writing ─────────────────────────────────────────────────────────
|
||||
|
|
||||
|
export function writeScopedSectionDraft( |
||||
|
profileId: number, |
||||
|
slug: string, |
||||
|
pending: Record<string, ScopedPendingField>, |
||||
|
): void { |
||||
|
try { |
||||
|
const key = getScopedSectionDraftKey(profileId, slug); |
||||
|
|
||||
|
if (Object.keys(pending).length === 0) { |
||||
|
window.localStorage.removeItem(key); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const draft: ScopedSectionDraft = { |
||||
|
version: SCOPED_STORAGE_VERSION, |
||||
|
ownerProfileId: profileId, |
||||
|
slug, |
||||
|
pending, |
||||
|
updatedAt: new Date().toISOString(), |
||||
|
}; |
||||
|
|
||||
|
window.localStorage.setItem(key, JSON.stringify(draft)); |
||||
|
} catch { |
||||
|
// localStorage can fail in private mode or when storage quota is exhausted.
|
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export function writeScopedAssessmentDraft( |
||||
|
profileId: number, |
||||
|
slug: string, |
||||
|
answers: Record<number, string | number>, |
||||
|
currentIndex: number, |
||||
|
totalQuestions: number, |
||||
|
): void { |
||||
|
try { |
||||
|
const key = getScopedAssessmentDraftKey(profileId, slug); |
||||
|
|
||||
|
if (Object.keys(answers).length === 0) { |
||||
|
window.localStorage.removeItem(key); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const draft: ScopedAssessmentDraft = { |
||||
|
version: SCOPED_STORAGE_VERSION, |
||||
|
ownerProfileId: profileId, |
||||
|
slug, |
||||
|
answers, |
||||
|
currentIndex, |
||||
|
totalQuestions, |
||||
|
}; |
||||
|
|
||||
|
window.localStorage.setItem(key, JSON.stringify(draft)); |
||||
|
} catch { |
||||
|
// localStorage can fail.
|
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export function removeScopedSectionDraft( |
||||
|
profileId: number, |
||||
|
slug: string, |
||||
|
): void { |
||||
|
try { |
||||
|
window.localStorage.removeItem( |
||||
|
getScopedSectionDraftKey(profileId, slug), |
||||
|
); |
||||
|
} catch {} |
||||
|
} |
||||
|
|
||||
|
export function removeScopedAssessmentDraft( |
||||
|
profileId: number, |
||||
|
slug: string, |
||||
|
): void { |
||||
|
try { |
||||
|
window.localStorage.removeItem( |
||||
|
getScopedAssessmentDraftKey(profileId, slug), |
||||
|
); |
||||
|
} catch {} |
||||
|
} |
||||
|
|
||||
|
// ─── Legacy Migration ────────────────────────────────────────────────
|
||||
|
|
||||
|
/** |
||||
|
* Remove old V2 unscoped answer keys. Since they have no owner metadata, |
||||
|
* ownership cannot be proven, so we do NOT migrate them. |
||||
|
* We also do NOT call localStorage.clear() because the application stores |
||||
|
* unrelated device/UI flags. |
||||
|
*/ |
||||
|
export function migrateLegacyStorageKeys(): void { |
||||
|
try { |
||||
|
const keysToRemove: string[] = []; |
||||
|
|
||||
|
for (let i = 0; i < window.localStorage.length; i++) { |
||||
|
const key = window.localStorage.key(i); |
||||
|
if (!key) continue; |
||||
|
|
||||
|
// Old V2 normal section keys: marriage:sections:{slug}:answers:v2
|
||||
|
if (key.match(/^marriage:sections:[^:]+:answers:v2$/)) { |
||||
|
keysToRemove.push(key); |
||||
|
} |
||||
|
|
||||
|
// Old unscoped assessment draft keys: marriage:tests:{slug}:draft
|
||||
|
if (key.match(/^marriage:tests:[^:]+:draft$/) && !key.includes(":user:")) { |
||||
|
keysToRemove.push(key); |
||||
|
} |
||||
|
|
||||
|
// Old unscoped completion markers: marriage:sections:{slug}:answers
|
||||
|
// (used by assessments to store {completed: true})
|
||||
|
if (key.match(/^marriage:sections:[^:]+:answers$/) && !key.includes(":user:")) { |
||||
|
keysToRemove.push(key); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
for (const key of keysToRemove) { |
||||
|
window.localStorage.removeItem(key); |
||||
|
} |
||||
|
|
||||
|
// Mark migration as done so we don't re-scan every page load
|
||||
|
window.localStorage.setItem("marriage:storage-migration:v3", "done"); |
||||
|
} catch { |
||||
|
// Non-critical; will retry next page load.
|
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Check if the V3 migration has already been performed. |
||||
|
*/ |
||||
|
export function isLegacyMigrationDone(): boolean { |
||||
|
try { |
||||
|
return window.localStorage.getItem("marriage:storage-migration:v3") === "done"; |
||||
|
} catch { |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Enumerate all scoped section draft keys for a given profile. |
||||
|
*/ |
||||
|
export function getAllScopedSectionDraftKeys( |
||||
|
profileId: number, |
||||
|
): string[] { |
||||
|
const prefix = `marriage:user:${profileId}:sections:`; |
||||
|
const suffix = `:draft:v${SCOPED_STORAGE_VERSION}`; |
||||
|
const keys: string[] = []; |
||||
|
|
||||
|
try { |
||||
|
for (let i = 0; i < window.localStorage.length; i++) { |
||||
|
const key = window.localStorage.key(i); |
||||
|
if (key && key.startsWith(prefix) && key.endsWith(suffix)) { |
||||
|
keys.push(key); |
||||
|
} |
||||
|
} |
||||
|
} catch {} |
||||
|
|
||||
|
return keys; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Extract the slug from a scoped section draft key. |
||||
|
*/ |
||||
|
export function extractSlugFromScopedKey(key: string): string | null { |
||||
|
const match = key.match(/^marriage:user:\d+:sections:([^:]+):draft:v\d+$/); |
||||
|
return match ? match[1] : null; |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue