Files
beenvoice-web/src/components/providers/animation-preferences-provider-synced.tsx
T
soconnorandCursor 6ec26a4a0d Redesign marketing pages and isolate public routes from app auth.
Give the landing, legal, and sign-in flows a consistent product shell while keeping marketing pages free of tRPC/session calls, fixing dev auth URL handling, and refreshing env and deploy docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 02:00:06 -04:00

236 lines
6.6 KiB
TypeScript

"use client";
import {
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { api } from "~/trpc/react";
import {
AnimationPreferencesContext,
DEFAULT_PREFERS_REDUCED,
DEFAULT_SPEED,
applyPreferencesToDOM,
clampSpeed,
readLocalStorage,
writeLocalStorage,
type AnimationPreferencesContextValue,
type AnimationPreferencesProviderProps,
} from "~/components/providers/animation-preferences-provider";
type PartialPrefs = {
prefersReducedMotion?: boolean;
animationSpeedMultiplier?: number;
};
/** Dashboard animation preferences with tRPC sync. Must render inside TRPCReactProvider. */
export function AnimationPreferencesProviderSynced({
children,
initial,
autoSync = true,
}: AnimationPreferencesProviderProps & { autoSync?: boolean }) {
const updateMutation = api.settings.updateAnimationPreferences.useMutation();
const { data: serverPrefs } = api.settings.getAnimationPreferences.useQuery(
undefined,
{
refetchOnWindowFocus: false,
staleTime: 60_000,
retry: false,
},
);
const [prefersReducedMotion, setPrefersReducedMotion] = useState<boolean>(
initial?.prefersReducedMotion ?? DEFAULT_PREFERS_REDUCED,
);
const [animationSpeedMultiplier, setAnimationSpeedMultiplier] =
useState<number>(
clampSpeed(initial?.animationSpeedMultiplier ?? DEFAULT_SPEED),
);
const [lastSyncedAt, setLastSyncedAt] = useState<number | null>(null);
const pendingSyncRef = useRef<PartialPrefs | null>(null);
const isHydratedRef = useRef(false);
const serverHydratedRef = useRef(false);
const [isUpdating, setIsUpdating] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
const stored = readLocalStorage();
const systemReduced = window.matchMedia?.(
"(prefers-reduced-motion: reduce)",
).matches;
const finalPrefers =
stored?.prefersReducedMotion ??
initial?.prefersReducedMotion ??
systemReduced ??
DEFAULT_PREFERS_REDUCED;
const finalSpeed = clampSpeed(
stored?.animationSpeedMultiplier ??
initial?.animationSpeedMultiplier ??
DEFAULT_SPEED,
);
// eslint-disable-next-line react-hooks/set-state-in-effect
setPrefersReducedMotion(finalPrefers);
setAnimationSpeedMultiplier(finalSpeed);
applyPreferencesToDOM({
prefersReducedMotion: finalPrefers,
animationSpeedMultiplier: finalSpeed,
});
isHydratedRef.current = true;
}, [initial?.prefersReducedMotion, initial?.animationSpeedMultiplier]);
const performUpdate = useCallback(
(patch: PartialPrefs, opts?: { sync?: boolean }) => {
setIsUpdating(true);
setPrefersReducedMotion((prev) => patch.prefersReducedMotion ?? prev);
setAnimationSpeedMultiplier((prev) =>
clampSpeed(patch.animationSpeedMultiplier ?? prev),
);
const normalizedPatch: PartialPrefs = { ...patch };
if (
normalizedPatch.prefersReducedMotion === true &&
normalizedPatch.animationSpeedMultiplier === undefined &&
animationSpeedMultiplier !== 1
) {
normalizedPatch.animationSpeedMultiplier = 1;
}
const nextReduced =
normalizedPatch.prefersReducedMotion ?? prefersReducedMotion;
let nextSpeed = clampSpeed(
normalizedPatch.animationSpeedMultiplier ?? animationSpeedMultiplier,
);
if (nextReduced && nextSpeed !== 1) {
nextSpeed = 1;
normalizedPatch.animationSpeedMultiplier ??= 1;
}
const newPrefs = {
prefersReducedMotion: nextReduced,
animationSpeedMultiplier: nextSpeed,
};
applyPreferencesToDOM(newPrefs);
writeLocalStorage(newPrefs);
const shouldSync = opts?.sync ?? autoSync;
if (shouldSync && serverPrefs) {
pendingSyncRef.current = {
prefersReducedMotion: patch.prefersReducedMotion,
animationSpeedMultiplier: patch.animationSpeedMultiplier,
};
updateMutation.mutate(
{
...(normalizedPatch.prefersReducedMotion !== undefined && {
prefersReducedMotion: normalizedPatch.prefersReducedMotion,
}),
...(normalizedPatch.animationSpeedMultiplier !== undefined && {
animationSpeedMultiplier: clampSpeed(
normalizedPatch.animationSpeedMultiplier,
),
}),
},
{
onSuccess: () => {
setLastSyncedAt(Date.now());
pendingSyncRef.current = null;
setIsUpdating(false);
},
onError: () => {
setIsUpdating(false);
},
},
);
} else {
setIsUpdating(false);
}
},
[
prefersReducedMotion,
animationSpeedMultiplier,
autoSync,
updateMutation,
serverPrefs,
],
);
useEffect(() => {
if (!isHydratedRef.current) return;
if (serverHydratedRef.current) return;
if (!serverPrefs) return;
const localIsDefault =
prefersReducedMotion === DEFAULT_PREFERS_REDUCED &&
animationSpeedMultiplier === DEFAULT_SPEED;
const differs =
serverPrefs.prefersReducedMotion !== prefersReducedMotion ||
serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier;
if (localIsDefault || differs) {
// eslint-disable-next-line react-hooks/set-state-in-effect
performUpdate(
{
prefersReducedMotion: serverPrefs.prefersReducedMotion,
animationSpeedMultiplier: serverPrefs.animationSpeedMultiplier,
},
{ sync: false },
);
}
serverHydratedRef.current = true;
}, [
serverPrefs,
performUpdate,
prefersReducedMotion,
animationSpeedMultiplier,
]);
const updatePreferences = useCallback<
AnimationPreferencesContextValue["updatePreferences"]
>(
(patch, opts) => {
performUpdate(patch, opts);
},
[performUpdate],
);
const handleSetReduced = useCallback(
(val: boolean) => {
updatePreferences({ prefersReducedMotion: val });
},
[updatePreferences],
);
const handleSetSpeed = useCallback(
(val: number) => {
updatePreferences({ animationSpeedMultiplier: clampSpeed(val) });
},
[updatePreferences],
);
const value: AnimationPreferencesContextValue = {
prefersReducedMotion,
animationSpeedMultiplier,
updatePreferences,
setPrefersReducedMotion: handleSetReduced,
setAnimationSpeedMultiplier: handleSetSpeed,
isUpdating: isUpdating || updateMutation.isPending,
lastSyncedAt,
};
return (
<AnimationPreferencesContext.Provider value={value}>
{children}
</AnimationPreferencesContext.Provider>
);
}