mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-02-05 08:16:31 -05:00
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:
105
src/components/layout/floating-action-bar.tsx
Normal file
105
src/components/layout/floating-action-bar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
src/components/layout/navbar.tsx
Normal file
73
src/components/layout/navbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
src/components/layout/page-header.tsx
Normal file
79
src/components/layout/page-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
148
src/components/layout/page-layout.tsx
Normal file
148
src/components/layout/page-layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/components/layout/quick-action-card.tsx
Normal file
112
src/components/layout/quick-action-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
src/components/layout/sidebar.tsx
Normal file
63
src/components/layout/sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user