mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-02-05 00:06:36 -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:
@@ -24,7 +24,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import { FileUpload } from "~/components/ui/file-upload";
|
||||
import { FileUpload } from "~/components/forms/file-upload";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import { StatusBadge, type StatusType } from "~/components/ui/status-badge";
|
||||
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
FileText,
|
||||
@@ -6,7 +6,7 @@ import { api } from "~/trpc/react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { StatusBadge, type StatusType } from "~/components/ui/status-badge";
|
||||
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -1,15 +1,27 @@
|
||||
import * as React from "react";
|
||||
import { Badge, type badgeVariants } from "./badge";
|
||||
import { Badge, type badgeVariants } from "~/components/ui/badge";
|
||||
import { type VariantProps } from "class-variance-authority";
|
||||
|
||||
type StatusType = "draft" | "sent" | "paid" | "overdue" | "success" | "warning" | "error" | "info";
|
||||
type StatusType =
|
||||
| "draft"
|
||||
| "sent"
|
||||
| "paid"
|
||||
| "overdue"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "error"
|
||||
| "info";
|
||||
|
||||
interface StatusBadgeProps extends Omit<React.ComponentProps<typeof Badge>, "variant"> {
|
||||
interface StatusBadgeProps
|
||||
extends Omit<React.ComponentProps<typeof Badge>, "variant"> {
|
||||
status: StatusType;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const statusVariantMap: Record<StatusType, VariantProps<typeof badgeVariants>["variant"]> = {
|
||||
const statusVariantMap: Record<
|
||||
StatusType,
|
||||
VariantProps<typeof badgeVariants>["variant"]
|
||||
> = {
|
||||
draft: "secondary",
|
||||
sent: "info",
|
||||
paid: "success",
|
||||
@@ -22,8 +22,8 @@ import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { FormSkeleton } from "~/components/ui/skeleton";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import { AddressForm } from "~/components/ui/address-form";
|
||||
import { FloatingActionBar } from "~/components/ui/floating-action-bar";
|
||||
import { AddressForm } from "~/components/forms/address-form";
|
||||
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
||||
import { api } from "~/trpc/react";
|
||||
import {
|
||||
formatPhoneNumber,
|
||||
@@ -10,8 +10,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { FormSkeleton } from "~/components/ui/skeleton";
|
||||
import { AddressForm } from "~/components/ui/address-form";
|
||||
import { FloatingActionBar } from "~/components/ui/floating-action-bar";
|
||||
import { AddressForm } from "~/components/forms/address-form";
|
||||
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
||||
import { api } from "~/trpc/react";
|
||||
import {
|
||||
formatPhoneNumber,
|
||||
@@ -5,7 +5,7 @@ import { useCallback } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Upload, FileText, X, CheckCircle, AlertCircle } from "lucide-react";
|
||||
import { Button } from "./button";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
interface FileUploadProps {
|
||||
onFilesSelected: (files: File[]) => void;
|
||||
@@ -25,7 +25,12 @@ interface FilePreviewProps {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function FilePreview({ file, onRemove, status = "pending", error }: FilePreviewProps) {
|
||||
function FilePreview({
|
||||
file,
|
||||
onRemove,
|
||||
status = "pending",
|
||||
error,
|
||||
}: FilePreviewProps) {
|
||||
const getStatusIcon = () => {
|
||||
switch (status) {
|
||||
case "success":
|
||||
@@ -49,20 +54,22 @@ function FilePreview({ file, onRemove, status = "pending", error }: FilePreviewP
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex items-center justify-between p-3 rounded-lg border",
|
||||
getStatusColor()
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between rounded-lg border p-3",
|
||||
getStatusColor(),
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusIcon()}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">{file.name}</p>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-gray-900">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{(file.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
{error && (
|
||||
<p className="text-xs text-red-600 mt-1">{error}</p>
|
||||
)}
|
||||
{error && <p className="mt-1 text-xs text-red-600">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
@@ -85,99 +92,111 @@ export function FileUpload({
|
||||
className,
|
||||
disabled = false,
|
||||
placeholder = "Drag & drop files here, or click to select",
|
||||
description
|
||||
description,
|
||||
}: FileUploadProps) {
|
||||
const [files, setFiles] = React.useState<File[]>([]);
|
||||
const [errors, setErrors] = React.useState<Record<string, string>>({});
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[], rejectedFiles: any[]) => {
|
||||
// Handle accepted files
|
||||
const newFiles = [...files, ...acceptedFiles];
|
||||
setFiles(newFiles);
|
||||
onFilesSelected(newFiles);
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[], rejectedFiles: any[]) => {
|
||||
// Handle accepted files
|
||||
const newFiles = [...files, ...acceptedFiles];
|
||||
setFiles(newFiles);
|
||||
onFilesSelected(newFiles);
|
||||
|
||||
// Handle rejected files
|
||||
const newErrors: Record<string, string> = { ...errors };
|
||||
rejectedFiles.forEach(({ file, errors }) => {
|
||||
const errorMessage = errors.map((e: any) => {
|
||||
if (e.code === 'file-too-large') {
|
||||
return `File is too large. Max size is ${(maxSize / 1024 / 1024).toFixed(1)}MB`;
|
||||
}
|
||||
if (e.code === 'file-invalid-type') {
|
||||
return 'File type not supported';
|
||||
}
|
||||
if (e.code === 'too-many-files') {
|
||||
return `Too many files. Max is ${maxFiles}`;
|
||||
}
|
||||
return e.message;
|
||||
}).join(', ');
|
||||
newErrors[file.name] = errorMessage;
|
||||
});
|
||||
setErrors(newErrors);
|
||||
}, [files, onFilesSelected, errors, maxFiles, maxSize]);
|
||||
// Handle rejected files
|
||||
const newErrors: Record<string, string> = { ...errors };
|
||||
rejectedFiles.forEach(({ file, errors }) => {
|
||||
const errorMessage = errors
|
||||
.map((e: any) => {
|
||||
if (e.code === "file-too-large") {
|
||||
return `File is too large. Max size is ${(maxSize / 1024 / 1024).toFixed(1)}MB`;
|
||||
}
|
||||
if (e.code === "file-invalid-type") {
|
||||
return "File type not supported";
|
||||
}
|
||||
if (e.code === "too-many-files") {
|
||||
return `Too many files. Max is ${maxFiles}`;
|
||||
}
|
||||
return e.message;
|
||||
})
|
||||
.join(", ");
|
||||
newErrors[file.name] = errorMessage;
|
||||
});
|
||||
setErrors(newErrors);
|
||||
},
|
||||
[files, onFilesSelected, errors, maxFiles, maxSize],
|
||||
);
|
||||
|
||||
const removeFile = (fileToRemove: File) => {
|
||||
const newFiles = files.filter(file => file !== fileToRemove);
|
||||
const newFiles = files.filter((file) => file !== fileToRemove);
|
||||
setFiles(newFiles);
|
||||
onFilesSelected(newFiles);
|
||||
|
||||
|
||||
const newErrors = { ...errors };
|
||||
delete newErrors[fileToRemove.name];
|
||||
setErrors(newErrors);
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({
|
||||
onDrop,
|
||||
accept,
|
||||
maxFiles,
|
||||
maxSize,
|
||||
disabled
|
||||
});
|
||||
const { getRootProps, getInputProps, isDragActive, isDragReject } =
|
||||
useDropzone({
|
||||
onDrop,
|
||||
accept,
|
||||
maxFiles,
|
||||
maxSize,
|
||||
disabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
"border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer",
|
||||
"cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors",
|
||||
"hover:border-emerald-400 hover:bg-emerald-50/50",
|
||||
isDragActive && "border-emerald-400 bg-emerald-50/50",
|
||||
isDragReject && "border-red-400 bg-red-50/50",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
"bg-white/80 backdrop-blur-sm"
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
"bg-white/80 backdrop-blur-sm",
|
||||
)}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className={cn(
|
||||
"p-3 rounded-full transition-colors",
|
||||
isDragActive ? "bg-emerald-100" : "bg-gray-100",
|
||||
isDragReject && "bg-red-100"
|
||||
)}>
|
||||
<Upload className={cn(
|
||||
"h-6 w-6 transition-colors",
|
||||
isDragActive ? "text-emerald-600" : "text-gray-400",
|
||||
isDragReject && "text-red-600"
|
||||
)} />
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-full p-3 transition-colors",
|
||||
isDragActive ? "bg-emerald-100" : "bg-gray-100",
|
||||
isDragReject && "bg-red-100",
|
||||
)}
|
||||
>
|
||||
<Upload
|
||||
className={cn(
|
||||
"h-6 w-6 transition-colors",
|
||||
isDragActive ? "text-emerald-600" : "text-gray-400",
|
||||
isDragReject && "text-red-600",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className={cn(
|
||||
"text-lg font-medium transition-colors",
|
||||
isDragActive ? "text-emerald-600" : "text-gray-900",
|
||||
isDragReject && "text-red-600"
|
||||
)}>
|
||||
{isDragActive
|
||||
? isDragReject
|
||||
? "File type not supported"
|
||||
<p
|
||||
className={cn(
|
||||
"text-lg font-medium transition-colors",
|
||||
isDragActive ? "text-emerald-600" : "text-gray-900",
|
||||
isDragReject && "text-red-600",
|
||||
)}
|
||||
>
|
||||
{isDragActive
|
||||
? isDragReject
|
||||
? "File type not supported"
|
||||
: "Drop files here"
|
||||
: placeholder
|
||||
}
|
||||
: placeholder}
|
||||
</p>
|
||||
{description && (
|
||||
<p className="text-sm text-gray-500">{description}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-400">
|
||||
Max {maxFiles} file{maxFiles !== 1 ? 's' : ''} • {(maxSize / 1024 / 1024).toFixed(1)}MB each
|
||||
Max {maxFiles} file{maxFiles !== 1 ? "s" : ""} •{" "}
|
||||
{(maxSize / 1024 / 1024).toFixed(1)}MB each
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -187,7 +206,7 @@ export function FileUpload({
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-gray-700">Selected Files</h4>
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||
<div className="max-h-60 space-y-2 overflow-y-auto">
|
||||
{files.map((file, index) => (
|
||||
<FilePreview
|
||||
key={`${file.name}-${index}`}
|
||||
@@ -203,16 +222,20 @@ export function FileUpload({
|
||||
|
||||
{/* Error Summary */}
|
||||
{Object.keys(errors).length > 0 && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-red-600" />
|
||||
<span className="text-sm font-medium text-red-800">Upload Errors</span>
|
||||
<span className="text-sm font-medium text-red-800">
|
||||
Upload Errors
|
||||
</span>
|
||||
</div>
|
||||
<ul className="text-sm text-red-700 space-y-1">
|
||||
<ul className="space-y-1 text-sm text-red-700">
|
||||
{Object.entries(errors).map(([fileName, error]) => (
|
||||
<li key={fileName} className="flex items-start gap-2">
|
||||
<span className="text-red-600">•</span>
|
||||
<span><strong>{fileName}:</strong> {error}</span>
|
||||
<span>
|
||||
<strong>{fileName}:</strong> {error}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -220,4 +243,4 @@ export function FileUpload({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
import { useRouter } from "next/navigation";
|
||||
import { format } from "date-fns";
|
||||
import { FormSkeleton } from "~/components/ui/skeleton";
|
||||
import { EditableInvoiceItems } from "~/components/editable-invoice-items";
|
||||
import { EditableInvoiceItems } from "~/components/data/editable-invoice-items";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{
|
||||
@@ -273,16 +273,16 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
return (
|
||||
<div className="space-y-6 pb-20">
|
||||
{/* Invoice Details Card Skeleton */}
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader>
|
||||
<div className="h-6 w-48 animate-pulse rounded bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="h-6 w-48 animate-pulse rounded bg-gray-300"></div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-6 xl:grid-cols-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="h-10 animate-pulse rounded bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-gray-300"></div>
|
||||
<div className="h-10 animate-pulse rounded bg-gray-300"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -290,20 +290,20 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
</Card>
|
||||
|
||||
{/* Invoice Items Card Skeleton */}
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-6 w-32 animate-pulse rounded bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="h-10 w-24 animate-pulse rounded bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="h-6 w-32 animate-pulse rounded bg-gray-300"></div>
|
||||
<div className="h-10 w-24 animate-pulse rounded bg-gray-300"></div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Items Table Header Skeleton */}
|
||||
<div className="grid grid-cols-12 gap-2 rounded-lg bg-gray-50 px-4 py-3 dark:bg-gray-700">
|
||||
<div className="grid grid-cols-12 gap-2 rounded-lg bg-gray-50 px-4 py-3">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-4 animate-pulse rounded bg-gray-300 dark:bg-gray-600"
|
||||
className="h-4 animate-pulse rounded bg-gray-300"
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
@@ -313,7 +313,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="grid animate-pulse grid-cols-12 items-center gap-2 rounded-lg border border-gray-200 p-4 dark:border-gray-700"
|
||||
className="grid animate-pulse grid-cols-12 items-center gap-2 rounded-lg border border-gray-200 p-4"
|
||||
>
|
||||
{Array.from({ length: 8 }).map((_, j) => (
|
||||
<div
|
||||
@@ -353,7 +353,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
return (
|
||||
<div className="space-y-6 pb-20">
|
||||
{/* Invoice Details Card Skeleton */}
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm">
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader>
|
||||
<div className="h-6 w-48 animate-pulse rounded bg-gray-300"></div>
|
||||
</CardHeader>
|
||||
@@ -370,7 +370,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
</Card>
|
||||
|
||||
{/* Invoice Items Card Skeleton */}
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-6 w-32 animate-pulse rounded bg-gray-300"></div>
|
||||
@@ -423,9 +423,9 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
return (
|
||||
<form id="invoice-form" onSubmit={handleSubmit} className="space-y-6 pb-20">
|
||||
{/* Invoice Details Card */}
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-emerald-700 dark:text-emerald-400">
|
||||
<CardTitle className="flex items-center gap-2 text-emerald-700">
|
||||
<FileText className="h-5 w-5" />
|
||||
Invoice Details
|
||||
</CardTitle>
|
||||
@@ -653,10 +653,10 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
</Card>
|
||||
|
||||
{/* Invoice Items Card */}
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-emerald-700 dark:text-emerald-400">
|
||||
<CardTitle className="flex items-center gap-2 text-emerald-700">
|
||||
<Clock className="h-5 w-5" />
|
||||
Invoice Items
|
||||
</CardTitle>
|
||||
@@ -4,8 +4,8 @@ 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 "./logo";
|
||||
import { SidebarTrigger } from "./SidebarTrigger";
|
||||
import { Logo } from "~/components/branding/logo";
|
||||
import { SidebarTrigger } from "~/components/navigation/sidebar-trigger";
|
||||
|
||||
export function Navbar() {
|
||||
const { data: session, status } = useSession();
|
||||
Reference in New Issue
Block a user