GSAP carries the counter roll-ups, the bandeja card physics, the dialog transitions and the three success moments FLOWS.md allows. Every one of them checks prefers-reduced-motion first and does nothing when it is set. boneyard and canvas-ui are not what SPEC.md's stack table says they are: on npm the names belong to two abandoned projects that do neither job. The skeletons were already ours; the two canvas spots are now sixty lines each with no dependency. DECISIONS.md records the substitution. The app installs, keeps a scan taken with no network in IndexedDB and sends it when there is one, falls back to a page that explains itself, and can push a deadline notice. Reading the log of what is queued is the source of truth, so the notice clears when the capture actually lands. The CSP now allows scripts by per-request nonce rather than by 'unsafe-inline'. That forced /offline to render per request: a prerendered page carries a build-time nonce no live policy matches, so its scripts were blocked and it never hydrated. Two crashes fixed on the way. web-push throws on a VAPID subject that is not https: or mailto:, and the code handed it APP_PUBLIC_URL, so any machine with push keys died at boot; a misconfigured optional channel now switches itself off and says why. And a subscription the push service answers 410 for is deleted rather than retried forever. Lighthouse on the production build: accessibility 100, best practices 96, SEO 100, performance 73. The performance number is not trustworthy on this machine and DECISIONS.md says why; total blocking time did fall from 17.6s to 1.7s once the hero canvas stopped drawing at full resolution every frame and the landing page stopped importing GSAP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
115 lines
4.1 KiB
TypeScript
115 lines
4.1 KiB
TypeScript
'use client';
|
|
|
|
import type { ProfileDto } from '@impuestos/contracts';
|
|
import { SUPPORTED_LOCALES, type Locale } from '@impuestos/i18n';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { PushOptIn } from '@/components/push-optin';
|
|
import { ThemeToggle } from '@/components/theme-toggle';
|
|
import { Card } from '@/components/ui/card';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { usePathname, useRouter } from '@/i18n/navigation';
|
|
import { useT } from '@/i18n/t';
|
|
import { api } from '@/lib/api';
|
|
|
|
const LABEL_KEY = { es: 'common.languageEs', en: 'common.languageEn' } as const;
|
|
|
|
export function NotificationsSection({ profile }: { profile: ProfileDto }) {
|
|
const t = useT();
|
|
const queryClient = useQueryClient();
|
|
const router = useRouter();
|
|
const pathname = usePathname();
|
|
|
|
const prefs = useQuery({
|
|
queryKey: ['me', 'notification-prefs'],
|
|
queryFn: ({ signal }) => api.getNotificationPrefs(signal),
|
|
});
|
|
|
|
const update = useMutation({
|
|
mutationFn: api.patchNotificationPrefs,
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['me', 'notification-prefs'] }),
|
|
});
|
|
|
|
/**
|
|
* Changing the language here persists it to the profile, because the API sends
|
|
* notifications, emails and error messages in the locale stored there.
|
|
*/
|
|
const setLocale = useMutation({
|
|
mutationFn: (locale: Locale) => api.putProfile({ ...profile, locale }),
|
|
onSuccess: async (_data, locale) => {
|
|
await queryClient.invalidateQueries({ queryKey: ['me'] });
|
|
router.replace(pathname, { locale });
|
|
},
|
|
});
|
|
|
|
return (
|
|
<Card className="space-y-5">
|
|
<h2 className="font-semibold tracking-tight">{t('profile.notifications')}</h2>
|
|
|
|
{prefs.isPending ? <Skeleton name="doc-list-item" count={2} /> : null}
|
|
|
|
{prefs.isError ? (
|
|
<p role="alert" className="text-sm text-overdue">
|
|
{t('common.error.generic')}
|
|
</p>
|
|
) : null}
|
|
|
|
{prefs.data ? (
|
|
<div className="space-y-4">
|
|
<label className="flex items-center justify-between gap-4">
|
|
<span className="text-sm">{t('notif.email')}</span>
|
|
<Switch
|
|
checked={prefs.data.emailEnabled}
|
|
onCheckedChange={(emailEnabled) => update.mutate({ emailEnabled })}
|
|
label={t('notif.email')}
|
|
/>
|
|
</label>
|
|
|
|
<label className="flex items-center justify-between gap-4">
|
|
<span className="text-sm">{t('notif.digestHour')}</span>
|
|
<select
|
|
aria-label={t('notif.digestHour')}
|
|
value={prefs.data.digestHour}
|
|
onChange={(event) => update.mutate({ digestHour: Number(event.target.value) })}
|
|
className="tnum h-11 rounded-2xl border bg-[var(--surface-raised)] px-3"
|
|
>
|
|
{Array.from({ length: 24 }).map((_, hour) => {
|
|
// A clock time, not copy: the same in every locale.
|
|
const label = `${String(hour).padStart(2, '0')}:00`;
|
|
return (
|
|
<option key={hour} value={hour}>
|
|
{label}
|
|
</option>
|
|
);
|
|
})}
|
|
</select>
|
|
</label>
|
|
|
|
{/* Push shows itself only where it can work; Telegram is linked from the bot
|
|
rather than here (FLOWS.md section 9: unconfigured channels stay hidden). */}
|
|
<PushOptIn />
|
|
</div>
|
|
) : null}
|
|
|
|
<ThemeToggle />
|
|
|
|
<div className="flex items-center justify-between gap-4 border-t pt-4">
|
|
<span className="text-sm">{t('common.language')}</span>
|
|
<select
|
|
aria-label={t('common.language')}
|
|
value={profile.locale}
|
|
disabled={setLocale.isPending}
|
|
onChange={(event) => setLocale.mutate(event.target.value as Locale)}
|
|
className="h-11 rounded-2xl border bg-[var(--surface-raised)] px-3"
|
|
>
|
|
{SUPPORTED_LOCALES.map((code) => (
|
|
<option key={code} value={code}>
|
|
{t(LABEL_KEY[code])}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|