feat: framer-motion page transitions and UI polish
This commit is contained in:
parent
72d95065d0
commit
ec7869abbc
|
|
@ -4,6 +4,7 @@ import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import Layout from '@/components/layout/Layout';
|
import Layout from '@/components/layout/Layout';
|
||||||
import AppNavigation from '@/components/layout/Sidebar';
|
import AppNavigation from '@/components/layout/Sidebar';
|
||||||
|
import PageTransition from '@/components/PageTransition';
|
||||||
import { ReleaseNotesDialog } from '@/components/ReleaseNotesDialog';
|
import { ReleaseNotesDialog } from '@/components/ReleaseNotesDialog';
|
||||||
import CommandPalette from '@/components/CommandPalette';
|
import CommandPalette from '@/components/CommandPalette';
|
||||||
import LoginPage from '@/pages/LoginPage';
|
import LoginPage from '@/pages/LoginPage';
|
||||||
|
|
@ -84,11 +85,15 @@ function RequireAuth({ children, role }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function AdminShell({ children }) {
|
function AdminShell({ children }) {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.10),transparent_34rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.28))] text-foreground">
|
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.10),transparent_34rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.28))] text-foreground">
|
||||||
<AppNavigation adminMode />
|
<AppNavigation adminMode />
|
||||||
<main className="mx-auto w-full max-w-[1500px] px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
|
<main className="mx-auto w-full max-w-[1500px] px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
|
||||||
{children}
|
<PageTransition routeKey={location.pathname}>
|
||||||
|
{children}
|
||||||
|
</PageTransition>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1101,8 +1101,9 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
||||||
Loading payment history...
|
Loading payment history...
|
||||||
</div>
|
</div>
|
||||||
) : payments.length === 0 ? (
|
) : payments.length === 0 ? (
|
||||||
<div className="rounded-lg border border-dashed border-border/70 px-3 py-8 text-center text-sm text-muted-foreground">
|
<div className="flex flex-col items-center gap-1.5 rounded-lg bg-muted/20 px-3 py-8 text-center">
|
||||||
No payments recorded for this bill.
|
<p className="text-sm font-medium text-muted-foreground">No payments yet</p>
|
||||||
|
<p className="text-xs text-muted-foreground/60">Use the form below to record the first payment.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="max-h-52 space-y-2 overflow-y-auto pr-1">
|
<div className="max-h-52 space-y-2 overflow-y-auto pr-1">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
|
||||||
|
|
||||||
|
export default function PageTransition({ children, routeKey }) {
|
||||||
|
const reduceMotion = useReducedMotion();
|
||||||
|
|
||||||
|
if (reduceMotion) return children;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
<motion.div
|
||||||
|
key={routeKey}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -6 }}
|
||||||
|
transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Link, Outlet } from 'react-router-dom';
|
import { Link, Outlet, useLocation } from 'react-router-dom';
|
||||||
import AppNavigation from './Sidebar';
|
import AppNavigation from './Sidebar';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
|
import PageTransition from '@/components/PageTransition';
|
||||||
|
|
||||||
function SimplefinBadge() {
|
function SimplefinBadge() {
|
||||||
const [enabled, setEnabled] = useState(false);
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
|
@ -23,6 +24,8 @@ function SimplefinBadge() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Layout({ mainContentId }) {
|
export default function Layout({ mainContentId }) {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.06),transparent_30rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.12))] text-foreground"
|
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.06),transparent_30rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.12))] text-foreground"
|
||||||
role="main"
|
role="main"
|
||||||
|
|
@ -34,7 +37,9 @@ export default function Layout({ mainContentId }) {
|
||||||
<div className="mx-auto w-full max-w-[1500px] px-4 py-6 sm:px-6 lg:px-8 lg:py-8"
|
<div className="mx-auto w-full max-w-[1500px] px-4 py-6 sm:px-6 lg:px-8 lg:py-8"
|
||||||
id={mainContentId}
|
id={mainContentId}
|
||||||
>
|
>
|
||||||
<Outlet />
|
<PageTransition routeKey={location.pathname}>
|
||||||
|
<Outlet />
|
||||||
|
</PageTransition>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<footer className="mx-auto flex w-full max-w-[1500px] flex-wrap items-center justify-center gap-x-4 gap-y-2 px-4 pb-6 text-xs text-muted-foreground sm:px-6 lg:px-8"
|
<footer className="mx-auto flex w-full max-w-[1500px] flex-wrap items-center justify-center gap-x-4 gap-y-2 px-4 pb-6 text-xs text-muted-foreground sm:px-6 lg:px-8"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
import { ArrowDown, ArrowUp, GripVertical, Pencil, TrendingUp } from 'lucide-react';
|
import { ArrowDown, ArrowUp, GripVertical, Pencil, TrendingUp } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api.js';
|
import { api } from '@/api.js';
|
||||||
|
|
@ -144,7 +145,9 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<motion.div
|
||||||
|
layout="position"
|
||||||
|
transition={{ layout: { duration: 0.2, ease: [0.22, 1, 0.36, 1] } }}
|
||||||
draggable={dragProps?.draggable}
|
draggable={dragProps?.draggable}
|
||||||
onDragStart={dragProps?.onDragStart}
|
onDragStart={dragProps?.onDragStart}
|
||||||
onDragEnter={dragProps?.onDragEnter}
|
onDragEnter={dragProps?.onDragEnter}
|
||||||
|
|
@ -359,7 +362,7 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
|
||||||
<div className="rounded-md border border-border/50 bg-muted/25 px-2 py-1.5">
|
<div className="rounded-md border border-border/50 bg-muted/25 px-2 py-1.5">
|
||||||
<NotesCell row={{ ...row, year, month }} refresh={refresh} />
|
<NotesCell row={{ ...row, year, month }} refresh={refresh} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{editPayment && (
|
{editPayment && (
|
||||||
<PaymentModal
|
<PaymentModal
|
||||||
|
|
|
||||||
|
|
@ -103,9 +103,10 @@ export function PaymentLedgerDialog({ row, year, month, threshold, defaultPaymen
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="rounded-md border border-dashed border-border/70 px-3 py-8 text-center text-sm text-muted-foreground">
|
<div className="flex flex-col items-center gap-1 rounded-md bg-muted/20 px-3 py-7 text-center">
|
||||||
No payments recorded for this month.
|
<p className="text-sm font-medium text-muted-foreground">No payments yet</p>
|
||||||
</p>
|
<p className="text-xs text-muted-foreground/60">Add one below.</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { LayoutGroup } from 'framer-motion';
|
||||||
import { ArrowDown, ArrowUp } from 'lucide-react';
|
import { ArrowDown, ArrowUp } from 'lucide-react';
|
||||||
import { cn, fmt } from '@/lib/utils';
|
import { cn, fmt } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
|
|
@ -160,6 +161,7 @@ export function TrackerBucket({ label, rows, year, month, refresh, onEditBill, l
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<LayoutGroup id={`tracker-bucket-mobile-${label}`}>
|
||||||
<div className="grid gap-3 p-3 lg:hidden" aria-busy={loading ? 'true' : 'false'}>
|
<div className="grid gap-3 p-3 lg:hidden" aria-busy={loading ? 'true' : 'false'}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
Array.from({ length: 3 }).map((_, i) => (
|
Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
|
@ -186,8 +188,8 @@ export function TrackerBucket({ label, rows, year, month, refresh, onEditBill, l
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<div className="rounded-lg border border-dashed border-border/70 bg-background/40 px-4 py-8 text-center text-sm text-muted-foreground">
|
<div className="rounded-lg bg-muted/15 px-4 py-8 text-center text-sm text-muted-foreground">
|
||||||
No bills match this bucket and filter set.
|
No bills match — try adjusting your filters.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
rows.map((r, i) => (
|
rows.map((r, i) => (
|
||||||
|
|
@ -206,6 +208,7 @@ export function TrackerBucket({ label, rows, year, month, refresh, onEditBill, l
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</LayoutGroup>
|
||||||
|
|
||||||
<div className="hidden lg:block" aria-busy={loading ? 'true' : 'false'}>
|
<div className="hidden lg:block" aria-busy={loading ? 'true' : 'false'}>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
|
|
@ -255,24 +258,26 @@ export function TrackerBucket({ label, rows, year, month, refresh, onEditBill, l
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<TableRow className="border-border/50">
|
<TableRow className="border-border/50">
|
||||||
<TableCell colSpan={9} className="py-10 text-center text-sm text-muted-foreground">
|
<TableCell colSpan={9} className="py-10 text-center text-sm text-muted-foreground">
|
||||||
No bills match this bucket and filter set.
|
No bills match — try adjusting your filters.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
rows.map((r, i) => (
|
<LayoutGroup id={`tracker-bucket-table-${label}`}>
|
||||||
<Row
|
{rows.map((r, i) => (
|
||||||
key={r.id}
|
<Row
|
||||||
row={r}
|
key={r.id}
|
||||||
year={year}
|
row={r}
|
||||||
month={month}
|
year={year}
|
||||||
refresh={refresh}
|
month={month}
|
||||||
index={i}
|
refresh={refresh}
|
||||||
onEditBill={onEditBill}
|
index={i}
|
||||||
moveControls={moveControlsFor(r, i)}
|
onEditBill={onEditBill}
|
||||||
dragProps={dragPropsFor(r, i)}
|
moveControls={moveControlsFor(r, i)}
|
||||||
isDrifted={driftedIds.has(r.id)}
|
dragProps={dragPropsFor(r, i)}
|
||||||
/>
|
isDrifted={driftedIds.has(r.id)}
|
||||||
))
|
/>
|
||||||
|
))}
|
||||||
|
</LayoutGroup>
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
|
||||||
|
|
@ -281,6 +281,8 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TableRow
|
<TableRow
|
||||||
|
layout="position"
|
||||||
|
transition={{ layout: { duration: 0.2, ease: [0.22, 1, 0.36, 1] } }}
|
||||||
data-tracker-row
|
data-tracker-row
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-rowindex={index + 1}
|
aria-rowindex={index + 1}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { buttonVariants } from '@/components/ui/button';
|
import { buttonVariants } from '@/components/ui/button';
|
||||||
|
|
||||||
|
|
@ -20,19 +21,29 @@ function AlertDialogOverlay({ className, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogContent({ className, ...props }) {
|
function AlertDialogContent({ className, children, ...props }) {
|
||||||
return (
|
return (
|
||||||
<AlertDialogPortal>
|
<AlertDialogPortal>
|
||||||
<AlertDialogOverlay />
|
<AlertDialogOverlay />
|
||||||
<AlertDialogPrimitive.Content
|
<AlertDialogPrimitive.Content
|
||||||
|
asChild
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: '-50%', y: '-48%', scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, x: '-50%', y: '-50%', scale: 1 }}
|
||||||
|
exit={{ opacity: 0, x: '-50%', y: '-48%', scale: 0.98 }}
|
||||||
|
transition={{ duration: 0.16, ease: [0.22, 1, 0.36, 1] }}
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-1rem)] max-w-md max-h-[calc(100svh-1rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-2xl border border-border/70 bg-card p-4 text-card-foreground shadow-xl duration-200 sm:w-full sm:max-h-[calc(100svh-2rem)] sm:p-6 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-1rem)] max-w-md max-h-[calc(100svh-1rem)] gap-4 overflow-y-auto rounded-2xl border border-border/70 bg-card p-4 text-card-foreground shadow-xl duration-200 sm:w-full sm:max-h-[calc(100svh-2rem)] sm:p-6',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
>
|
||||||
/>
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
</AlertDialogPrimitive.Content>
|
||||||
</AlertDialogPortal>
|
</AlertDialogPortal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
|
@ -25,20 +26,28 @@ function DialogContent({ className, children, ref, ...props }) {
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
|
asChild
|
||||||
ref={ref}
|
ref={ref}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: '-50%', y: '-48%', scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, x: '-50%', y: '-50%', scale: 1 }}
|
||||||
|
exit={{ opacity: 0, x: '-50%', y: '-48%', scale: 0.98 }}
|
||||||
|
transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-1rem)] max-w-lg max-h-[calc(100svh-1rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-2xl border border-border/70 bg-card p-4 text-card-foreground shadow-xl duration-200 sm:w-full sm:max-h-[calc(100svh-2rem)] sm:p-6 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-1rem)] max-w-lg max-h-[calc(100svh-1rem)] gap-4 overflow-y-auto rounded-2xl border border-border/70 bg-card p-4 text-card-foreground shadow-xl duration-200 sm:w-full sm:max-h-[calc(100svh-2rem)] sm:p-6',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-full p-1 opacity-70 transition-all hover:bg-accent hover:opacity-100 focus:outline-none focus:ring-[3px] focus:ring-ring/50 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground" aria-label="Close dialog">
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-full p-1 opacity-70 transition-all hover:bg-accent hover:opacity-100 focus:outline-none focus:ring-[3px] focus:ring-ring/50 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground" aria-label="Close dialog">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
|
</motion.div>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
function Table({ className, ref, ...props }) {
|
function Table({ className, ref, ...props }) {
|
||||||
|
|
@ -58,7 +59,7 @@ function TableFooter({ className, ref, ...props }) {
|
||||||
|
|
||||||
function TableRow({ className, ref, ...props }) {
|
function TableRow({ className, ref, ...props }) {
|
||||||
return (
|
return (
|
||||||
<tr
|
<motion.tr
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'border-b border-border/50 last:border-0',
|
'border-b border-border/50 last:border-0',
|
||||||
|
|
|
||||||
|
|
@ -501,7 +501,7 @@ function HistoryVisibilityDialog({ bill, onClose, onSaved }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ranges.length === 0 ? (
|
{ranges.length === 0 ? (
|
||||||
<div className="rounded-lg border border-dashed border-border px-4 py-8 text-center text-sm text-muted-foreground">
|
<div className="rounded-lg bg-muted/20 px-4 py-8 text-center text-sm text-muted-foreground">
|
||||||
No ranges yet. Add a range to use selected history.
|
No ranges yet. Add a range to use selected history.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -649,8 +649,8 @@ function DayDetailDialog({ day, open, onOpenChange, moneyMarker }) {
|
||||||
<section>
|
<section>
|
||||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Bills Due</h3>
|
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Bills Due</h3>
|
||||||
{day.bills_due.length === 0 ? (
|
{day.bills_due.length === 0 ? (
|
||||||
<div className="rounded-lg border border-dashed border-border/70 p-4 text-sm text-muted-foreground">
|
<div className="rounded-lg bg-muted/15 p-4 text-sm text-muted-foreground">
|
||||||
No bills are due on this day.
|
No bills due this day.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -151,10 +151,10 @@ function ExpandedBills({ category }) {
|
||||||
if (!bills.length) {
|
if (!bills.length) {
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-border/60 bg-muted/15 px-4 py-5 sm:px-6">
|
<div className="border-t border-border/60 bg-muted/15 px-4 py-5 sm:px-6">
|
||||||
<div className="flex flex-col gap-3 rounded-lg border border-dashed border-border/70 bg-background/65 p-4 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 rounded-lg bg-muted/20 p-4 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
||||||
<span>No bills in this category yet.</span>
|
<span>No bills in this category yet.</span>
|
||||||
<Button asChild variant="outline" size="sm" className="w-fit">
|
<Button asChild variant="outline" size="sm" className="w-fit">
|
||||||
<Link to="/bills">Open Bills</Link>
|
<Link to="/bills">Add a bill →</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ function InputRow({ label, hint, children }) {
|
||||||
|
|
||||||
function EmptyDebts() {
|
function EmptyDebts() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border/60 bg-muted/10 px-6 py-16 text-center">
|
<div className="flex flex-col items-center justify-center rounded-xl bg-muted/20 px-6 py-16 text-center">
|
||||||
<TrendingDown className="h-10 w-10 text-muted-foreground/40 mb-4" />
|
<TrendingDown className="h-10 w-10 text-muted-foreground/40 mb-4" />
|
||||||
<p className="text-sm font-medium text-foreground">No bills with a balance found</p>
|
<p className="text-sm font-medium text-foreground">No bills with a balance found</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
|
@ -125,7 +125,7 @@ function EmptyDebts() {
|
||||||
|
|
||||||
function NoSelection() {
|
function NoSelection() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border/60 bg-muted/10 px-6 py-16 text-center">
|
<div className="flex flex-col items-center justify-center rounded-xl bg-muted/20 px-6 py-16 text-center">
|
||||||
<Calculator className="h-10 w-10 text-muted-foreground/40 mb-4" />
|
<Calculator className="h-10 w-10 text-muted-foreground/40 mb-4" />
|
||||||
<p className="text-sm font-medium text-foreground">Select a loan or debt to begin</p>
|
<p className="text-sm font-medium text-foreground">Select a loan or debt to begin</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
|
@ -597,7 +597,7 @@ export default function PayoffPage() {
|
||||||
startBalance={activeBalance}
|
startBalance={activeBalance}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center rounded-xl border border-dashed border-border/60 bg-muted/10 h-[300px] text-sm text-muted-foreground">
|
<div className="flex items-center justify-center rounded-xl bg-muted/20 h-[300px] text-sm text-muted-foreground">
|
||||||
{customNeedsBalance
|
{customNeedsBalance
|
||||||
? 'Enter a balance and payment to see the chart'
|
? 'Enter a balance and payment to see the chart'
|
||||||
: simPaymentN <= 0
|
: simPaymentN <= 0
|
||||||
|
|
|
||||||
|
|
@ -441,7 +441,7 @@ export default function RoadmapPage() {
|
||||||
<p className="text-sm text-muted-foreground">{roadmapError}</p>
|
<p className="text-sm text-muted-foreground">{roadmapError}</p>
|
||||||
</div>
|
</div>
|
||||||
) : issues.length === 0 ? (
|
) : issues.length === 0 ? (
|
||||||
<div className="rounded-xl border border-dashed border-border/60 py-12 text-center text-sm text-muted-foreground">
|
<div className="rounded-xl bg-muted/20 py-12 text-center text-sm text-muted-foreground">
|
||||||
No open issues.
|
No open issues.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -475,7 +475,7 @@ export default function RoadmapPage() {
|
||||||
<p className="text-sm text-muted-foreground mt-1">{devLogError}</p>
|
<p className="text-sm text-muted-foreground mt-1">{devLogError}</p>
|
||||||
</div>
|
</div>
|
||||||
) : devLogData && devLogData.entries?.length === 0 ? (
|
) : devLogData && devLogData.entries?.length === 0 ? (
|
||||||
<div className="rounded-xl border border-dashed border-border/60 py-12 text-center text-sm text-muted-foreground">
|
<div className="rounded-xl bg-muted/20 py-12 text-center text-sm text-muted-foreground">
|
||||||
No activity log entries found.
|
No activity log entries found.
|
||||||
</div>
|
</div>
|
||||||
) : devLogData ? (
|
) : devLogData ? (
|
||||||
|
|
|
||||||
|
|
@ -759,7 +759,7 @@ export default function SnowballPage() {
|
||||||
|
|
||||||
{/* Empty state */}
|
{/* Empty state */}
|
||||||
{bills.length === 0 && (
|
{bills.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border/60 py-20 text-center gap-3">
|
<div className="flex flex-col items-center justify-center rounded-xl bg-muted/20 py-20 text-center gap-3">
|
||||||
<TrendingDown className="h-10 w-10 text-muted-foreground/30" />
|
<TrendingDown className="h-10 w-10 text-muted-foreground/30" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">No debt bills found</p>
|
<p className="text-sm font-medium text-muted-foreground">No debt bills found</p>
|
||||||
<p className="text-xs text-muted-foreground/70 max-w-sm">
|
<p className="text-xs text-muted-foreground/70 max-w-sm">
|
||||||
|
|
|
||||||
|
|
@ -663,8 +663,9 @@ export default function SummaryPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expenses.length === 0 ? (
|
{expenses.length === 0 ? (
|
||||||
<div className="rounded-xl border border-dashed border-border p-6 text-sm text-muted-foreground">
|
<div className="flex flex-col items-center gap-2 rounded-xl bg-muted/20 px-6 py-10 text-center">
|
||||||
No bills found for this month.
|
<p className="text-sm font-medium text-muted-foreground">No bills for this month</p>
|
||||||
|
<a href="/bills" className="text-xs text-primary underline underline-offset-4 hover:opacity-80 transition-opacity">Add a bill →</a>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-2xl border border-border/60 bg-background/70 px-3">
|
<div className="rounded-2xl border border-border/60 bg-background/70 px-3">
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^8.4.1",
|
"express-rate-limit": "^8.4.1",
|
||||||
|
"framer-motion": "^12.40.0",
|
||||||
"lucide-react": "^0.456.0",
|
"lucide-react": "^0.456.0",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"nodemailer": "^8.0.9",
|
"nodemailer": "^8.0.9",
|
||||||
|
|
@ -7007,6 +7008,33 @@
|
||||||
"url": "https://github.com/sponsors/rawify"
|
"url": "https://github.com/sponsors/rawify"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/framer-motion": {
|
||||||
|
"version": "12.40.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz",
|
||||||
|
"integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"motion-dom": "^12.40.0",
|
||||||
|
"motion-utils": "^12.39.0",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emotion/is-prop-valid": "*",
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@emotion/is-prop-valid": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fresh": {
|
"node_modules/fresh": {
|
||||||
"version": "0.5.2",
|
"version": "0.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||||
|
|
@ -9252,6 +9280,21 @@
|
||||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/motion-dom": {
|
||||||
|
"version": "12.40.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz",
|
||||||
|
"integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"motion-utils": "^12.39.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/motion-utils": {
|
||||||
|
"version": "12.39.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
|
||||||
|
"integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^8.4.1",
|
"express-rate-limit": "^8.4.1",
|
||||||
|
"framer-motion": "^12.40.0",
|
||||||
"lucide-react": "^0.456.0",
|
"lucide-react": "^0.456.0",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"nodemailer": "^8.0.9",
|
"nodemailer": "^8.0.9",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue