hard-cut onboarding modal flow orchestration
This commit is contained in:
parent
14b1b5602f
commit
22f132bbc8
7 changed files with 371 additions and 346 deletions
|
|
@ -4,16 +4,13 @@ import type { ReactNode } from 'react';
|
|||
|
||||
import { ConfigProvider } from '@/contexts/ConfigContext';
|
||||
import { DocumentProvider } from '@/contexts/DocumentContext';
|
||||
import { DexieMigrationModal } from '@/components/documents/DexieMigrationModal';
|
||||
import { OnboardingFlowProvider } from '@/contexts/OnboardingFlowContext';
|
||||
|
||||
export default function AppHomeLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ConfigProvider>
|
||||
<DocumentProvider>
|
||||
<>
|
||||
{children}
|
||||
<DexieMigrationModal />
|
||||
</>
|
||||
<OnboardingFlowProvider>{children}</OnboardingFlowProvider>
|
||||
</DocumentProvider>
|
||||
</ConfigProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import type { ReactNode } from 'react';
|
|||
import { Toaster } from 'react-hot-toast';
|
||||
|
||||
import { Providers } from '@/app/providers';
|
||||
import ClaimDataPopup from '@/components/auth/ClaimDataModal';
|
||||
import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isAuthEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
|
@ -36,7 +35,6 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
|||
githubAuthEnabled={githubAuthEnabled}
|
||||
>
|
||||
<div className="app-shell min-h-screen flex flex-col bg-background">
|
||||
{authEnabled && <ClaimDataPopup />}
|
||||
<main className="flex-1 flex flex-col">{children}</main>
|
||||
</div>
|
||||
<Toaster
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import Link from 'next/link';
|
|||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { ChevronUpDownIcon, CheckIcon, SettingsIcon, KeyIcon, PaletteIcon, DocumentIcon, UserIcon, DownloadIcon, ChevronRightIcon } from '@/components/icons/Icons';
|
||||
import { getAppConfig, getFirstVisit, setFirstVisit } from '@/lib/client/dexie';
|
||||
import { useDocuments } from '@/contexts/DocumentContext';
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
import { ProgressPopup } from '@/components/ProgressPopup';
|
||||
|
|
@ -33,7 +32,6 @@ import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
|||
import { useRouter } from 'next/navigation';
|
||||
import { showPrivacyModal } from '@/components/PrivacyModal';
|
||||
import { deleteDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents';
|
||||
import { postChangelogVersionCheck } from '@/lib/client/api/user-state';
|
||||
import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents';
|
||||
import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews';
|
||||
import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings';
|
||||
|
|
@ -71,8 +69,7 @@ import {
|
|||
type ChangelogManifestEntry,
|
||||
type ChangelogReleaseBody,
|
||||
} from '@/lib/shared/changelog';
|
||||
import { useOnboardingCoordinator } from '@/hooks/useOnboardingCoordinator';
|
||||
import { ONBOARDING_STATE_REGISTRY } from '@/lib/shared/onboarding-state';
|
||||
import { useOnboardingFlow } from '@/contexts/OnboardingFlowContext';
|
||||
|
||||
// Hard-coded theme color palettes for the visual theme selector
|
||||
type ThemeColorSet = { background: string; base: string; offbase: string; accent: string; secondaryAccent: string; foreground: string; muted: string };
|
||||
|
|
@ -174,7 +171,8 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false);
|
||||
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
|
||||
const { authEnabled, baseUrl: authBaseUrl } = useAuthConfig();
|
||||
const { data: session, isPending: isSessionPending } = useAuthSession();
|
||||
const { data: session } = useAuthSession();
|
||||
const { requestOpenSettings, registerSettingsController } = useOnboardingFlow();
|
||||
const router = useRouter();
|
||||
const isBusy = isImportingLibrary;
|
||||
const {
|
||||
|
|
@ -217,34 +215,15 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
setIsChangelogOpen(Boolean(options?.changelog));
|
||||
}, []);
|
||||
|
||||
const readLocalOnboardingSnapshot = useCallback(async () => {
|
||||
const appConfig = await getAppConfig();
|
||||
const row = appConfig as Record<string, unknown> | null;
|
||||
const privacyKey = ONBOARDING_STATE_REGISTRY.privacyAccepted.localKey;
|
||||
const firstVisitKey = ONBOARDING_STATE_REGISTRY.firstVisitSettingsOpened.localKey;
|
||||
|
||||
return {
|
||||
privacyAccepted: privacyKey ? Boolean(row?.[privacyKey]) : false,
|
||||
firstVisitSettingsOpened: firstVisitKey ? Boolean(row?.[firstVisitKey]) : await getFirstVisit(),
|
||||
useEffect(() => {
|
||||
registerSettingsController({
|
||||
open: openSettings,
|
||||
close: closeSettings,
|
||||
});
|
||||
return () => {
|
||||
registerSettingsController(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const markFirstVisitSettingsOpened = useCallback(async () => {
|
||||
await setFirstVisit(true);
|
||||
}, []);
|
||||
|
||||
const { requestOpenSettings } = useOnboardingCoordinator({
|
||||
authEnabled,
|
||||
isSessionPending,
|
||||
sessionUserId: session?.user?.id,
|
||||
appVersion: runtimeConfig.appVersion,
|
||||
isSettingsOpen: isOpen,
|
||||
readLocalSnapshot: readLocalOnboardingSnapshot,
|
||||
markFirstVisitSettingsOpened,
|
||||
postChangelogVersionCheck: async (currentVersion) => postChangelogVersionCheck(currentVersion),
|
||||
openSettings,
|
||||
closeSettings,
|
||||
});
|
||||
}, [closeSettings, openSettings, registerSettingsController]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalApiKey(apiKey);
|
||||
|
|
@ -530,7 +509,11 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
</Button>
|
||||
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-50" onClose={resetToCurrent}>
|
||||
<Dialog
|
||||
as="div"
|
||||
className={`relative ${isChangelogOpen ? 'z-[90]' : 'z-50'}`}
|
||||
onClose={resetToCurrent}
|
||||
>
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { Fragment, useState, useEffect, useCallback } from 'react';
|
||||
import { Fragment, useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogPanel,
|
||||
|
|
@ -9,11 +9,10 @@ import {
|
|||
TransitionChild,
|
||||
Button,
|
||||
} from '@headlessui/react';
|
||||
import { useAuthSession } from '@/hooks/useAuthSession';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
type ClaimableCounts = {
|
||||
export type ClaimableCounts = {
|
||||
documents: number;
|
||||
audiobooks: number;
|
||||
preferences: number;
|
||||
|
|
@ -30,53 +29,21 @@ function toClaimableCounts(value: unknown): ClaimableCounts {
|
|||
};
|
||||
}
|
||||
|
||||
export default function ClaimDataModal() {
|
||||
const { data: sessionData } = useAuthSession();
|
||||
type ClaimDataModalProps = {
|
||||
isOpen: boolean;
|
||||
claimableCounts: ClaimableCounts;
|
||||
onDismiss: () => void;
|
||||
onClaimed: () => void;
|
||||
};
|
||||
|
||||
export default function ClaimDataModal({
|
||||
isOpen,
|
||||
claimableCounts,
|
||||
onDismiss,
|
||||
onClaimed,
|
||||
}: ClaimDataModalProps) {
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [hasChecked, setHasChecked] = useState(false);
|
||||
const [isClaiming, setIsClaiming] = useState(false);
|
||||
const [claimableCounts, setClaimableCounts] = useState<ClaimableCounts>({
|
||||
documents: 0,
|
||||
audiobooks: 0,
|
||||
preferences: 0,
|
||||
progress: 0,
|
||||
});
|
||||
const user = sessionData?.user;
|
||||
const userId = user?.id;
|
||||
|
||||
const checkClaimableData = useCallback(async () => {
|
||||
setHasChecked(true);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/user/claim', {
|
||||
method: 'GET',
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const counts = toClaimableCounts(data);
|
||||
setClaimableCounts(counts);
|
||||
|
||||
if (counts.documents + counts.audiobooks + counts.preferences + counts.progress > 0) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset per-user guard so account switches trigger a fresh check.
|
||||
setHasChecked(false);
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only check once per authenticated user
|
||||
if (userId && !user?.isAnonymous && !hasChecked) {
|
||||
checkClaimableData();
|
||||
}
|
||||
}, [userId, user?.isAnonymous, hasChecked, checkClaimableData]);
|
||||
|
||||
const handleClaim = async () => {
|
||||
setIsClaiming(true);
|
||||
|
|
@ -93,8 +60,7 @@ export default function ClaimDataModal() {
|
|||
+ `${claimed.preferences} preference set(s), and `
|
||||
+ `${claimed.progress} reading progress record(s)!`,
|
||||
);
|
||||
|
||||
setIsOpen(false);
|
||||
onClaimed();
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
|
@ -107,15 +73,9 @@ export default function ClaimDataModal() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
// Close the modal for this session - will reappear on next page load/refresh
|
||||
setIsOpen(false);
|
||||
// Keep hasChecked = true so useEffect doesn't re-trigger in this session
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-50" onClose={handleDismiss}>
|
||||
<Dialog as="div" className="relative z-[80]" onClose={onDismiss}>
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
|
|
@ -169,7 +129,7 @@ export default function ClaimDataModal() {
|
|||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
onClick={onDismiss}
|
||||
disabled={isClaiming}
|
||||
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
|
||||
font-medium text-foreground hover:bg-offbase focus:outline-none
|
||||
|
|
|
|||
|
|
@ -1,25 +1,38 @@
|
|||
'use client';
|
||||
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Fragment, useCallback, useEffect, useState } from 'react';
|
||||
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Button } from '@headlessui/react';
|
||||
import { getAppConfig, getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, updateAppConfig } from '@/lib/client/dexie';
|
||||
import { getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, updateAppConfig } from '@/lib/client/dexie';
|
||||
import { listDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents';
|
||||
import { useDocuments } from '@/contexts/DocumentContext';
|
||||
import type { BaseDocument } from '@/types/documents';
|
||||
import { cacheStoredDocumentFromBytes } from '@/lib/client/cache/documents';
|
||||
|
||||
export function DexieMigrationModal() {
|
||||
type DexieMigrationModalProps = {
|
||||
isOpen: boolean;
|
||||
localCount: number;
|
||||
missingCount: number;
|
||||
onComplete: () => void;
|
||||
};
|
||||
|
||||
export function DexieMigrationModal({
|
||||
isOpen,
|
||||
localCount,
|
||||
missingCount,
|
||||
onComplete,
|
||||
}: DexieMigrationModalProps) {
|
||||
const { refreshDocuments } = useDocuments();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [localCount, setLocalCount] = useState(0);
|
||||
const [missingCount, setMissingCount] = useState(0);
|
||||
const [displayMissingCount, setDisplayMissingCount] = useState(missingCount);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const checkedRef = useRef(false);
|
||||
|
||||
const closeDisabled = isUploading;
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayMissingCount(missingCount);
|
||||
}, [missingCount, isOpen]);
|
||||
|
||||
const loadLocalDexieDocs = useCallback(async (): Promise<{
|
||||
docs: BaseDocument[];
|
||||
pdfById: Map<string, { id: string; name: string; size: number; lastModified: number; data: ArrayBuffer }>;
|
||||
|
|
@ -38,60 +51,12 @@ export function DexieMigrationModal() {
|
|||
return { docs, pdfById, epubById, htmlById };
|
||||
}, []);
|
||||
|
||||
const checkAndMaybePrompt = useCallback(async () => {
|
||||
if (checkedRef.current) return;
|
||||
checkedRef.current = true;
|
||||
|
||||
const cfg = await getAppConfig();
|
||||
if (!cfg?.privacyAccepted) {
|
||||
// Wait for privacy acceptance before prompting.
|
||||
checkedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (cfg.documentsMigrationPrompted) return;
|
||||
|
||||
const { docs } = await loadLocalDexieDocs();
|
||||
const count = docs.length;
|
||||
setLocalCount(count);
|
||||
|
||||
if (count === 0) return;
|
||||
|
||||
const serverDocs = await listDocuments().catch(() => null);
|
||||
if (serverDocs) {
|
||||
const serverIds = new Set(serverDocs.map((d) => d.id));
|
||||
const missing = docs.filter((d) => !serverIds.has(d.id));
|
||||
setMissingCount(missing.length);
|
||||
if (missing.length === 0) return;
|
||||
} else {
|
||||
// If the server list fails, still prompt so the user can attempt upload.
|
||||
setMissingCount(count);
|
||||
}
|
||||
|
||||
setIsOpen(true);
|
||||
}, [loadLocalDexieDocs]);
|
||||
|
||||
useEffect(() => {
|
||||
checkAndMaybePrompt().catch((err) => {
|
||||
console.error('Dexie migration check failed:', err);
|
||||
});
|
||||
}, [checkAndMaybePrompt]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
checkedRef.current = false;
|
||||
checkAndMaybePrompt().catch((err) => console.error('Dexie migration check failed:', err));
|
||||
};
|
||||
window.addEventListener('openreader:privacyAccepted', handler as EventListener);
|
||||
return () => window.removeEventListener('openreader:privacyAccepted', handler as EventListener);
|
||||
}, [checkAndMaybePrompt]);
|
||||
|
||||
const title = 'Upload your local documents?';
|
||||
|
||||
const handleSkip = useCallback(async () => {
|
||||
await updateAppConfig({ documentsMigrationPrompted: true });
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
onComplete();
|
||||
}, [onComplete]);
|
||||
|
||||
const handleUpload = useCallback(async () => {
|
||||
setIsUploading(true);
|
||||
|
|
@ -104,7 +69,7 @@ export function DexieMigrationModal() {
|
|||
const serverDocs = await listDocuments().catch(() => null);
|
||||
const serverIds = serverDocs ? new Set(serverDocs.map((d) => d.id)) : null;
|
||||
const toUpload = serverIds ? docs.filter((d) => !serverIds.has(d.id)) : docs;
|
||||
setMissingCount(toUpload.length);
|
||||
setDisplayMissingCount(toUpload.length);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
for (let i = 0; i < toUpload.length; i++) {
|
||||
|
|
@ -159,21 +124,20 @@ export function DexieMigrationModal() {
|
|||
setStatus('Refreshing...');
|
||||
await refreshDocuments();
|
||||
await updateAppConfig({ documentsMigrationPrompted: true });
|
||||
setIsOpen(false);
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
console.error('Dexie migration upload failed:', err);
|
||||
setStatus('Upload failed. You can retry or skip.');
|
||||
checkedRef.current = false;
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}, [loadLocalDexieDocs, refreshDocuments]);
|
||||
}, [loadLocalDexieDocs, onComplete, refreshDocuments]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-[70]" onClose={() => (closeDisabled ? null : setIsOpen(false))}>
|
||||
<Dialog as="div" className="relative z-[80]" onClose={() => (closeDisabled ? null : onComplete())}>
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
|
|
@ -204,8 +168,8 @@ export function DexieMigrationModal() {
|
|||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted mb-2">
|
||||
Found {localCount} document{localCount === 1 ? '' : 's'} stored locally from an older version.
|
||||
{missingCount > 0 ? (
|
||||
<> {missingCount} {missingCount === 1 ? 'is' : 'are'} not here yet.</>
|
||||
{displayMissingCount > 0 ? (
|
||||
<> {displayMissingCount} {displayMissingCount === 1 ? 'is' : 'are'} not here yet.</>
|
||||
) : null}
|
||||
{' '}This app now stores documents on the server and keeps a local cache for speed.
|
||||
</p>
|
||||
|
|
|
|||
307
src/contexts/OnboardingFlowContext.tsx
Normal file
307
src/contexts/OnboardingFlowContext.tsx
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
'use client';
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import ClaimDataModal, { type ClaimableCounts } from '@/components/auth/ClaimDataModal';
|
||||
import { DexieMigrationModal } from '@/components/documents/DexieMigrationModal';
|
||||
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
||||
import { useAuthSession } from '@/hooks/useAuthSession';
|
||||
import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
|
||||
import { getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, getAppConfig, setFirstVisit } from '@/lib/client/dexie';
|
||||
import { listDocuments } from '@/lib/client/api/documents';
|
||||
import { postChangelogVersionCheck } from '@/lib/client/api/user-state';
|
||||
import { scheduleChangelogCheck } from '@/lib/client/changelog-check';
|
||||
import { ONBOARDING_STATE_REGISTRY } from '@/lib/shared/onboarding-state';
|
||||
|
||||
type SettingsOpenOptions = {
|
||||
changelog?: boolean;
|
||||
};
|
||||
|
||||
type SettingsController = {
|
||||
open: (options?: SettingsOpenOptions) => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
type OnboardingFlowContextValue = {
|
||||
requestOpenSettings: (options?: SettingsOpenOptions) => Promise<boolean>;
|
||||
registerSettingsController: (controller: SettingsController | null) => void;
|
||||
};
|
||||
|
||||
const OnboardingFlowContext = createContext<OnboardingFlowContextValue | null>(null);
|
||||
|
||||
type MigrationPromptState = {
|
||||
shouldPrompt: boolean;
|
||||
localCount: number;
|
||||
missingCount: number;
|
||||
};
|
||||
|
||||
const EMPTY_CLAIM_COUNTS: ClaimableCounts = {
|
||||
documents: 0,
|
||||
audiobooks: 0,
|
||||
preferences: 0,
|
||||
progress: 0,
|
||||
};
|
||||
|
||||
function toClaimableCounts(value: unknown): ClaimableCounts {
|
||||
const rec = (value && typeof value === 'object') ? (value as Record<string, unknown>) : {};
|
||||
return {
|
||||
documents: Number(rec.documents ?? 0),
|
||||
audiobooks: Number(rec.audiobooks ?? 0),
|
||||
preferences: Number(rec.preferences ?? 0),
|
||||
progress: Number(rec.progress ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchClaimableCounts(): Promise<ClaimableCounts> {
|
||||
const res = await fetch('/api/user/claim', { method: 'GET' });
|
||||
if (!res.ok) {
|
||||
return EMPTY_CLAIM_COUNTS;
|
||||
}
|
||||
const data = await res.json();
|
||||
return toClaimableCounts(data);
|
||||
}
|
||||
|
||||
async function getMigrationPromptState(): Promise<MigrationPromptState> {
|
||||
const cfg = await getAppConfig();
|
||||
if (!cfg?.privacyAccepted || cfg.documentsMigrationPrompted) {
|
||||
return { shouldPrompt: false, localCount: 0, missingCount: 0 };
|
||||
}
|
||||
|
||||
const [pdfs, epubs, htmls] = await Promise.all([
|
||||
getAllPdfDocuments(),
|
||||
getAllEpubDocuments(),
|
||||
getAllHtmlDocuments(),
|
||||
]);
|
||||
const localDocs = [
|
||||
...pdfs.map((d) => d.id),
|
||||
...epubs.map((d) => d.id),
|
||||
...htmls.map((d) => d.id),
|
||||
];
|
||||
const localCount = localDocs.length;
|
||||
if (localCount === 0) {
|
||||
return { shouldPrompt: false, localCount: 0, missingCount: 0 };
|
||||
}
|
||||
|
||||
const serverDocs = await listDocuments().catch(() => null);
|
||||
if (!serverDocs) {
|
||||
return { shouldPrompt: true, localCount, missingCount: localCount };
|
||||
}
|
||||
|
||||
const serverIds = new Set(serverDocs.map((d) => d.id));
|
||||
const missingCount = localDocs.filter((id) => !serverIds.has(id)).length;
|
||||
return {
|
||||
shouldPrompt: missingCount > 0,
|
||||
localCount,
|
||||
missingCount,
|
||||
};
|
||||
}
|
||||
|
||||
type LocalOnboardingSnapshot = {
|
||||
privacyAccepted: boolean;
|
||||
firstVisitSettingsOpened: boolean;
|
||||
};
|
||||
|
||||
async function readLocalOnboardingSnapshot(): Promise<LocalOnboardingSnapshot> {
|
||||
const appConfig = await getAppConfig();
|
||||
const row = appConfig as Record<string, unknown> | null;
|
||||
const privacyKey = ONBOARDING_STATE_REGISTRY.privacyAccepted.localKey;
|
||||
const firstVisitKey = ONBOARDING_STATE_REGISTRY.firstVisitSettingsOpened.localKey;
|
||||
|
||||
return {
|
||||
privacyAccepted: privacyKey ? Boolean(row?.[privacyKey]) : false,
|
||||
firstVisitSettingsOpened: firstVisitKey ? Boolean(row?.[firstVisitKey]) : false,
|
||||
};
|
||||
}
|
||||
|
||||
export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
|
||||
const { authEnabled } = useAuthConfig();
|
||||
const { data: session, isPending: isSessionPending } = useAuthSession();
|
||||
const runtimeConfig = useRuntimeConfig();
|
||||
const user = session?.user as { id?: string; isAnonymous?: boolean } | undefined;
|
||||
const userId = user?.id ?? null;
|
||||
const isAnonymous = Boolean(user?.isAnonymous);
|
||||
|
||||
const [activeBlockingModal, setActiveBlockingModal] = useState<'claim' | 'migration' | null>(null);
|
||||
const [claimableCounts, setClaimableCounts] = useState<ClaimableCounts>(EMPTY_CLAIM_COUNTS);
|
||||
const [migrationCounts, setMigrationCounts] = useState<{ localCount: number; missingCount: number }>({
|
||||
localCount: 0,
|
||||
missingCount: 0,
|
||||
});
|
||||
|
||||
const settingsControllerRef = useRef<SettingsController | null>(null);
|
||||
const pendingChangelogOpenRef = useRef(false);
|
||||
const runningAdvanceRef = useRef(false);
|
||||
const claimDismissedUsersRef = useRef<Set<string>>(new Set());
|
||||
const changelogVersionCheckKeyRef = useRef<string | null>(null);
|
||||
const changelogVersionCheckInFlightRef = useRef<string | null>(null);
|
||||
|
||||
const openSettingsNow = useCallback((options?: SettingsOpenOptions) => {
|
||||
settingsControllerRef.current?.open(options);
|
||||
}, []);
|
||||
|
||||
const advanceFlow = useCallback(async () => {
|
||||
if (runningAdvanceRef.current) {
|
||||
return;
|
||||
}
|
||||
runningAdvanceRef.current = true;
|
||||
try {
|
||||
if (activeBlockingModal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const local = await readLocalOnboardingSnapshot();
|
||||
if (authEnabled && !local.privacyAccepted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (authEnabled && userId && !isAnonymous && !claimDismissedUsersRef.current.has(userId)) {
|
||||
const counts = await fetchClaimableCounts();
|
||||
const total = counts.documents + counts.audiobooks + counts.preferences + counts.progress;
|
||||
if (total > 0) {
|
||||
setClaimableCounts(counts);
|
||||
setActiveBlockingModal('claim');
|
||||
return;
|
||||
}
|
||||
claimDismissedUsersRef.current.add(userId);
|
||||
}
|
||||
|
||||
const migrationState = await getMigrationPromptState();
|
||||
if (migrationState.shouldPrompt) {
|
||||
setMigrationCounts({
|
||||
localCount: migrationState.localCount,
|
||||
missingCount: migrationState.missingCount,
|
||||
});
|
||||
setActiveBlockingModal('migration');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!local.firstVisitSettingsOpened) {
|
||||
await setFirstVisit(true);
|
||||
openSettingsNow();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingChangelogOpenRef.current) {
|
||||
pendingChangelogOpenRef.current = false;
|
||||
openSettingsNow({ changelog: true });
|
||||
}
|
||||
} finally {
|
||||
runningAdvanceRef.current = false;
|
||||
}
|
||||
}, [activeBlockingModal, authEnabled, isAnonymous, openSettingsNow, userId]);
|
||||
|
||||
const requestOpenSettings = useCallback(async (options?: SettingsOpenOptions): Promise<boolean> => {
|
||||
const local = await readLocalOnboardingSnapshot();
|
||||
if (authEnabled && !local.privacyAccepted) {
|
||||
if (options?.changelog) {
|
||||
pendingChangelogOpenRef.current = true;
|
||||
}
|
||||
settingsControllerRef.current?.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (activeBlockingModal) {
|
||||
if (options?.changelog) {
|
||||
pendingChangelogOpenRef.current = true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!settingsControllerRef.current) {
|
||||
if (options?.changelog) {
|
||||
pendingChangelogOpenRef.current = true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
settingsControllerRef.current.open(options);
|
||||
return true;
|
||||
}, [activeBlockingModal, authEnabled]);
|
||||
|
||||
const registerSettingsController = useCallback((controller: SettingsController | null) => {
|
||||
settingsControllerRef.current = controller;
|
||||
if (controller) {
|
||||
void advanceFlow();
|
||||
}
|
||||
}, [advanceFlow]);
|
||||
|
||||
const handleClaimComplete = useCallback(() => {
|
||||
if (userId) {
|
||||
claimDismissedUsersRef.current.add(userId);
|
||||
}
|
||||
setActiveBlockingModal(null);
|
||||
void advanceFlow();
|
||||
}, [advanceFlow, userId]);
|
||||
|
||||
const handleMigrationComplete = useCallback(() => {
|
||||
setActiveBlockingModal(null);
|
||||
void advanceFlow();
|
||||
}, [advanceFlow]);
|
||||
|
||||
useEffect(() => {
|
||||
void advanceFlow();
|
||||
}, [advanceFlow, authEnabled, isAnonymous, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authEnabled) {
|
||||
return;
|
||||
}
|
||||
const onPrivacyAccepted = () => {
|
||||
void advanceFlow();
|
||||
};
|
||||
window.addEventListener('openreader:privacyAccepted', onPrivacyAccepted);
|
||||
return () => {
|
||||
window.removeEventListener('openreader:privacyAccepted', onPrivacyAccepted);
|
||||
};
|
||||
}, [advanceFlow, authEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
return scheduleChangelogCheck({
|
||||
authEnabled,
|
||||
isSessionPending,
|
||||
sessionUserId: userId,
|
||||
appVersion: runtimeConfig.appVersion,
|
||||
completedRef: changelogVersionCheckKeyRef,
|
||||
inFlightRef: changelogVersionCheckInFlightRef,
|
||||
postCheck: async (currentVersion) => postChangelogVersionCheck(currentVersion),
|
||||
onShouldOpen: () => {
|
||||
pendingChangelogOpenRef.current = true;
|
||||
void advanceFlow();
|
||||
},
|
||||
delayMs: 120,
|
||||
retryDelayMs: 400,
|
||||
});
|
||||
}, [advanceFlow, authEnabled, isSessionPending, runtimeConfig.appVersion, userId]);
|
||||
|
||||
const contextValue = useMemo<OnboardingFlowContextValue>(() => ({
|
||||
requestOpenSettings,
|
||||
registerSettingsController,
|
||||
}), [registerSettingsController, requestOpenSettings]);
|
||||
|
||||
return (
|
||||
<OnboardingFlowContext.Provider value={contextValue}>
|
||||
{children}
|
||||
{authEnabled && (
|
||||
<ClaimDataModal
|
||||
isOpen={activeBlockingModal === 'claim'}
|
||||
claimableCounts={claimableCounts}
|
||||
onDismiss={handleClaimComplete}
|
||||
onClaimed={handleClaimComplete}
|
||||
/>
|
||||
)}
|
||||
<DexieMigrationModal
|
||||
isOpen={activeBlockingModal === 'migration'}
|
||||
localCount={migrationCounts.localCount}
|
||||
missingCount={migrationCounts.missingCount}
|
||||
onComplete={handleMigrationComplete}
|
||||
/>
|
||||
</OnboardingFlowContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useOnboardingFlow() {
|
||||
const context = useContext(OnboardingFlowContext);
|
||||
if (!context) {
|
||||
throw new Error('useOnboardingFlow must be used inside OnboardingFlowProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
scheduleChangelogCheck,
|
||||
type ChangelogVersionCheckResponse,
|
||||
} from '@/lib/client/changelog-check';
|
||||
|
||||
type OpenSettingsOptions = {
|
||||
changelog?: boolean;
|
||||
};
|
||||
|
||||
type LocalOnboardingSnapshot = {
|
||||
privacyAccepted: boolean;
|
||||
firstVisitSettingsOpened: boolean;
|
||||
};
|
||||
|
||||
type UseOnboardingCoordinatorArgs = {
|
||||
authEnabled: boolean;
|
||||
isSessionPending: boolean;
|
||||
sessionUserId: string | null | undefined;
|
||||
appVersion: string | null | undefined;
|
||||
isSettingsOpen: boolean;
|
||||
readLocalSnapshot: () => Promise<LocalOnboardingSnapshot>;
|
||||
markFirstVisitSettingsOpened: () => Promise<void>;
|
||||
postChangelogVersionCheck: (currentVersion: string) => Promise<ChangelogVersionCheckResponse>;
|
||||
openSettings: (options?: OpenSettingsOptions) => void;
|
||||
closeSettings: () => void;
|
||||
};
|
||||
|
||||
type UseOnboardingCoordinatorResult = {
|
||||
requestOpenSettings: (options?: OpenSettingsOptions) => Promise<boolean>;
|
||||
};
|
||||
|
||||
export function useOnboardingCoordinator(
|
||||
args: UseOnboardingCoordinatorArgs,
|
||||
): UseOnboardingCoordinatorResult {
|
||||
const {
|
||||
authEnabled,
|
||||
isSessionPending,
|
||||
sessionUserId,
|
||||
appVersion,
|
||||
isSettingsOpen,
|
||||
readLocalSnapshot,
|
||||
markFirstVisitSettingsOpened,
|
||||
postChangelogVersionCheck,
|
||||
openSettings,
|
||||
closeSettings,
|
||||
} = args;
|
||||
|
||||
const pendingChangelogOpenRef = useRef(false);
|
||||
const changelogVersionCheckKeyRef = useRef<string | null>(null);
|
||||
const changelogVersionCheckInFlightRef = useRef<string | null>(null);
|
||||
|
||||
const canOpenSettingsNow = useCallback(async (): Promise<{
|
||||
allowed: boolean;
|
||||
local: LocalOnboardingSnapshot | null;
|
||||
}> => {
|
||||
if (!authEnabled) {
|
||||
return { allowed: true, local: null };
|
||||
}
|
||||
|
||||
const local = await readLocalSnapshot();
|
||||
return {
|
||||
allowed: local.privacyAccepted,
|
||||
local,
|
||||
};
|
||||
}, [authEnabled, readLocalSnapshot]);
|
||||
|
||||
const requestOpenSettings = useCallback(async (options?: OpenSettingsOptions): Promise<boolean> => {
|
||||
const gate = await canOpenSettingsNow();
|
||||
if (!gate.allowed) {
|
||||
if (options?.changelog) {
|
||||
pendingChangelogOpenRef.current = true;
|
||||
}
|
||||
closeSettings();
|
||||
return false;
|
||||
}
|
||||
|
||||
openSettings({ changelog: Boolean(options?.changelog) });
|
||||
return true;
|
||||
}, [canOpenSettingsNow, closeSettings, openSettings]);
|
||||
|
||||
const maybeOpenFirstVisitSettings = useCallback(async () => {
|
||||
const gate = await canOpenSettingsNow();
|
||||
if (!gate.allowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstVisitSettingsOpened = gate.local
|
||||
? gate.local.firstVisitSettingsOpened
|
||||
: (await readLocalSnapshot()).firstVisitSettingsOpened;
|
||||
|
||||
if (firstVisitSettingsOpened) {
|
||||
return;
|
||||
}
|
||||
|
||||
await markFirstVisitSettingsOpened();
|
||||
openSettings();
|
||||
}, [canOpenSettingsNow, markFirstVisitSettingsOpened, openSettings, readLocalSnapshot]);
|
||||
|
||||
const replayDeferredChangelogOpen = useCallback(async () => {
|
||||
if (!pendingChangelogOpenRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const opened = await requestOpenSettings({ changelog: true });
|
||||
if (opened) {
|
||||
pendingChangelogOpenRef.current = false;
|
||||
}
|
||||
}, [requestOpenSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
maybeOpenFirstVisitSettings().catch((err) => {
|
||||
console.error('First visit settings check failed:', err);
|
||||
});
|
||||
}, [maybeOpenFirstVisitSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onPrivacyAccepted = () => {
|
||||
maybeOpenFirstVisitSettings().catch((err) => {
|
||||
console.error('First visit settings check after privacy acceptance failed:', err);
|
||||
});
|
||||
replayDeferredChangelogOpen().catch((err) => {
|
||||
console.error('Deferred changelog open after privacy acceptance failed:', err);
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('openreader:privacyAccepted', onPrivacyAccepted);
|
||||
return () => {
|
||||
window.removeEventListener('openreader:privacyAccepted', onPrivacyAccepted);
|
||||
};
|
||||
}, [authEnabled, maybeOpenFirstVisitSettings, replayDeferredChangelogOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSettingsOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const gate = await canOpenSettingsNow();
|
||||
if (!gate.allowed && !cancelled) {
|
||||
closeSettings();
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [canOpenSettingsNow, closeSettings, isSettingsOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
return scheduleChangelogCheck({
|
||||
authEnabled,
|
||||
isSessionPending,
|
||||
sessionUserId,
|
||||
appVersion,
|
||||
completedRef: changelogVersionCheckKeyRef,
|
||||
inFlightRef: changelogVersionCheckInFlightRef,
|
||||
postCheck: postChangelogVersionCheck,
|
||||
onShouldOpen: () => {
|
||||
void requestOpenSettings({ changelog: true });
|
||||
},
|
||||
delayMs: 120,
|
||||
retryDelayMs: 400,
|
||||
});
|
||||
}, [
|
||||
appVersion,
|
||||
authEnabled,
|
||||
isSessionPending,
|
||||
postChangelogVersionCheck,
|
||||
sessionUserId,
|
||||
requestOpenSettings,
|
||||
]);
|
||||
|
||||
return {
|
||||
requestOpenSettings,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue