phase-7: motion, an installable app, and a capture that survives no signal
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4c39926483
commit
e4eb1617d1
@@ -7,13 +7,16 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { CategoryPicker } from '@/components/category-picker';
|
||||
import { ConfettiMoment } from '@/components/confetti-moment';
|
||||
import { Button, buttonClasses } from '@/components/ui/button';
|
||||
import { CheckDraw } from '@/components/ui/check-draw';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { useT } from '@/i18n/t';
|
||||
import { api } from '@/lib/api';
|
||||
import { DURATION, EASE, gsap, usePrefersReducedMotion } from '@/lib/motion';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Past this many pixels, letting go commits the swipe. */
|
||||
@@ -28,8 +31,13 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [index, setIndex] = useState(0);
|
||||
const [picking, setPicking] = useState(false);
|
||||
const [drag, setDrag] = useState(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [celebrating, setCelebrating] = useState(false);
|
||||
const reduced = usePrefersReducedMotion();
|
||||
|
||||
// The drag lives on the element rather than in state: a re-render per pointer move is
|
||||
// both slower and a fight with GSAP over who owns the transform.
|
||||
const card = useRef<HTMLDivElement>(null);
|
||||
const nextCard = useRef<HTMLDivElement>(null);
|
||||
const dragStart = useRef<number | null>(null);
|
||||
|
||||
const pending = useQuery({
|
||||
@@ -37,11 +45,23 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
queryFn: ({ signal }) => api.listDocuments({ status: 'needs_review' }, signal),
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether anything has ever been confirmed. FLOWS.md A6 makes the first confirmed
|
||||
* document the moment worth celebrating, and it is only the first one: the count is read
|
||||
* before the confirm so the answer is about what the user had, not what they now have.
|
||||
*/
|
||||
const confirmed = useQuery({
|
||||
queryKey: ['documents', { status: 'confirmed' }],
|
||||
queryFn: ({ signal }) => api.listDocuments({ status: 'confirmed' }, signal),
|
||||
});
|
||||
const nothingConfirmedYet = confirmed.data?.total === 0;
|
||||
|
||||
const documents = pending.data?.items ?? [];
|
||||
// Confirming the last card shortens the list under us. Clamping here rather than in an
|
||||
// effect keeps the render consistent and avoids a cascading update.
|
||||
const safeIndex = Math.min(index, Math.max(0, documents.length - 1));
|
||||
const current = documents[safeIndex];
|
||||
const next = documents[safeIndex + 1];
|
||||
|
||||
const invalidate = useCallback(
|
||||
() => queryClient.invalidateQueries({ queryKey: ['documents'] }),
|
||||
@@ -51,7 +71,7 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
const confirm = useMutation({
|
||||
mutationFn: (id: string) => api.confirmDocument(id),
|
||||
onSuccess: async () => {
|
||||
setDrag(0);
|
||||
if (nothingConfirmedYet) setCelebrating(true);
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
@@ -59,10 +79,7 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
const reject = useMutation({
|
||||
mutationFn: (args: { id: string; reason: 'not_mine' | 'duplicate' | 'other' }) =>
|
||||
api.rejectDocument(args.id, { reason: args.reason }),
|
||||
onSuccess: async () => {
|
||||
setDrag(0);
|
||||
await invalidate();
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const reclassify = useMutation({
|
||||
@@ -74,14 +91,55 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Sends the card off the way it was pushed, brings the one behind it forward, and only
|
||||
* then does the thing (FLOWS.md B4: fly out on commit, next card scales up). Under
|
||||
* reduced motion the action happens immediately and nothing moves.
|
||||
*/
|
||||
const flyOut = useCallback(
|
||||
(direction: 1 | -1, run: () => void) => {
|
||||
const node = card.current;
|
||||
if (reduced || !node) {
|
||||
run();
|
||||
return;
|
||||
}
|
||||
gsap.to(node, {
|
||||
x: direction * 520,
|
||||
rotation: direction * 16,
|
||||
autoAlpha: 0,
|
||||
duration: DURATION.base,
|
||||
ease: 'power2.in',
|
||||
onComplete: () => {
|
||||
gsap.set(node, { clearProps: 'all' });
|
||||
run();
|
||||
},
|
||||
});
|
||||
if (nextCard.current) {
|
||||
gsap.to(nextCard.current, { scale: 1, opacity: 1, duration: DURATION.base, ease: EASE });
|
||||
}
|
||||
},
|
||||
[reduced],
|
||||
);
|
||||
|
||||
const settle = useCallback(() => {
|
||||
const node = card.current;
|
||||
if (!node) return;
|
||||
if (reduced) {
|
||||
gsap.set(node, { x: 0, rotation: 0 });
|
||||
return;
|
||||
}
|
||||
gsap.to(node, { x: 0, rotation: 0, duration: DURATION.base, ease: EASE });
|
||||
}, [reduced]);
|
||||
|
||||
// Desktop keyboard mirrors of every gesture (FLOWS.md B4).
|
||||
useEffect(() => {
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (!current || picking) return;
|
||||
if (event.key === 'j') setIndex(Math.min(safeIndex + 1, documents.length - 1));
|
||||
else if (event.key === 'k') setIndex(Math.max(safeIndex - 1, 0));
|
||||
else if (event.key === 'Enter') confirm.mutate(current.id);
|
||||
else if (event.key === 'x' || event.key === 'X') reject.mutate({ id: current.id, reason: 'other' });
|
||||
else if (event.key === 'Enter') flyOut(1, () => confirm.mutate(current.id));
|
||||
else if (event.key === 'x' || event.key === 'X')
|
||||
flyOut(-1, () => reject.mutate({ id: current.id, reason: 'other' }));
|
||||
else if (/^[1-8]$/.test(event.key)) {
|
||||
const category = IRP_CATEGORIES[Number(event.key) - 1];
|
||||
if (category) reclassify.mutate({ id: current.id, irpCategory: category });
|
||||
@@ -91,7 +149,7 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [current, documents.length, safeIndex, picking, confirm, reject, reclassify]);
|
||||
}, [current, documents.length, safeIndex, picking, confirm, reject, reclassify, flyOut]);
|
||||
|
||||
if (pending.isPending) {
|
||||
return (
|
||||
@@ -124,7 +182,7 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
<div className="py-4">
|
||||
<Card>
|
||||
<EmptyState
|
||||
icon={<Check aria-hidden className="size-8" />}
|
||||
icon={<CheckDraw className="size-8" />}
|
||||
title={t('bandeja.empty')}
|
||||
body={t('bandeja.emptyBody')}
|
||||
action={
|
||||
@@ -143,6 +201,8 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-4">
|
||||
<ConfettiMoment play={celebrating} />
|
||||
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('bandeja.title')}</h1>
|
||||
<span className="text-sm text-[var(--text-muted)]">
|
||||
@@ -150,31 +210,45 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{/*
|
||||
The shoulder of the next card, so the stack is visible and committing this one
|
||||
has somewhere to go. A shoulder rather than a whole card behind: the front card's
|
||||
height depends on its content, and a full card would sit entirely hidden behind a
|
||||
tall one and entirely exposed behind a short one.
|
||||
*/}
|
||||
{next ? (
|
||||
<div
|
||||
ref={nextCard}
|
||||
aria-hidden
|
||||
className="absolute inset-x-4 -top-2 h-8 origin-bottom scale-95 rounded-2xl border bg-[var(--surface-raised)] opacity-70"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Card
|
||||
ref={card}
|
||||
data-testid="bandeja-card"
|
||||
className="touch-pan-y space-y-4 select-none"
|
||||
style={{
|
||||
transform: `translateX(${drag}px) rotate(${drag / 40}deg)`,
|
||||
transition: dragging ? 'none' : 'transform 200ms ease-out',
|
||||
}}
|
||||
className="relative touch-pan-y space-y-4 select-none"
|
||||
onPointerDown={(event) => {
|
||||
dragStart.current = event.clientX;
|
||||
setDragging(true);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (dragStart.current === null) return;
|
||||
setDrag(event.clientX - dragStart.current);
|
||||
const offset = event.clientX - dragStart.current;
|
||||
gsap.set(card.current, { x: offset, rotation: offset / 40 });
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
const offset = drag;
|
||||
onPointerUp={(event) => {
|
||||
const start = dragStart.current;
|
||||
dragStart.current = null;
|
||||
setDragging(false);
|
||||
if (offset > COMMIT_PX) confirm.mutate(current.id);
|
||||
if (start === null) return;
|
||||
const offset = event.clientX - start;
|
||||
|
||||
if (offset > COMMIT_PX) flyOut(1, () => confirm.mutate(current.id));
|
||||
else if (offset < -COMMIT_PX) {
|
||||
settle();
|
||||
setPicking(true);
|
||||
setDrag(0);
|
||||
} else setDrag(0);
|
||||
} else settle();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
@@ -208,6 +282,7 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
</ul>
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{picking ? (
|
||||
<Card className="space-y-3">
|
||||
@@ -229,7 +304,7 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
size="lg"
|
||||
block
|
||||
disabled={confirm.isPending}
|
||||
onClick={() => confirm.mutate(current.id)}
|
||||
onClick={() => flyOut(1, () => confirm.mutate(current.id))}
|
||||
>
|
||||
<Check aria-hidden className="size-4" />
|
||||
{t('bandeja.confirm')}
|
||||
@@ -239,7 +314,7 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={reject.isPending}
|
||||
onClick={() => reject.mutate({ id: current.id, reason: 'other' })}
|
||||
onClick={() => flyOut(-1, () => reject.mutate({ id: current.id, reason: 'other' }))}
|
||||
>
|
||||
<X aria-hidden className="size-4" />
|
||||
{t('scan.result.discard')}
|
||||
|
||||
@@ -4,9 +4,11 @@ import { isApiError, type DeclarationDto } from '@impuestos/contracts';
|
||||
import { formatDateLong, formatGs, formatGsAmount, type Locale } from '@impuestos/i18n';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRef, useState } from 'react';
|
||||
import { ConfettiMoment } from '@/components/confetti-moment';
|
||||
import { FormPreview } from '@/components/form-preview';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Reveal } from '@/components/ui/reveal';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useT } from '@/i18n/t';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -22,6 +24,7 @@ export function DeclarationDetail({ id, locale }: { id: string; locale: string }
|
||||
const queryClient = useQueryClient();
|
||||
const confirmDialog = useRef<HTMLDialogElement>(null);
|
||||
const [staleWarning, setStaleWarning] = useState<string | null>(null);
|
||||
const [celebrating, setCelebrating] = useState(false);
|
||||
|
||||
const declaration = useQuery({
|
||||
queryKey: ['declarations', id],
|
||||
@@ -32,6 +35,8 @@ export function DeclarationDetail({ id, locale }: { id: string; locale: string }
|
||||
const approve = useMutation({
|
||||
mutationFn: () => api.approveDeclaration(id),
|
||||
onSuccess: async () => {
|
||||
// One of the three moments FLOWS.md section 8 allows a celebration.
|
||||
setCelebrating(true);
|
||||
setStaleWarning(null);
|
||||
confirmDialog.current?.close();
|
||||
await queryClient.invalidateQueries({ queryKey: ['declarations'] });
|
||||
@@ -75,7 +80,9 @@ export function DeclarationDetail({ id, locale }: { id: string; locale: string }
|
||||
const officialTitle = `Formulario ${data.formCode}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-4 pb-28">
|
||||
<Reveal className="space-y-4 py-4 pb-28">
|
||||
<ConfettiMoment play={celebrating} />
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{officialTitle}</h1>
|
||||
<p className="tnum text-sm text-[var(--text-muted)]">{data.period}</p>
|
||||
@@ -172,7 +179,7 @@ export function DeclarationDetail({ id, locale }: { id: string; locale: string }
|
||||
{t('decl.approvedAt', { date: formatDateLong(locale as Locale, data.approvedAt.slice(0, 10)) })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { formatDateLong, formatGs, type Locale } from '@impuestos/i18n';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { CelebrationCanvas } from '@/components/canvas/celebration-canvas';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { useT } from '@/i18n/t';
|
||||
@@ -46,8 +47,10 @@ export function FilingChecklist({
|
||||
|
||||
if (filed) {
|
||||
return (
|
||||
<Card className="space-y-2">
|
||||
{/* A short celebration belongs here (FLOWS.md D3); the motion pass is phase 7. */}
|
||||
<Card className="relative isolate space-y-2 overflow-hidden">
|
||||
{/* The second and last canvas spot (FLOWS.md D3), only in the moment it happens:
|
||||
a reload of an already filed declaration is not a new success. */}
|
||||
{markFiled.isSuccess ? <CelebrationCanvas /> : null}
|
||||
<h2 className="text-xl font-semibold tracking-tight text-positive">
|
||||
{t('decl.filed.title')}
|
||||
</h2>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Link, useRouter } from '@/i18n/navigation';
|
||||
import { useT } from '@/i18n/t';
|
||||
import { api } from '@/lib/api';
|
||||
import { enqueueCapture, listQueue, subscribeToQueue } from '@/lib/offline-queue';
|
||||
import { captureFrame, decodeQr, decodeQrFromVideo } from '@/lib/qr';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -35,6 +36,16 @@ export function ScanScreen({ locale }: { locale: string }) {
|
||||
const [showNoQrHint, setShowNoQrHint] = useState(false);
|
||||
const [torchOn, setTorchOn] = useState(false);
|
||||
const [outcome, setOutcome] = useState<Outcome | null>(null);
|
||||
// Whether anything of this reader's is still waiting to be sent. It follows the queue
|
||||
// rather than being set once, so the notice goes away when the capture actually lands.
|
||||
const [queued, setQueued] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => void listQueue().then((items) => setQueued(items.length > 0));
|
||||
const unsubscribe = subscribeToQueue(check);
|
||||
void Promise.resolve().then(check);
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async (input: { file: File; qrPayload: string | null }) => {
|
||||
@@ -54,6 +65,16 @@ export function ScanScreen({ locale }: { locale: string }) {
|
||||
const submit = useCallback(
|
||||
(file: File, qrPayload: string | null) => {
|
||||
if (busy.current) return;
|
||||
|
||||
/**
|
||||
* Offline, the capture is kept rather than lost (FLOWS.md B2). The chip in the shell
|
||||
* sends it the moment there is a connection, so this screen can say so and move on.
|
||||
*/
|
||||
if (!navigator.onLine) {
|
||||
void enqueueCapture(file, qrPayload);
|
||||
return;
|
||||
}
|
||||
|
||||
busy.current = true;
|
||||
upload.mutate({ file, qrPayload }, { onSettled: () => (busy.current = false) });
|
||||
},
|
||||
@@ -200,6 +221,12 @@ export function ScanScreen({ locale }: { locale: string }) {
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{queued ? (
|
||||
<p role="status" className="text-center text-sm text-[var(--text-muted)]">
|
||||
{t('offline.queued')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{upload.isError ? (
|
||||
<p role="alert" className="text-center text-sm text-overdue">
|
||||
{t('common.error.generic')}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { TraceableNumber } from '@/components/traceable-number';
|
||||
import { Button, buttonClasses } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
import { Reveal } from '@/components/ui/reveal';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { useT } from '@/i18n/t';
|
||||
@@ -53,7 +54,7 @@ export function Dashboard({ locale, fullName }: { locale: Locale; fullName: stri
|
||||
const nothingYet = !data.hasDocuments;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-4">
|
||||
<Reveal className="space-y-4 py-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('home.title')}</h1>
|
||||
<p className="text-sm text-[var(--text-muted)]">{fullName}</p>
|
||||
@@ -89,7 +90,7 @@ export function Dashboard({ locale, fullName }: { locale: Locale; fullName: stri
|
||||
<InsightFeed insights={data.insights} locale={locale} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { AppShell } from '@/components/app-shell';
|
||||
import { LanguageSwitcher } from '@/components/language-switcher';
|
||||
import { OfflineSync } from '@/components/offline-sync';
|
||||
import { PwaProvider } from '@/components/pwa-provider';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { useT } from '@/i18n/t';
|
||||
|
||||
@@ -22,6 +24,8 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
{/* Bottom padding clears the tab bar and the scan button above it. */}
|
||||
<main className="flex-1 px-5 pb-36">{children}</main>
|
||||
<AppShell />
|
||||
<OfflineSync />
|
||||
<PwaProvider />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
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';
|
||||
@@ -83,11 +85,14 @@ export function NotificationsSection({ profile }: { profile: ProfileDto }) {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* Push and Telegram are hidden until they are configured (FLOWS.md section 9).
|
||||
They are wired with the notification channels in phase 4. */}
|
||||
{/* 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
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useT } from '@/i18n/t';
|
||||
import { api } from '@/lib/api';
|
||||
import { DangerZone } from './danger-zone';
|
||||
import { InstallPrompt } from '@/components/install-prompt';
|
||||
import { Reveal } from '@/components/ui/reveal';
|
||||
import { DependentsSection } from './dependents-section';
|
||||
import { IdentitySection } from './identity-section';
|
||||
import { NotificationsSection } from './notifications-section';
|
||||
@@ -53,12 +55,13 @@ export function ProfileScreen() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-4">
|
||||
<Reveal className="space-y-4 py-4">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('profile.title')}</h1>
|
||||
<IdentitySection profile={profile.data} />
|
||||
<DependentsSection />
|
||||
<NotificationsSection profile={profile.data} />
|
||||
<InstallPrompt />
|
||||
<DangerZone />
|
||||
</div>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user