Archived
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user