11 changed files with 752 additions and 210 deletions
-
3src/app/providers.tsx
-
21src/app/questions-list/[slug]/loading.tsx
-
49src/app/questions-list/[slug]/question-detail-client.tsx
-
144src/app/questions-list/questions-list-client.tsx
-
99src/components/Componentes/auth-data-boundary.tsx
-
57src/components/Componentes/dev-tap-instrumentation.tsx
-
189src/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