feat: improve invoice view responsiveness and settings UX

- Replace custom invoice items table with responsive DataTable component
- Fix server/client component error by creating InvoiceItemsTable client
  component
- Merge danger zone with actions sidebar and use destructive button
  variant
- Standardize button text sizing across all action buttons
- Remove false claims from homepage (testimonials, ratings, fake user
  counts)
- Focus homepage messaging on freelancers with honest feature
  descriptions
- Fix dark mode support throughout app by replacing hard-coded colors
  with semantic classes
- Remove aggressive red styling from settings, add subtle red accents
  only
- Align import/export buttons and improve delete confirmation UX
- Update dark mode background to have subtle green tint instead of pure
  black
- Fix HTML nesting error in AlertDialog by using div instead of nested p
  tags

This update makes the invoice view properly responsive, removes
misleading marketing claims, and ensures consistent dark mode support
across the entire application.
This commit is contained in:
2025-07-15 02:35:55 -04:00
parent f331136090
commit c9a664869c
71 changed files with 2795 additions and 3043 deletions

View File

@@ -0,0 +1,105 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { cn } from "~/lib/utils";
interface FloatingActionBarProps {
/** Ref to the element that triggers visibility when scrolled out of view */
triggerRef: React.RefObject<HTMLElement | null>;
/** Title text displayed on the left */
title: string;
/** Action buttons to display on the right */
children: React.ReactNode;
/** Additional className for styling */
className?: string;
/** Whether to show the floating bar (for manual control) */
show?: boolean;
/** Callback when visibility changes */
onVisibilityChange?: (visible: boolean) => void;
}
export function FloatingActionBar({
triggerRef,
title,
children,
className,
show,
onVisibilityChange,
}: FloatingActionBarProps) {
const [isVisible, setIsVisible] = useState(false);
const floatingRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// If show prop is provided, use it instead of auto-detection
if (show !== undefined) {
setIsVisible(show);
onVisibilityChange?.(show);
return;
}
const handleScroll = () => {
if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
const isInView = rect.top < window.innerHeight && rect.bottom >= 0;
// Show floating bar when trigger element is out of view
const shouldShow = !isInView;
if (shouldShow !== isVisible) {
setIsVisible(shouldShow);
onVisibilityChange?.(shouldShow);
}
};
// Use ResizeObserver and IntersectionObserver for better detection
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry) {
const shouldShow = !entry.isIntersecting;
if (shouldShow !== isVisible) {
setIsVisible(shouldShow);
onVisibilityChange?.(shouldShow);
}
}
},
{
// Trigger when element is completely out of view
threshold: 0,
rootMargin: "0px 0px -100% 0px",
},
);
// Start observing when trigger element is available
if (triggerRef.current) {
observer.observe(triggerRef.current);
}
// Also add scroll listener as fallback
window.addEventListener("scroll", handleScroll, { passive: true });
// Check initial state
handleScroll();
return () => {
observer.disconnect();
window.removeEventListener("scroll", handleScroll);
};
}, [triggerRef, isVisible, show, onVisibilityChange]);
if (!isVisible) return null;
return (
<div
ref={floatingRef}
className={cn(
"border-border/40 bg-background/60 animate-in slide-in-from-bottom-4 fixed right-3 bottom-3 left-3 z-20 flex items-center justify-between rounded-2xl border p-4 shadow-lg backdrop-blur-xl backdrop-saturate-150 duration-300 md:right-3 md:left-[279px]",
className,
)}
>
<p className="text-muted-foreground text-sm">{title}</p>
<div className="flex items-center gap-3">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import Link from "next/link";
import { useSession, signOut } from "next-auth/react";
import { useState } from "react";
import { Button } from "~/components/ui/button";
import { Skeleton } from "~/components/ui/skeleton";
import { Logo } from "~/components/branding/logo";
import { SidebarTrigger } from "~/components/navigation/sidebar-trigger";
export function Navbar() {
const { data: session, status } = useSession();
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false);
return (
<header className="fixed top-2 right-2 left-2 z-30 md:top-3 md:right-3 md:left-3">
<div className="bg-background/60 border-border/40 relative rounded-2xl border shadow-lg backdrop-blur-xl backdrop-saturate-150">
<div className="flex h-14 items-center justify-between px-4 md:h-16 md:px-8">
<div className="flex items-center gap-4 md:gap-6">
<SidebarTrigger
isOpen={isMobileNavOpen}
onToggle={() => setIsMobileNavOpen(!isMobileNavOpen)}
/>
<Link href="/dashboard" className="flex items-center gap-2">
<Logo size="md" />
</Link>
</div>
<div className="flex items-center gap-2 md:gap-4">
{status === "loading" ? (
<>
<Skeleton className="bg-muted/20 hidden h-5 w-20 sm:inline" />
<Skeleton className="bg-muted/20 h-8 w-16" />
</>
) : session?.user ? (
<>
<span className="text-muted-foreground hidden text-xs font-medium sm:inline md:text-sm">
{session.user.name ?? session.user.email}
</span>
<Button
variant="outline"
size="sm"
onClick={() => signOut({ callbackUrl: "/" })}
className="border-border/40 hover:bg-accent/50 text-xs md:text-sm"
>
Sign Out
</Button>
</>
) : (
<>
<Link href="/auth/signin">
<Button
variant="ghost"
size="sm"
className="hover:bg-accent/50 text-xs md:text-sm"
>
Sign In
</Button>
</Link>
<Link href="/auth/register">
<Button
size="sm"
className="bg-gradient-to-r from-emerald-600 to-teal-600 text-xs font-medium text-white shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg md:text-sm"
>
Register
</Button>
</Link>
</>
)}
</div>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,79 @@
import React from "react";
interface PageHeaderProps {
title: string;
description?: string;
children?: React.ReactNode; // For action buttons or other header content
className?: string;
variant?: "default" | "gradient" | "large" | "large-gradient";
titleClassName?: string;
}
export function PageHeader({
title,
description,
children,
className = "",
variant = "default",
titleClassName,
}: PageHeaderProps) {
const getTitleClasses = () => {
const baseClasses = "font-bold";
switch (variant) {
case "gradient":
return `${baseClasses} text-3xl bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent`;
case "large":
return `${baseClasses} text-4xl text-foreground`;
case "large-gradient":
return `${baseClasses} text-4xl bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent`;
default:
return `${baseClasses} text-3xl text-foreground`;
}
};
const getDescriptionSpacing = () => {
return variant === "large" || variant === "large-gradient"
? "mt-2"
: "mt-1";
};
return (
<div className={`mb-8 ${className}`}>
<div className="flex items-start justify-between gap-4">
<h1 className={titleClassName ?? getTitleClasses()}>{title}</h1>
{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">
{children}
</div>
)}
</div>
{description && (
<p
className={`text-muted-foreground ${getDescriptionSpacing()} text-lg`}
>
{description}
</p>
)}
</div>
);
}
// Convenience wrapper for dashboard page with larger gradient title
export function DashboardPageHeader({
title,
description,
children,
className = "",
}: Omit<PageHeaderProps, "variant">) {
return (
<PageHeader
title={title}
description={description}
variant="large-gradient"
className={className}
>
{children}
</PageHeader>
);
}

View File

@@ -0,0 +1,148 @@
import * as React from "react";
import { cn } from "~/lib/utils";
interface PageLayoutProps {
children: React.ReactNode;
className?: string;
}
export function PageLayout({ children, className }: PageLayoutProps) {
return (
<div className={cn("min-h-screen", className)}>
{children}
</div>
);
}
interface PageContentProps {
children: React.ReactNode;
className?: string;
spacing?: "default" | "compact" | "large";
}
export function PageContent({
children,
className,
spacing = "default"
}: PageContentProps) {
const spacingClasses = {
default: "space-y-8",
compact: "space-y-4",
large: "space-y-12"
};
return (
<div className={cn(spacingClasses[spacing], className)}>
{children}
</div>
);
}
interface PageSectionProps {
children: React.ReactNode;
className?: string;
title?: string;
description?: string;
actions?: React.ReactNode;
}
export function PageSection({
children,
className,
title,
description,
actions
}: PageSectionProps) {
return (
<section className={cn("space-y-4", className)}>
{(title ?? description ?? actions) && (
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
{title && (
<h2 className="text-xl font-semibold text-foreground">{title}</h2>
)}
{description && (
<p className="text-sm text-muted-foreground mt-1">{description}</p>
)}
</div>
{actions && (
<div className="flex flex-shrink-0 gap-3">{actions}</div>
)}
</div>
)}
{children}
</section>
);
}
interface PageGridProps {
children: React.ReactNode;
className?: string;
columns?: 1 | 2 | 3 | 4;
gap?: "default" | "compact" | "large";
}
export function PageGrid({
children,
className,
columns = 3,
gap = "default"
}: PageGridProps) {
const columnClasses = {
1: "grid-cols-1",
2: "grid-cols-1 md:grid-cols-2",
3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
4: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4"
};
const gapClasses = {
default: "gap-4",
compact: "gap-2",
large: "gap-6"
};
return (
<div className={cn(
"grid",
columnClasses[columns],
gapClasses[gap],
className
)}>
{children}
</div>
);
}
// Empty state component for consistent empty states across pages
interface EmptyStateProps {
icon?: React.ReactNode;
title: string;
description?: string;
action?: React.ReactNode;
className?: string;
}
export function EmptyState({
icon,
title,
description,
action,
className
}: EmptyStateProps) {
return (
<div className={cn("py-12 text-center", className)}>
{icon && (
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted/50">
{icon}
</div>
)}
<h3 className="mb-2 text-lg font-semibold">{title}</h3>
{description && (
<p className="text-muted-foreground mb-4 max-w-sm mx-auto">
{description}
</p>
)}
{action && <div className="mt-4">{action}</div>}
</div>
);
}

View File

@@ -0,0 +1,112 @@
import * as React from "react";
import { Card, CardContent } from "~/components/ui/card";
import { cn } from "~/lib/utils";
import type { LucideIcon } from "lucide-react";
interface QuickActionCardProps {
title: string;
description?: string;
icon: LucideIcon;
variant?: "default" | "success" | "info" | "warning" | "purple";
className?: string;
onClick?: () => void;
children?: React.ReactNode;
}
const variantStyles = {
default: {
icon: "text-foreground",
background: "bg-muted/50",
hoverBackground: "group-hover:bg-muted/70",
},
success: {
icon: "text-status-success",
background: "bg-status-success-muted",
hoverBackground: "group-hover:bg-status-success-muted/70",
},
info: {
icon: "text-status-info",
background: "bg-status-info-muted",
hoverBackground: "group-hover:bg-status-info-muted/70",
},
warning: {
icon: "text-status-warning",
background: "bg-status-warning-muted",
hoverBackground: "group-hover:bg-status-warning-muted/70",
},
purple: {
icon: "text-purple-600",
background: "bg-purple-100 dark:bg-purple-900/30",
hoverBackground:
"group-hover:bg-purple-200 dark:group-hover:bg-purple-900/50",
},
};
export function QuickActionCard({
title,
description,
icon: Icon,
variant = "default",
className,
onClick,
children,
}: QuickActionCardProps) {
const styles = variantStyles[variant];
const content = (
<CardContent className="p-6 text-center">
<div
className={cn(
"mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full transition-colors",
styles.background,
styles.hoverBackground,
)}
>
<Icon className={cn("h-6 w-6", styles.icon)} />
</div>
<h3 className="font-semibold">{title}</h3>
{description && (
<p className="text-muted-foreground mt-1 text-sm">{description}</p>
)}
</CardContent>
);
if (children) {
return (
<Card
className={cn(
"group cursor-pointer border-0 shadow-md transition-all hover:scale-[1.02] hover:shadow-lg",
className,
)}
>
{children}
</Card>
);
}
return (
<Card
className={cn(
"group cursor-pointer border-0 shadow-md transition-all hover:scale-[1.02] hover:shadow-lg",
className,
)}
onClick={onClick}
>
{content}
</Card>
);
}
export function QuickActionCardSkeleton() {
return (
<Card className="border-0 shadow-md">
<CardContent className="p-6">
<div className="animate-pulse">
<div className="bg-muted mx-auto mb-3 h-12 w-12 rounded-full"></div>
<div className="bg-muted mx-auto mb-2 h-4 w-2/3 rounded"></div>
<div className="bg-muted mx-auto h-3 w-1/2 rounded"></div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,63 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useSession } from "next-auth/react";
import { Skeleton } from "~/components/ui/skeleton";
import { navigationConfig } from "~/lib/navigation";
export function Sidebar() {
const pathname = usePathname();
const { status } = useSession();
return (
<aside className="border-border/40 bg-background/60 fixed top-[5.75rem] bottom-3 left-3 z-20 hidden w-64 flex-col justify-between rounded-2xl border p-6 shadow-lg backdrop-blur-xl backdrop-saturate-150 md:flex">
<nav className="flex flex-col">
{navigationConfig.map((section, sectionIndex) => (
<div key={section.title} className={sectionIndex > 0 ? "mt-6" : ""}>
{sectionIndex > 0 && (
<div className="border-border/40 my-4 border-t" />
)}
<div className="text-muted-foreground mb-3 text-xs font-semibold tracking-wider uppercase">
{section.title}
</div>
<div className="flex flex-col gap-0.5">
{status === "loading" ? (
<>
{Array.from({ length: section.links.length }).map((_, i) => (
<div
key={i}
className="flex items-center gap-3 rounded-lg px-3 py-2.5"
>
<Skeleton className="bg-muted/20 h-4 w-4" />
<Skeleton className="bg-muted/20 h-4 w-20" />
</div>
))}
</>
) : (
section.links.map((link) => {
const Icon = link.icon;
return (
<Link
key={link.href}
href={link.href}
aria-current={pathname === link.href ? "page" : undefined}
className={`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200 ${
pathname === link.href
? "bg-gradient-to-r from-emerald-600/10 to-teal-600/10 text-emerald-700 shadow-sm dark:from-emerald-500/20 dark:to-teal-500/20 dark:text-emerald-400"
: "text-foreground hover:bg-accent/50 hover:text-accent-foreground"
}`}
>
<Icon className="h-4 w-4" />
{link.name}
</Link>
);
})
)}
</div>
</div>
))}
</nav>
</aside>
);
}