-
{title}
+
+
{title}
+ {description && (
+
+ {description}
+
+ )}
+
{children && (
-
- {description && (
-
- {description}
-
- )}
);
}
diff --git a/src/components/ui/skeletons.tsx b/src/components/ui/skeletons.tsx
new file mode 100644
index 0000000..730bc25
--- /dev/null
+++ b/src/components/ui/skeletons.tsx
@@ -0,0 +1,352 @@
+import { cn } from "~/lib/utils";
+
+interface SkeletonProps {
+ className?: string;
+}
+
+export function Skeleton({ className }: SkeletonProps) {
+ return (
+
+ );
+}
+
+// Page Header Skeleton
+export function PageHeaderSkeleton() {
+ return (
+
+ );
+}
+
+// Invoice Items Skeleton
+export function InvoiceItemsSkeleton() {
+ return (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+// Recent Activity Skeleton
+export function RecentActivitySkeleton() {
+ return (
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+// Current Work Skeleton
+export function CurrentWorkSkeleton() {
+ return (
+
+ );
+}
+
+// Client Info Skeleton
+export function ClientInfoSkeleton() {
+ return (
+
+ );
+}
+
+// Stats Summary Skeleton
+export function StatsSummarySkeleton() {
+ return (
+
+ );
+}
+
+// Recent Invoices Skeleton
+export function RecentInvoicesSkeleton() {
+ return (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+// Settings Form Skeleton
+export function SettingsFormSkeleton() {
+ return (
+
+ );
+}
+
+// Data Stats Skeleton
+export function DataStatsSkeleton() {
+ return (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+// Invoice Summary Skeleton
+export function InvoiceSummarySkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+// Actions Sidebar Skeleton
+export function ActionsSidebarSkeleton() {
+ return (
+
+
+
+
+
+
+ );
+}
+
+// Form Section Skeleton
+export function FormSectionSkeleton() {
+ return (
+
+ );
+}
+
+// Line Items Table Skeleton
+export function LineItemsTableSkeleton() {
+ return (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+
+
+
+
+
+
+ ))}
+
+ );
+}
+
+// Business Card Skeleton
+export function BusinessCardSkeleton() {
+ return (
+
+ );
+}
+
+// Generic Card Grid Skeleton
+export function CardGridSkeleton({ count = 6 }: { count?: number }) {
+ return (
+
+ {Array.from({ length: count }).map((_, i) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/src/hooks/useCountUp.ts b/src/hooks/useCountUp.ts
new file mode 100644
index 0000000..8853b17
--- /dev/null
+++ b/src/hooks/useCountUp.ts
@@ -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
,
+) {
+ return useCountUp({
+ ...options,
+ end,
+ currency: true,
+ decimals: 2,
+ });
+}
+
+/**
+ * Preset for integer counting animation
+ */
+export function useIntegerCountUp(
+ end: number,
+ options?: Omit,
+) {
+ return useCountUp({
+ ...options,
+ end,
+ decimals: 0,
+ });
+}
diff --git a/src/styles/globals.css b/src/styles/globals.css
index faa08a7..8786fbf 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -440,3 +440,640 @@ li[data-sonner-toast] button:hover,
);
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;
+}