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
+32
View File
@@ -0,0 +1,32 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ChevronRight } from "lucide-react";
export function Breadcrumbs() {
const pathname = usePathname();
const segments = pathname.split("/").filter(Boolean);
const crumbs = [
{ name: "Dashboard", href: "/dashboard" },
...segments.slice(1).map((seg, i) => ({
name: seg.charAt(0).toUpperCase() + seg.slice(1),
href: "/dashboard/" + segments.slice(1, i + 2).join("/"),
})),
];
return (
<nav className="flex items-center text-sm text-muted-foreground" aria-label="Breadcrumb">
{crumbs.map((crumb, i) => (
<span key={crumb.href} className="flex items-center">
{i > 0 && <ChevronRight className="mx-2 h-4 w-4 text-gray-300" />}
{i < crumbs.length - 1 ? (
<Link href={crumb.href} className="hover:underline text-gray-500">
{crumb.name}
</Link>
) : (
<span className="font-medium text-gray-700">{crumb.name}</span>
)}
</span>
))}
</nav>
);
}
@@ -0,0 +1,188 @@
"use client";
import {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
} from "~/components/ui/breadcrumb";
import { usePathname } from "next/navigation";
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import React from "react";
import { api } from "~/trpc/react";
import { format } from "date-fns";
import { Skeleton } from "~/components/ui/skeleton";
import { getRouteLabel, capitalize } from "~/lib/pluralize";
function isUUID(str: string) {
return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(
str,
);
}
// Special segment labels
const SPECIAL_SEGMENTS: Record<string, string> = {
new: "New",
edit: "Edit",
import: "Import",
export: "Export",
dashboard: "Dashboard",
};
export function DashboardBreadcrumbs() {
const pathname = usePathname();
const segments = pathname.split("/").filter(Boolean);
// Determine resource type and ID from path
const resourceType = segments[1]; // e.g., 'clients', 'invoices', 'businesses'
const resourceId =
segments[2] && isUUID(segments[2]) ? segments[2] : undefined;
const action = segments[3]; // e.g., 'edit'
// Fetch client data if needed
const { data: client, isLoading: clientLoading } =
api.clients.getById.useQuery(
{ id: resourceId ?? "" },
{ enabled: resourceType === "clients" && !!resourceId },
);
// Fetch invoice data if needed
const { data: invoice, isLoading: invoiceLoading } =
api.invoices.getById.useQuery(
{ id: resourceId ?? "" },
{ enabled: resourceType === "invoices" && !!resourceId },
);
// Fetch business data if needed
const { data: business, isLoading: businessLoading } =
api.businesses.getById.useQuery(
{ id: resourceId ?? "" },
{ enabled: resourceType === "businesses" && !!resourceId },
);
// Generate breadcrumb items based on pathname
const breadcrumbs = React.useMemo(() => {
const items = [];
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
const path = `/${segments.slice(0, i + 1).join("/")}`;
// Skip dashboard segment as it's always shown as root
if (segment === "dashboard") continue;
let label: string | React.ReactElement = "";
let shouldShow = true;
// Handle UUID segments
if (segment && isUUID(segment)) {
// Determine which resource we're looking at
const prevSegment = segments[i - 1];
if (prevSegment === "clients") {
if (clientLoading) {
label = <Skeleton className="inline-block h-5 w-24 align-middle" />;
} else if (client) {
label = client.name;
}
} else if (prevSegment === "invoices") {
if (invoiceLoading) {
label = <Skeleton className="inline-block h-5 w-24 align-middle" />;
} else if (invoice) {
// You can customize this - show invoice number or date
label =
invoice.invoiceNumber ||
format(new Date(invoice.issueDate), "MMM dd, yyyy");
}
} else if (prevSegment === "businesses") {
if (businessLoading) {
label = <Skeleton className="inline-block h-5 w-24 align-middle" />;
} else if (business) {
label = business.name;
}
}
}
// Handle action segments (edit, new, etc.)
else if (segment && SPECIAL_SEGMENTS[segment]) {
// Don't show 'edit' as the last breadcrumb when we have the resource name
if (segment === "edit" && i === segments.length - 1 && resourceId) {
shouldShow = false;
} else {
label = SPECIAL_SEGMENTS[segment];
}
}
// Handle resource segments (clients, invoices, etc.)
else if (segment) {
// Use plural form for list pages, singular when there's a specific ID
const nextSegment = segments[i + 1];
const isListPage =
!nextSegment || (!isUUID(nextSegment) && nextSegment !== "new");
label = getRouteLabel(segment, isListPage);
}
if (shouldShow && label) {
items.push({
label,
href: path,
isLast: i === segments.length - 1,
});
}
}
return items;
}, [
segments,
client,
invoice,
business,
clientLoading,
invoiceLoading,
businessLoading,
resourceId,
]);
if (breadcrumbs.length === 0) return null;
return (
<Breadcrumb className="mb-4 sm:mb-6">
<BreadcrumbList className="flex-wrap">
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link
href="/dashboard"
className="text-sm sm:text-base dark:text-gray-300"
>
Dashboard
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
{breadcrumbs.map((crumb, index) => (
<React.Fragment key={`${crumb.href}-${index}`}>
<BreadcrumbSeparator>
<ChevronRight className="h-3 w-3 sm:h-4 sm:w-4" />
</BreadcrumbSeparator>
<BreadcrumbItem>
{crumb.isLast ? (
<BreadcrumbPage className="text-sm sm:text-base dark:text-white">
{crumb.label}
</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link
href={crumb.href}
className="text-sm sm:text-base dark:text-gray-300"
>
{crumb.label}
</Link>
</BreadcrumbLink>
)}
</BreadcrumbItem>
</React.Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
);
}
+36
View File
@@ -0,0 +1,36 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Button } from "~/components/ui/button";
import { cn } from "~/lib/utils";
const navigation = [
{ name: "Dashboard", href: "/dashboard" },
{ name: "Clients", href: "/clients" },
{ name: "Invoices", href: "/invoices" },
];
export function Navigation() {
const pathname = usePathname();
return (
<nav className="flex space-x-2">
{navigation.map((item) => (
<Link key={item.name} href={item.href}>
<Button
variant={pathname === item.href ? "default" : "ghost"}
className={cn(
"transition-colors",
pathname === item.href
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
)}
>
{item.name}
</Button>
</Link>
))}
</nav>
);
}
@@ -0,0 +1,95 @@
"use client";
import { Button } from "~/components/ui/button";
import { Skeleton } from "~/components/ui/skeleton";
import { MenuIcon, X } from "lucide-react";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useSession } from "next-auth/react";
import { navigationConfig } from "~/lib/navigation";
interface SidebarTriggerProps {
isOpen: boolean;
onToggle: () => void;
}
export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
const pathname = usePathname();
const { status } = useSession();
return (
<>
<Button
variant="outline"
size="icon"
aria-label="Toggle navigation"
onClick={onToggle}
className="bg-card/80 h-8 w-8 shadow-lg backdrop-blur-sm md:hidden"
>
{isOpen ? <X className="h-4 w-4" /> : <MenuIcon className="h-4 w-4" />}
</Button>
{/* Mobile dropdown navigation */}
{isOpen && (
<div className="bg-background/95 border-border/40 absolute top-full right-0 left-0 z-40 mt-2 rounded-2xl border shadow-2xl backdrop-blur-xl md:hidden">
{/* Navigation content */}
<nav className="flex flex-col p-4">
{navigationConfig.map((section, sectionIndex) => (
<div
key={section.title}
className={sectionIndex > 0 ? "mt-4" : ""}
>
{sectionIndex > 0 && (
<div className="border-border/40 my-3 border-t" />
)}
<div className="text-muted-foreground mb-2 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"
}`}
onClick={onToggle}
>
<Icon className="h-4 w-4" />
{link.name}
</Link>
);
})
)}
</div>
</div>
))}
</nav>
</div>
)}
</>
);
}