mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2025-12-15 18:44:45 -05:00
Add global animation system and entrance effects to UI
This commit is contained in:
BIN
public/beenvoice.afdesign
Normal file
BIN
public/beenvoice.afdesign
Normal file
Binary file not shown.
84
src/app/dashboard/_components/animated-stats-card.tsx
Normal file
84
src/app/dashboard/_components/animated-stats-card.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
TrendingDown,
|
||||||
|
TrendingUp,
|
||||||
|
DollarSign,
|
||||||
|
Clock,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
|
|
||||||
|
type IconName = "DollarSign" | "Clock" | "Users" | "TrendingDown";
|
||||||
|
|
||||||
|
interface AnimatedStatsCardProps {
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
change: string;
|
||||||
|
trend: "up" | "down";
|
||||||
|
iconName: IconName;
|
||||||
|
description: string;
|
||||||
|
delay?: number;
|
||||||
|
isCurrency?: boolean;
|
||||||
|
numericValue?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconMap = {
|
||||||
|
DollarSign,
|
||||||
|
Clock,
|
||||||
|
Users,
|
||||||
|
TrendingDown,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function AnimatedStatsCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
change,
|
||||||
|
trend,
|
||||||
|
iconName,
|
||||||
|
description,
|
||||||
|
delay = 0,
|
||||||
|
isCurrency = false,
|
||||||
|
numericValue,
|
||||||
|
}: AnimatedStatsCardProps) {
|
||||||
|
const Icon = iconMap[iconName];
|
||||||
|
const TrendIcon = trend === "up" ? TrendingUp : TrendingDown;
|
||||||
|
const isPositive = trend === "up";
|
||||||
|
|
||||||
|
// For now, always use the formatted value prop to ensure correct display
|
||||||
|
// Animation can be added back once the basic display is working correctly
|
||||||
|
const displayValue = value;
|
||||||
|
|
||||||
|
// Suppress unused parameter warnings for now
|
||||||
|
void delay;
|
||||||
|
void isCurrency;
|
||||||
|
void numericValue;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="stats-card">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between space-y-0 pb-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Icon className="text-muted-foreground h-5 w-5" />
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">{title}</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-1 text-xs"
|
||||||
|
style={{
|
||||||
|
color: isPositive
|
||||||
|
? "oklch(var(--chart-2))"
|
||||||
|
: "oklch(var(--chart-3))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TrendIcon className="h-3 w-3" />
|
||||||
|
<span>{change}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="animate-count-up text-2xl font-bold">{displayValue}</p>
|
||||||
|
<p className="text-muted-foreground text-xs">{description}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,13 +16,13 @@ async function BusinessesTable() {
|
|||||||
|
|
||||||
export default async function BusinessesPage() {
|
export default async function BusinessesPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="page-enter space-y-8">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Businesses"
|
title="Businesses"
|
||||||
description="Manage your businesses and their information"
|
description="Manage your businesses and their information"
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
>
|
>
|
||||||
<Button asChild variant="default" className="shadow-md">
|
<Button asChild variant="default" className="hover-lift shadow-md">
|
||||||
<Link href="/dashboard/businesses/new">
|
<Link href="/dashboard/businesses/new">
|
||||||
<Plus className="mr-2 h-5 w-5" />
|
<Plus className="mr-2 h-5 w-5" />
|
||||||
<span>Add Business</span>
|
<span>Add Business</span>
|
||||||
@@ -35,6 +35,6 @@ export default async function BusinessesPage() {
|
|||||||
<BusinessesTable />
|
<BusinessesTable />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import { ClientsTable } from "./_components/clients-table";
|
|||||||
|
|
||||||
export default async function ClientsPage() {
|
export default async function ClientsPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="page-enter space-y-8">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Clients"
|
title="Clients"
|
||||||
description="Manage your clients and their information."
|
description="Manage your clients and their information."
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
>
|
>
|
||||||
<Button asChild variant="default" className="shadow-md">
|
<Button asChild variant="default" className="hover-lift shadow-md">
|
||||||
<Link href="/dashboard/clients/new">
|
<Link href="/dashboard/clients/new">
|
||||||
<Plus className="mr-2 h-5 w-5" />
|
<Plus className="mr-2 h-5 w-5" />
|
||||||
<span>Add Client</span>
|
<span>Add Client</span>
|
||||||
@@ -24,6 +24,6 @@ export default async function ClientsPage() {
|
|||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
<ClientsTable />
|
<ClientsTable />
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,14 +123,18 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-24">
|
<div className="page-enter space-y-6 pb-24">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Invoice Details"
|
title="Invoice Details"
|
||||||
description="View and manage invoice information"
|
description="View and manage invoice information"
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
>
|
>
|
||||||
<PDFDownloadButton invoiceId={invoice.id} variant="outline" />
|
<PDFDownloadButton
|
||||||
<Button asChild variant="default">
|
invoiceId={invoice.id}
|
||||||
|
variant="outline"
|
||||||
|
className="hover-lift"
|
||||||
|
/>
|
||||||
|
<Button asChild variant="default" className="hover-lift">
|
||||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||||
<Edit className="h-5 w-5" />
|
<Edit className="h-5 w-5" />
|
||||||
<span>Edit</span>
|
<span>Edit</span>
|
||||||
@@ -324,8 +328,8 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{invoice.items.map((item) => (
|
{invoice.items.map((item, _index) => (
|
||||||
<Card key={item.id} className="card-secondary">
|
<Card key={item.id} className="invoice-item card-secondary">
|
||||||
<CardContent className="p-3">
|
<CardContent className="p-3">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
|||||||
@@ -165,7 +165,14 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const invoice = row.original;
|
const invoice = row.original;
|
||||||
return <StatusBadge status={getStatusType(invoice)} />;
|
return (
|
||||||
|
<StatusBadge
|
||||||
|
status={getStatusType(invoice)}
|
||||||
|
className={
|
||||||
|
getStatusType(invoice) === "sent" ? "status-pending" : ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
filterFn: (row, id, value: string[]) => {
|
filterFn: (row, id, value: string[]) => {
|
||||||
const invoice = row.original;
|
const invoice = row.original;
|
||||||
@@ -210,7 +217,7 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 w-8 p-0"
|
className="hover-scale h-8 w-8 p-0"
|
||||||
data-action-button="true"
|
data-action-button="true"
|
||||||
>
|
>
|
||||||
<Eye className="h-3.5 w-3.5" />
|
<Eye className="h-3.5 w-3.5" />
|
||||||
@@ -220,7 +227,7 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 w-8 p-0"
|
className="hover-scale h-8 w-8 p-0"
|
||||||
data-action-button="true"
|
data-action-button="true"
|
||||||
>
|
>
|
||||||
<Edit className="h-3.5 w-3.5" />
|
<Edit className="h-3.5 w-3.5" />
|
||||||
@@ -229,7 +236,7 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-destructive hover:text-destructive/80 h-8 w-8 p-0"
|
className="hover-scale text-destructive hover:text-destructive/80 h-8 w-8 p-0"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleDelete(invoice);
|
handleDelete(invoice);
|
||||||
|
|||||||
@@ -16,19 +16,19 @@ async function InvoicesTable() {
|
|||||||
|
|
||||||
export default async function InvoicesPage() {
|
export default async function InvoicesPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="page-enter space-y-8">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Invoices"
|
title="Invoices"
|
||||||
description="Manage your invoices and track payments"
|
description="Manage your invoices and track payments"
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
>
|
>
|
||||||
<Button asChild variant="outline" className="shadow-sm">
|
<Button asChild variant="outline" className="hover-lift shadow-sm">
|
||||||
<Link href="/dashboard/invoices/import">
|
<Link href="/dashboard/invoices/import">
|
||||||
<Upload className="mr-2 h-5 w-5" />
|
<Upload className="mr-2 h-5 w-5" />
|
||||||
<span>Import CSV</span>
|
<span>Import CSV</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild variant="default" className="shadow-md">
|
<Button asChild variant="default" className="hover-lift shadow-md">
|
||||||
<Link href="/dashboard/invoices/new">
|
<Link href="/dashboard/invoices/new">
|
||||||
<Plus className="mr-2 h-5 w-5" />
|
<Plus className="mr-2 h-5 w-5" />
|
||||||
<span>Create Invoice</span>
|
<span>Create Invoice</span>
|
||||||
@@ -41,6 +41,6 @@ export default async function InvoicesPage() {
|
|||||||
<InvoicesTable />
|
<InvoicesTable />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,10 @@ import {
|
|||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Calendar,
|
Calendar,
|
||||||
Clock,
|
|
||||||
DollarSign,
|
|
||||||
Edit,
|
Edit,
|
||||||
Eye,
|
Eye,
|
||||||
FileText,
|
FileText,
|
||||||
Plus,
|
Plus,
|
||||||
TrendingDown,
|
|
||||||
TrendingUp,
|
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -26,6 +22,7 @@ import type { StoredInvoiceStatus } from "~/types/invoice";
|
|||||||
import { RevenueChart } from "~/app/dashboard/_components/revenue-chart";
|
import { RevenueChart } from "~/app/dashboard/_components/revenue-chart";
|
||||||
import { InvoiceStatusChart } from "~/app/dashboard/_components/invoice-status-chart";
|
import { InvoiceStatusChart } from "~/app/dashboard/_components/invoice-status-chart";
|
||||||
import { MonthlyMetricsChart } from "~/app/dashboard/_components/monthly-metrics-chart";
|
import { MonthlyMetricsChart } from "~/app/dashboard/_components/monthly-metrics-chart";
|
||||||
|
import { AnimatedStatsCard } from "~/app/dashboard/_components/animated-stats-card";
|
||||||
|
|
||||||
// Hero section with clean mono design
|
// Hero section with clean mono design
|
||||||
function DashboardHero({ firstName }: { firstName: string }) {
|
function DashboardHero({ firstName }: { firstName: string }) {
|
||||||
@@ -160,80 +157,79 @@ async function DashboardStats() {
|
|||||||
return value > 0 ? `+${value.toFixed(1)}%` : `${value.toFixed(1)}%`;
|
return value > 0 ? `+${value.toFixed(1)}%` : `${value.toFixed(1)}%`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Debug logging to see actual values
|
||||||
|
console.log("Dashboard Stats Debug:", {
|
||||||
|
totalRevenue,
|
||||||
|
pendingAmount,
|
||||||
|
totalClients,
|
||||||
|
overdueInvoices: overdueInvoices.length,
|
||||||
|
revenueChange,
|
||||||
|
pendingChange,
|
||||||
|
clientChange,
|
||||||
|
overdueChange,
|
||||||
|
paidInvoicesCount: paidInvoices.length,
|
||||||
|
pendingInvoicesCount: pendingInvoices.length,
|
||||||
|
});
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{
|
{
|
||||||
title: "Total Revenue",
|
title: "Total Revenue",
|
||||||
value: `$${totalRevenue.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
|
value: `$${totalRevenue.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
|
||||||
|
numericValue: totalRevenue,
|
||||||
|
isCurrency: true,
|
||||||
change: formatTrend(revenueChange),
|
change: formatTrend(revenueChange),
|
||||||
trend: revenueChange >= 0 ? ("up" as const) : ("down" as const),
|
trend: revenueChange >= 0 ? ("up" as const) : ("down" as const),
|
||||||
icon: DollarSign,
|
iconName: "DollarSign" as const,
|
||||||
description: `From ${paidInvoices.length} paid invoices`,
|
description: `From ${paidInvoices.length} paid invoices`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Pending Amount",
|
title: "Pending Amount",
|
||||||
value: `$${pendingAmount.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
|
value: `$${pendingAmount.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
|
||||||
|
numericValue: pendingAmount,
|
||||||
|
isCurrency: true,
|
||||||
change: formatTrend(pendingChange),
|
change: formatTrend(pendingChange),
|
||||||
trend: pendingChange >= 0 ? ("up" as const) : ("down" as const),
|
trend: pendingChange >= 0 ? ("up" as const) : ("down" as const),
|
||||||
icon: Clock,
|
iconName: "Clock" as const,
|
||||||
description: `${pendingInvoices.length} invoices awaiting payment`,
|
description: `${pendingInvoices.length} invoices awaiting payment`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Active Clients",
|
title: "Active Clients",
|
||||||
value: totalClients.toString(),
|
value: totalClients.toString(),
|
||||||
|
numericValue: totalClients,
|
||||||
|
isCurrency: false,
|
||||||
change: formatTrend(clientChange, true),
|
change: formatTrend(clientChange, true),
|
||||||
trend: clientChange >= 0 ? ("up" as const) : ("down" as const),
|
trend: clientChange >= 0 ? ("up" as const) : ("down" as const),
|
||||||
icon: Users,
|
iconName: "Users" as const,
|
||||||
description: "Total registered clients",
|
description: "Total registered clients",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Overdue Invoices",
|
title: "Overdue Invoices",
|
||||||
value: overdueInvoices.length.toString(),
|
value: overdueInvoices.length.toString(),
|
||||||
|
numericValue: overdueInvoices.length,
|
||||||
|
isCurrency: false,
|
||||||
change: formatTrend(overdueChange, true),
|
change: formatTrend(overdueChange, true),
|
||||||
trend: overdueChange <= 0 ? ("up" as const) : ("down" as const),
|
trend: overdueChange <= 0 ? ("up" as const) : ("down" as const),
|
||||||
icon: TrendingDown,
|
iconName: "TrendingDown" as const,
|
||||||
description: "Invoices past due date",
|
description: "Invoices past due date",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-8 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="mb-8 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{stats.map((stat) => {
|
{stats.map((stat, index) => (
|
||||||
const Icon = stat.icon;
|
<AnimatedStatsCard
|
||||||
const TrendIcon = stat.trend === "up" ? TrendingUp : TrendingDown;
|
key={stat.title}
|
||||||
const isPositive = stat.trend === "up";
|
title={stat.title}
|
||||||
|
value={stat.value}
|
||||||
return (
|
numericValue={stat.numericValue}
|
||||||
<Card key={stat.title}>
|
isCurrency={stat.isCurrency}
|
||||||
<CardContent className="p-6">
|
iconName={stat.iconName}
|
||||||
<div className="flex items-center justify-between space-y-0 pb-2">
|
change={stat.change}
|
||||||
<div className="flex items-center space-x-2">
|
trend={stat.trend}
|
||||||
<Icon className="text-muted-foreground h-5 w-5" />
|
description={stat.description}
|
||||||
<p className="text-muted-foreground text-sm font-medium">
|
delay={index * 100}
|
||||||
{stat.title}
|
/>
|
||||||
</p>
|
))}
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="flex items-center space-x-1 text-xs"
|
|
||||||
style={{
|
|
||||||
color: isPositive
|
|
||||||
? "oklch(var(--chart-2))"
|
|
||||||
: "oklch(var(--chart-3))",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TrendIcon className="h-3 w-3" />
|
|
||||||
<span>{stat.change}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-2xl font-bold">{stat.value}</p>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
{stat.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -327,7 +323,7 @@ function QuickActions() {
|
|||||||
<Link
|
<Link
|
||||||
key={action.title}
|
key={action.title}
|
||||||
href={action.href}
|
href={action.href}
|
||||||
className={`flex w-full items-start space-x-3 rounded-lg border p-4 transition-colors ${
|
className={`hover-lift flex w-full items-start space-x-3 rounded-lg border p-4 transition-colors ${
|
||||||
action.featured
|
action.featured
|
||||||
? "border-foreground/20 bg-muted/50 hover:bg-muted"
|
? "border-foreground/20 bg-muted/50 hover:bg-muted"
|
||||||
: "border-border bg-background hover:bg-muted/50"
|
: "border-border bg-background hover:bg-muted/50"
|
||||||
@@ -420,13 +416,18 @@ async function CurrentWork() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button asChild variant="outline" size="sm" className="flex-1">
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="hover-lift flex-1"
|
||||||
|
>
|
||||||
<Link href={`/dashboard/invoices/${currentInvoice.id}`}>
|
<Link href={`/dashboard/invoices/${currentInvoice.id}`}>
|
||||||
<Eye className="mr-2 h-4 w-4" />
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
View
|
View
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild size="sm" className="flex-1">
|
<Button asChild size="sm" className="hover-lift flex-1">
|
||||||
<Link href={`/dashboard/invoices/${currentInvoice.id}/edit`}>
|
<Link href={`/dashboard/invoices/${currentInvoice.id}/edit`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Continue
|
Continue
|
||||||
@@ -509,13 +510,13 @@ async function RecentActivity() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{recentInvoices.map((invoice) => (
|
{recentInvoices.map((invoice, _index) => (
|
||||||
<Link
|
<Link
|
||||||
key={invoice.id}
|
key={invoice.id}
|
||||||
href={`/dashboard/invoices/${invoice.id}`}
|
href={`/dashboard/invoices/${invoice.id}`}
|
||||||
className="block"
|
className="block"
|
||||||
>
|
>
|
||||||
<div className="bg-muted/50 hover:bg-muted border-foreground/20 rounded-lg border p-3 transition-colors">
|
<div className="recent-activity-item bg-muted/50 hover:bg-muted border-foreground/20 rounded-lg border p-3 transition-colors">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="bg-muted flex-shrink-0 rounded-lg p-2">
|
<div className="bg-muted flex-shrink-0 rounded-lg p-2">
|
||||||
<FileText className="text-muted-foreground h-4 w-4" />
|
<FileText className="text-muted-foreground h-4 w-4" />
|
||||||
@@ -627,7 +628,7 @@ export default async function DashboardPage() {
|
|||||||
const firstName = session?.user?.name?.split(" ")[0] ?? "User";
|
const firstName = session?.user?.name?.split(" ")[0] ?? "User";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="page-enter space-y-8">
|
||||||
<DashboardHero firstName={firstName} />
|
<DashboardHero firstName={firstName} />
|
||||||
|
|
||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
|
|||||||
@@ -1,26 +1,38 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import * as React from "react";
|
|
||||||
import { useSession } from "next-auth/react";
|
|
||||||
import {
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Building,
|
||||||
|
ChevronDown,
|
||||||
|
Database,
|
||||||
Download,
|
Download,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
FileText,
|
||||||
|
FileUp,
|
||||||
|
Info,
|
||||||
|
Key,
|
||||||
|
Shield,
|
||||||
Upload,
|
Upload,
|
||||||
User,
|
User,
|
||||||
Database,
|
|
||||||
AlertTriangle,
|
|
||||||
Shield,
|
|
||||||
FileText,
|
|
||||||
Users,
|
Users,
|
||||||
Building,
|
|
||||||
Key,
|
|
||||||
Eye,
|
|
||||||
FileUp,
|
|
||||||
ChevronDown,
|
|
||||||
Info,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
import { api } from "~/trpc/react";
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "~/components/ui/alert-dialog";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -34,22 +46,6 @@ import {
|
|||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "~/components/ui/collapsible";
|
} from "~/components/ui/collapsible";
|
||||||
import { Input } from "~/components/ui/input";
|
|
||||||
import { Label } from "~/components/ui/label";
|
|
||||||
import { Textarea } from "~/components/ui/textarea";
|
|
||||||
import { Badge } from "~/components/ui/badge";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "~/components/ui/alert-dialog";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -59,6 +55,10 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "~/components/ui/dialog";
|
} from "~/components/ui/dialog";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
|
||||||
export function SettingsContent() {
|
export function SettingsContent() {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
@@ -300,7 +300,7 @@ export function SettingsContent() {
|
|||||||
{/* Profile & Account Overview */}
|
{/* Profile & Account Overview */}
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
{/* Profile Section */}
|
{/* Profile Section */}
|
||||||
<Card className="bg-card border-border border">
|
<Card className="form-section bg-card border-border border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-foreground flex items-center gap-2">
|
<CardTitle className="text-foreground flex items-center gap-2">
|
||||||
<User className="text-primary h-5 w-5" />
|
<User className="text-primary h-5 w-5" />
|
||||||
@@ -337,6 +337,7 @@ export function SettingsContent() {
|
|||||||
type="submit"
|
type="submit"
|
||||||
disabled={updateProfileMutation.isPending}
|
disabled={updateProfileMutation.isPending}
|
||||||
variant="default"
|
variant="default"
|
||||||
|
className="hover-lift"
|
||||||
>
|
>
|
||||||
{updateProfileMutation.isPending
|
{updateProfileMutation.isPending
|
||||||
? "Updating..."
|
? "Updating..."
|
||||||
@@ -347,7 +348,7 @@ export function SettingsContent() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Data Overview */}
|
{/* Data Overview */}
|
||||||
<Card className="bg-card border-border border">
|
<Card className="form-section bg-card border-border border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-foreground flex items-center gap-2">
|
<CardTitle className="text-foreground flex items-center gap-2">
|
||||||
<Database className="text-primary h-5 w-5" />
|
<Database className="text-primary h-5 w-5" />
|
||||||
@@ -359,12 +360,13 @@ export function SettingsContent() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{dataStatItems.map((item) => {
|
{dataStatItems.map((item, index) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.label}
|
key={item.label}
|
||||||
className="bg-card border p-4 transition-shadow hover:shadow-sm"
|
className="hover-lift bg-card border p-4 transition-shadow hover:shadow-sm"
|
||||||
|
style={{ animationDelay: `${index * 100}ms` }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { SettingsContent } from "./_components/settings-content";
|
|||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="page-enter space-y-8">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Settings"
|
title="Settings"
|
||||||
description="Manage your account preferences and data"
|
description="Manage your account preferences and data"
|
||||||
@@ -18,6 +18,6 @@ export default async function SettingsPage() {
|
|||||||
<SettingsContent />
|
<SettingsContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -361,7 +361,7 @@ export function DataTable<TData, TValue>({
|
|||||||
key={row.id}
|
key={row.id}
|
||||||
data-state={row.getIsSelected() && "selected"}
|
data-state={row.getIsSelected() && "selected"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"hover:bg-muted/20 data-[state=selected]:bg-muted/50 border-border/40 border-b transition-colors",
|
"hover:bg-muted/20 data-[state=selected]:bg-muted/50 border-border/40 table-row border-b transition-colors",
|
||||||
onRowClick && "cursor-pointer",
|
onRowClick && "cursor-pointer",
|
||||||
)}
|
)}
|
||||||
onClick={(event) =>
|
onClick={(event) =>
|
||||||
|
|||||||
@@ -51,7 +51,15 @@ export function StatusBadge({
|
|||||||
const label = children ?? statusLabelMap[status];
|
const label = children ?? statusLabelMap[status];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge className={cn(statusClass, className)} {...props}>
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
statusClass,
|
||||||
|
"transition-all duration-200 hover:scale-105",
|
||||||
|
status === "sent" && "animate-pulse",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -493,7 +493,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-6 pb-32">
|
<div className="page-enter space-y-6 pb-32">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={
|
title={
|
||||||
invoiceId && invoiceId !== "new" ? "Edit Invoice" : "Create Invoice"
|
invoiceId && invoiceId !== "new" ? "Edit Invoice" : "Create Invoice"
|
||||||
@@ -510,13 +510,18 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={loading || deleteInvoice.isPending}
|
disabled={loading || deleteInvoice.isPending}
|
||||||
className="text-destructive hover:bg-destructive/10 shadow-sm"
|
className="hover-lift text-destructive hover:bg-destructive/10 shadow-sm"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 sm:mr-2" />
|
<Trash2 className="h-4 w-4 sm:mr-2" />
|
||||||
<span className="hidden sm:inline">Delete Invoice</span>
|
<span className="hidden sm:inline">Delete Invoice</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button onClick={handleSubmit} disabled={loading} variant="default">
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={loading}
|
||||||
|
variant="default"
|
||||||
|
className="hover-lift"
|
||||||
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
<Clock className="h-4 w-4 animate-spin sm:mr-2" />
|
<Clock className="h-4 w-4 animate-spin sm:mr-2" />
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export function FloatingActionBar({
|
|||||||
isDocked ? "flex justify-center" : "",
|
isDocked ? "flex justify-center" : "",
|
||||||
// Dynamic bottom positioning
|
// Dynamic bottom positioning
|
||||||
isDocked ? "bottom-4" : "bottom-0",
|
isDocked ? "bottom-4" : "bottom-0",
|
||||||
|
// Add entrance animation
|
||||||
|
"animate-slide-in-bottom",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -65,17 +67,19 @@ export function FloatingActionBar({
|
|||||||
isDocked ? "mx-auto mb-0 px-4" : "mb-4 px-4",
|
isDocked ? "mx-auto mb-0 px-4" : "mb-4 px-4",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Card className="bg-card border-border border">
|
<Card className="hover-lift bg-card border-border border shadow-lg">
|
||||||
<CardContent className="flex items-center justify-between p-4">
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
{/* Left content */}
|
{/* Left content */}
|
||||||
{leftContent && (
|
{leftContent && (
|
||||||
<div className="flex flex-1 items-center gap-3">
|
<div className="animate-fade-in flex flex-1 items-center gap-3">
|
||||||
{leftContent}
|
{leftContent}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Right actions */}
|
{/* Right actions */}
|
||||||
<div className="flex items-center gap-2 sm:gap-3">{children}</div>
|
<div className="animate-fade-in animate-delay-100 flex items-center gap-2 sm:gap-3">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,22 +39,24 @@ export function PageHeader({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`mb-8 ${className}`}>
|
<div className={`animate-fade-in-down mb-8 ${className}`}>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<h1 className={titleClassName ?? getTitleClasses()}>{title}</h1>
|
<div className="animate-fade-in-up space-y-1">
|
||||||
|
<h1 className={titleClassName ?? getTitleClasses()}>{title}</h1>
|
||||||
|
{description && (
|
||||||
|
<p
|
||||||
|
className={`animate-fade-in-up animate-delay-100 text-muted-foreground ${getDescriptionSpacing()} text-lg`}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{children && (
|
{children && (
|
||||||
<div className="flex flex-shrink-0 gap-2 sm:gap-3 [&>*]:h-8 [&>*]:px-2 [&>*]:text-sm sm:[&>*]:h-10 sm:[&>*]:px-4 sm:[&>*]:text-base [&>*>span]:hidden sm:[&>*>span]:inline [&>*>svg]:mr-0 sm:[&>*>svg]:mr-2">
|
<div className="animate-slide-in-right animate-delay-200 flex flex-shrink-0 gap-2 sm:gap-3 [&>*]:h-8 [&>*]:px-2 [&>*]:text-sm sm:[&>*]:h-10 sm:[&>*]:px-4 sm:[&>*]:text-base [&>*>span]:hidden sm:[&>*>span]:inline [&>*>svg]:mr-0 sm:[&>*>svg]:mr-2">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{description && (
|
|
||||||
<p
|
|
||||||
className={`text-muted-foreground ${getDescriptionSpacing()} text-lg`}
|
|
||||||
>
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
352
src/components/ui/skeletons.tsx
Normal file
352
src/components/ui/skeletons.tsx
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
interface SkeletonProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Skeleton({ className }: SkeletonProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn("skeleton bg-muted animate-pulse rounded", className)} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page Header Skeleton
|
||||||
|
export function PageHeaderSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-5 w-96" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Skeleton className="h-10 w-24" />
|
||||||
|
<Skeleton className="h-10 w-32" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoice Items Skeleton
|
||||||
|
export function InvoiceItemsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="rounded-lg border p-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-5 w-3/4" />
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-6 w-20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent Activity Skeleton
|
||||||
|
export function RecentActivitySkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="bg-muted/50 border-foreground/20 rounded-lg border p-3"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Skeleton className="h-8 w-8 rounded" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-3 w-32" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-5 w-12 rounded-full" />
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current Work Skeleton
|
||||||
|
export function CurrentWorkSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-5 w-5" />
|
||||||
|
<Skeleton className="h-6 w-24" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-6 w-20 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<Skeleton className="h-6 w-32" />
|
||||||
|
<Skeleton className="h-8 w-24" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<Skeleton className="h-4 w-28" />
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Skeleton className="h-8 flex-1" />
|
||||||
|
<Skeleton className="h-8 flex-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client Info Skeleton
|
||||||
|
export function ClientInfoSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Skeleton className="h-8 w-8" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Skeleton className="h-4 w-12" />
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Skeleton className="h-8 w-8" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Skeleton className="h-4 w-12" />
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Skeleton className="mb-4 h-6 w-32" />
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<Skeleton className="h-8 w-8" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
|
<Skeleton className="h-4 w-36" />
|
||||||
|
<Skeleton className="h-4 w-28" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats Summary Skeleton
|
||||||
|
export function StatsSummarySkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2 text-center">
|
||||||
|
<Skeleton className="mx-auto h-8 w-24" />
|
||||||
|
<Skeleton className="mx-auto h-4 w-20" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-center">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Skeleton className="mx-auto h-6 w-8" />
|
||||||
|
<Skeleton className="mx-auto h-3 w-12" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Skeleton className="mx-auto h-6 w-8" />
|
||||||
|
<Skeleton className="mx-auto h-3 w-16" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent Invoices Skeleton
|
||||||
|
export function RecentInvoicesSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="card-secondary hover:bg-muted/50 border p-3">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-3 w-20" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
<Skeleton className="h-5 w-12 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings Form Skeleton
|
||||||
|
export function SettingsFormSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Skeleton className="h-10 w-24" />
|
||||||
|
<Skeleton className="h-10 w-20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data Stats Skeleton
|
||||||
|
export function DataStatsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="bg-card border p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Skeleton className="h-8 w-8" />
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-6 w-8" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoice Summary Skeleton
|
||||||
|
export function InvoiceSummarySkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="bg-muted/30 rounded-lg p-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
</div>
|
||||||
|
<div className="bg-border h-px" />
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Skeleton className="h-5 w-12" />
|
||||||
|
<Skeleton className="h-5 w-24" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions Sidebar Skeleton
|
||||||
|
export function ActionsSidebarSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form Section Skeleton
|
||||||
|
export function FormSectionSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line Items Table Skeleton
|
||||||
|
export function LineItemsTableSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="grid grid-cols-5 gap-4 rounded border p-3">
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Business Card Skeleton
|
||||||
|
export function BusinessCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 rounded-lg border p-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-5 w-16 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
|
<Skeleton className="h-4 w-28" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic Card Grid Skeleton
|
||||||
|
export function CardGridSkeleton({ count = 6 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<div key={i} className="rounded-lg border p-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Skeleton className="h-8 w-8" />
|
||||||
|
<Skeleton className="h-5 w-24" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
<Skeleton className="h-8 w-20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
src/hooks/useCountUp.ts
Normal file
133
src/hooks/useCountUp.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface UseCountUpOptions {
|
||||||
|
/** The target number to count up to */
|
||||||
|
end: number;
|
||||||
|
/** The starting number (default: 0) */
|
||||||
|
start?: number;
|
||||||
|
/** Duration of the animation in milliseconds (default: 1000) */
|
||||||
|
duration?: number;
|
||||||
|
/** Delay before starting the animation in milliseconds (default: 0) */
|
||||||
|
delay?: number;
|
||||||
|
/** Custom easing function */
|
||||||
|
easing?: (t: number) => number;
|
||||||
|
/** Whether to format as currency */
|
||||||
|
currency?: boolean;
|
||||||
|
/** Number of decimal places (default: 0) */
|
||||||
|
decimals?: number;
|
||||||
|
/** Whether to use number separators (default: true) */
|
||||||
|
useGrouping?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for animating numbers with a counting up effect
|
||||||
|
*/
|
||||||
|
export function useCountUp({
|
||||||
|
end,
|
||||||
|
start = 0,
|
||||||
|
duration = 1000,
|
||||||
|
delay = 0,
|
||||||
|
easing = (t: number) => t * t * (3 - 2 * t), // smooth step
|
||||||
|
currency = false,
|
||||||
|
decimals = 0,
|
||||||
|
useGrouping = true,
|
||||||
|
}: UseCountUpOptions) {
|
||||||
|
const [count, setCount] = useState(start);
|
||||||
|
const [isAnimating, setIsAnimating] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Reset when end value changes
|
||||||
|
setCount(start);
|
||||||
|
setIsAnimating(false);
|
||||||
|
|
||||||
|
const startAnimation = () => {
|
||||||
|
setIsAnimating(true);
|
||||||
|
const startTime = Date.now();
|
||||||
|
const range = end - start;
|
||||||
|
|
||||||
|
const updateCount = () => {
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
const easedProgress = easing(progress);
|
||||||
|
const currentCount = start + range * easedProgress;
|
||||||
|
setCount(currentCount);
|
||||||
|
requestAnimationFrame(updateCount);
|
||||||
|
} else {
|
||||||
|
setCount(end);
|
||||||
|
setIsAnimating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
requestAnimationFrame(updateCount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(startAnimation, delay);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [end, start, duration, delay, easing]);
|
||||||
|
|
||||||
|
// Format the number for display
|
||||||
|
const formatNumber = (num: number): string => {
|
||||||
|
const options: Intl.NumberFormatOptions = {
|
||||||
|
minimumFractionDigits: decimals,
|
||||||
|
maximumFractionDigits: decimals,
|
||||||
|
useGrouping,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (currency) {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
...options,
|
||||||
|
}).format(num);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.NumberFormat("en-US", options).format(num);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** The current animated value */
|
||||||
|
count,
|
||||||
|
/** Formatted display value */
|
||||||
|
displayValue: formatNumber(count),
|
||||||
|
/** Whether the animation is currently running */
|
||||||
|
isAnimating,
|
||||||
|
/** Reset the animation */
|
||||||
|
reset: () => {
|
||||||
|
setCount(start);
|
||||||
|
setIsAnimating(false);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset for currency counting animation
|
||||||
|
*/
|
||||||
|
export function useCurrencyCountUp(
|
||||||
|
end: number,
|
||||||
|
options?: Omit<UseCountUpOptions, "currency" | "decimals" | "end">,
|
||||||
|
) {
|
||||||
|
return useCountUp({
|
||||||
|
...options,
|
||||||
|
end,
|
||||||
|
currency: true,
|
||||||
|
decimals: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset for integer counting animation
|
||||||
|
*/
|
||||||
|
export function useIntegerCountUp(
|
||||||
|
end: number,
|
||||||
|
options?: Omit<UseCountUpOptions, "decimals" | "end">,
|
||||||
|
) {
|
||||||
|
return useCountUp({
|
||||||
|
...options,
|
||||||
|
end,
|
||||||
|
decimals: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -440,3 +440,640 @@ li[data-sonner-toast] button:hover,
|
|||||||
);
|
);
|
||||||
background-size: 40px 40px;
|
background-size: 40px 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
BEENVOICE ANIMATION SYSTEM
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
/* CSS Custom Properties for Animation Timing */
|
||||||
|
:root {
|
||||||
|
--animation-speed-fast: 0.15s;
|
||||||
|
--animation-speed-normal: 0.3s;
|
||||||
|
--animation-speed-slow: 0.5s;
|
||||||
|
--animation-easing: ease-out;
|
||||||
|
--animation-easing-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accessibility: Respect prefers-reduced-motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
BASE KEYFRAMES
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInLeft {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInBottom {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes expandDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
max-height: 0;
|
||||||
|
transform: scaleY(0);
|
||||||
|
transform-origin: top;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
max-height: 200px;
|
||||||
|
transform: scaleY(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shrinkUp {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
max-height: 200px;
|
||||||
|
transform: scaleY(1);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
max-height: 0;
|
||||||
|
transform: scaleY(0);
|
||||||
|
transform-origin: top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounce {
|
||||||
|
0%,
|
||||||
|
20%,
|
||||||
|
53%,
|
||||||
|
80%,
|
||||||
|
100% {
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
40%,
|
||||||
|
43% {
|
||||||
|
transform: translate3d(0, -15px, 0);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
transform: translate3d(0, -7px, 0);
|
||||||
|
}
|
||||||
|
90% {
|
||||||
|
transform: translate3d(0, -2px, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.05);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes countUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.8);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
ANIMATION UTILITY CLASSES
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
/* Base Animations */
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fadeIn var(--animation-speed-slow) var(--animation-easing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in-up {
|
||||||
|
animation: fadeInUp var(--animation-speed-slow) var(--animation-easing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in-down {
|
||||||
|
animation: fadeInDown var(--animation-speed-slow) var(--animation-easing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-in-left {
|
||||||
|
animation: slideInLeft var(--animation-speed-slow) var(--animation-easing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-in-right {
|
||||||
|
animation: slideInRight var(--animation-speed-slow) var(--animation-easing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-in-bottom {
|
||||||
|
animation: slideInBottom var(--animation-speed-slow) var(--animation-easing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-scale-in {
|
||||||
|
animation: scaleIn var(--animation-speed-normal) var(--animation-easing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-bounce {
|
||||||
|
animation: bounce 1s var(--animation-easing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse {
|
||||||
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-count-up {
|
||||||
|
animation: countUp 0.8s var(--animation-easing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stagger Animation Delays */
|
||||||
|
.animate-delay-75 {
|
||||||
|
animation-delay: 75ms;
|
||||||
|
}
|
||||||
|
.animate-delay-100 {
|
||||||
|
animation-delay: 100ms;
|
||||||
|
}
|
||||||
|
.animate-delay-150 {
|
||||||
|
animation-delay: 150ms;
|
||||||
|
}
|
||||||
|
.animate-delay-200 {
|
||||||
|
animation-delay: 200ms;
|
||||||
|
}
|
||||||
|
.animate-delay-300 {
|
||||||
|
animation-delay: 300ms;
|
||||||
|
}
|
||||||
|
.animate-delay-500 {
|
||||||
|
animation-delay: 500ms;
|
||||||
|
}
|
||||||
|
.animate-delay-700 {
|
||||||
|
animation-delay: 700ms;
|
||||||
|
}
|
||||||
|
.animate-delay-1000 {
|
||||||
|
animation-delay: 1000ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
HOVER STATE ANIMATIONS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.hover-lift {
|
||||||
|
transition: transform var(--animation-speed-fast) var(--animation-easing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-lift:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-scale {
|
||||||
|
transition: transform var(--animation-speed-fast) var(--animation-easing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-scale:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-glow {
|
||||||
|
transition: box-shadow var(--animation-speed-normal) var(--animation-easing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-glow:hover {
|
||||||
|
box-shadow: 0 0 20px hsl(var(--primary) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-slide-right {
|
||||||
|
transition: transform var(--animation-speed-fast) var(--animation-easing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-slide-right:hover {
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
LOADING SKELETON ANIMATIONS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
hsl(var(--muted)) 0%,
|
||||||
|
hsl(var(--muted) / 0.5) 50%,
|
||||||
|
hsl(var(--muted)) 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-text {
|
||||||
|
height: 1rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-text-lg {
|
||||||
|
height: 1.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-text-xl {
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-button {
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-avatar {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-card {
|
||||||
|
height: 8rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
PAGE ENTRANCE ANIMATIONS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.page-enter {
|
||||||
|
animation: fadeInUp 0.6s var(--animation-easing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-enter-stagger > * {
|
||||||
|
animation: fadeInUp var(--animation-speed-slow) var(--animation-easing)
|
||||||
|
forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-enter-stagger > *:nth-child(1) {
|
||||||
|
animation-delay: 0ms;
|
||||||
|
}
|
||||||
|
.page-enter-stagger > *:nth-child(2) {
|
||||||
|
animation-delay: 100ms;
|
||||||
|
}
|
||||||
|
.page-enter-stagger > *:nth-child(3) {
|
||||||
|
animation-delay: 200ms;
|
||||||
|
}
|
||||||
|
.page-enter-stagger > *:nth-child(4) {
|
||||||
|
animation-delay: 300ms;
|
||||||
|
}
|
||||||
|
.page-enter-stagger > *:nth-child(5) {
|
||||||
|
animation-delay: 400ms;
|
||||||
|
}
|
||||||
|
.page-enter-stagger > *:nth-child(6) {
|
||||||
|
animation-delay: 500ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
COMPONENT-SPECIFIC ANIMATIONS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
/* Stats Cards */
|
||||||
|
.stats-card {
|
||||||
|
animation: fadeInUp var(--animation-speed-slow) var(--animation-easing)
|
||||||
|
forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card:nth-child(1) {
|
||||||
|
animation-delay: 0ms;
|
||||||
|
}
|
||||||
|
.stats-card:nth-child(2) {
|
||||||
|
animation-delay: 100ms;
|
||||||
|
}
|
||||||
|
.stats-card:nth-child(3) {
|
||||||
|
animation-delay: 200ms;
|
||||||
|
}
|
||||||
|
.stats-card:nth-child(4) {
|
||||||
|
animation-delay: 300ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Invoice Items */
|
||||||
|
.invoice-item {
|
||||||
|
animation: fadeInUp var(--animation-speed-normal) var(--animation-easing)
|
||||||
|
forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-item:nth-child(1) {
|
||||||
|
animation-delay: 0ms;
|
||||||
|
}
|
||||||
|
.invoice-item:nth-child(2) {
|
||||||
|
animation-delay: 100ms;
|
||||||
|
}
|
||||||
|
.invoice-item:nth-child(3) {
|
||||||
|
animation-delay: 200ms;
|
||||||
|
}
|
||||||
|
.invoice-item:nth-child(4) {
|
||||||
|
animation-delay: 300ms;
|
||||||
|
}
|
||||||
|
.invoice-item:nth-child(5) {
|
||||||
|
animation-delay: 400ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recent Activity Items */
|
||||||
|
.recent-activity-item {
|
||||||
|
animation: slideInLeft var(--animation-speed-normal) var(--animation-easing)
|
||||||
|
forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-activity-item:nth-child(1) {
|
||||||
|
animation-delay: 0ms;
|
||||||
|
}
|
||||||
|
.recent-activity-item:nth-child(2) {
|
||||||
|
animation-delay: 75ms;
|
||||||
|
}
|
||||||
|
.recent-activity-item:nth-child(3) {
|
||||||
|
animation-delay: 150ms;
|
||||||
|
}
|
||||||
|
.recent-activity-item:nth-child(4) {
|
||||||
|
animation-delay: 225ms;
|
||||||
|
}
|
||||||
|
.recent-activity-item:nth-child(5) {
|
||||||
|
animation-delay: 300ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Animations */
|
||||||
|
.form-section {
|
||||||
|
animation: fadeInUp var(--animation-speed-slow) var(--animation-easing)
|
||||||
|
forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.2);
|
||||||
|
transition: box-shadow var(--animation-speed-fast) var(--animation-easing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Line Item Animations */
|
||||||
|
.line-item-enter {
|
||||||
|
animation: expandDown var(--animation-speed-normal) var(--animation-easing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-item-exit {
|
||||||
|
animation: shrinkUp var(--animation-speed-fast) ease-in forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Badge Pulse for Pending States */
|
||||||
|
.status-pending {
|
||||||
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Loading States */
|
||||||
|
.button-loading {
|
||||||
|
position: relative;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-loading::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: transparent;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: translate(-50%, -50%) rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
SUCCESS/ERROR STATE ANIMATIONS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.success-state {
|
||||||
|
animation: successPulse 0.6s var(--animation-easing);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes successPulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
background-color: hsl(var(--success) / 0.1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.02);
|
||||||
|
background-color: hsl(var(--success) / 0.2);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
background-color: hsl(var(--success) / 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
animation: errorShake 0.5s var(--animation-easing);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes errorShake {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
10%,
|
||||||
|
30%,
|
||||||
|
50%,
|
||||||
|
70%,
|
||||||
|
90% {
|
||||||
|
transform: translateX(-2px);
|
||||||
|
}
|
||||||
|
20%,
|
||||||
|
40%,
|
||||||
|
60%,
|
||||||
|
80% {
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
TABLE AND LIST ANIMATIONS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.table-row {
|
||||||
|
transition: all var(--animation-speed-fast) var(--animation-easing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-row:hover {
|
||||||
|
background-color: hsl(var(--muted) / 0.5);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
MODAL AND DIALOG ANIMATIONS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.modal-enter {
|
||||||
|
animation: modalSlideIn var(--animation-speed-normal) var(--animation-easing);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalSlideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px) scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
animation: backdropFadeIn var(--animation-speed-normal)
|
||||||
|
var(--animation-easing);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes backdropFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
UTILITY CLASSES FOR COMMON PATTERNS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
/* Hidden initially for entrance animations */
|
||||||
|
.animate-on-load {
|
||||||
|
opacity: 0;
|
||||||
|
animation: fadeInUp var(--animation-speed-slow) var(--animation-easing)
|
||||||
|
forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stagger children for list entrances */
|
||||||
|
.stagger-children > * {
|
||||||
|
animation: fadeInUp var(--animation-speed-normal) var(--animation-easing)
|
||||||
|
forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stagger-children > *:nth-child(1) {
|
||||||
|
animation-delay: 0ms;
|
||||||
|
}
|
||||||
|
.stagger-children > *:nth-child(2) {
|
||||||
|
animation-delay: 100ms;
|
||||||
|
}
|
||||||
|
.stagger-children > *:nth-child(3) {
|
||||||
|
animation-delay: 200ms;
|
||||||
|
}
|
||||||
|
.stagger-children > *:nth-child(4) {
|
||||||
|
animation-delay: 300ms;
|
||||||
|
}
|
||||||
|
.stagger-children > *:nth-child(5) {
|
||||||
|
animation-delay: 400ms;
|
||||||
|
}
|
||||||
|
.stagger-children > *:nth-child(6) {
|
||||||
|
animation-delay: 500ms;
|
||||||
|
}
|
||||||
|
.stagger-children > *:nth-child(7) {
|
||||||
|
animation-delay: 600ms;
|
||||||
|
}
|
||||||
|
.stagger-children > *:nth-child(8) {
|
||||||
|
animation-delay: 700ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Performance optimizations */
|
||||||
|
.will-animate {
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.will-animate.animation-done {
|
||||||
|
will-change: auto;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user