mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-02-05 08:16:31 -05:00
Build fixes, email preview system
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import Image from "next/image";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface LogoProps {
|
||||
className?: string;
|
||||
@@ -18,8 +17,8 @@ export function Logo({ className, size = "md" }: LogoProps) {
|
||||
<Image
|
||||
src="/beenvoice-logo.svg"
|
||||
alt="beenvoice logo"
|
||||
width={width}
|
||||
height={height}
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
priority
|
||||
/>
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Calendar, Clock, Edit, Eye, FileText, Plus, User } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
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 { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import {
|
||||
FileText,
|
||||
Clock,
|
||||
Plus,
|
||||
Edit,
|
||||
Eye,
|
||||
DollarSign,
|
||||
User,
|
||||
Calendar,
|
||||
} from "lucide-react";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
export function CurrentOpenInvoiceCard() {
|
||||
const { data: currentInvoice, isLoading } =
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
@@ -18,23 +17,25 @@ import {
|
||||
import {
|
||||
ArrowUpDown,
|
||||
ChevronDown,
|
||||
Search,
|
||||
Filter,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Filter,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -50,7 +51,6 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "~/components/ui/table";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
@@ -77,7 +77,7 @@ interface DataTableProps<TData, TValue> {
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
searchKey,
|
||||
searchKey: _searchKey,
|
||||
searchPlaceholder = "Search...",
|
||||
showColumnVisibility = true,
|
||||
showPagination = true,
|
||||
@@ -511,7 +511,7 @@ export function DataTable<TData, TValue>({
|
||||
}
|
||||
|
||||
// Helper component for sortable column headers
|
||||
export function DataTableColumnHeader<TData, TValue>({
|
||||
export function DataTableColumnHeader({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
@@ -552,7 +552,7 @@ export function DataTableColumnHeader<TData, TValue>({
|
||||
|
||||
// Export skeleton component for loading states
|
||||
export function DataTableSkeleton({
|
||||
columns = 5,
|
||||
columns: _columns = 5,
|
||||
rows = 5,
|
||||
}: {
|
||||
columns?: number;
|
||||
|
||||
@@ -89,9 +89,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
||||
deleteInvoice.mutate({ id: invoiceId });
|
||||
};
|
||||
|
||||
const handleStatusUpdate = (
|
||||
newStatus: "draft" | "sent" | "paid" | "overdue",
|
||||
) => {
|
||||
const handleStatusUpdate = (newStatus: "draft" | "sent" | "paid") => {
|
||||
updateStatus.mutate({ id: invoiceId, status: newStatus });
|
||||
};
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ export function AddressForm({
|
||||
className = "",
|
||||
}: AddressFormProps) {
|
||||
const handlePostalCodeChange = (value: string) => {
|
||||
const formatted = formatPostalCode(value, country || "US");
|
||||
const formatted = formatPostalCode(value, country ?? "US");
|
||||
onChange("postalCode", formatted);
|
||||
};
|
||||
|
||||
@@ -137,7 +137,7 @@ export function AddressForm({
|
||||
key={`state-${state}`}
|
||||
id="state"
|
||||
options={stateOptions}
|
||||
value={state || ""}
|
||||
value={state ?? ""}
|
||||
onValueChange={(value) => onChange("state", value)}
|
||||
placeholder="Select a state"
|
||||
className={errors.state ? "border-destructive" : ""}
|
||||
@@ -194,7 +194,7 @@ export function AddressForm({
|
||||
key={`country-${country}`}
|
||||
id="country"
|
||||
options={countryOptions}
|
||||
value={country || ""}
|
||||
value={country ?? ""}
|
||||
onValueChange={(value) => {
|
||||
// Don't save the placeholder value
|
||||
if (value !== "__placeholder__") {
|
||||
@@ -218,7 +218,8 @@ export function AddressForm({
|
||||
return option.label;
|
||||
}}
|
||||
isOptionDisabled={(option) =>
|
||||
option.disabled || option.value?.startsWith("divider-")
|
||||
(option.disabled ?? false) ||
|
||||
(option.value?.startsWith("divider-") ?? false)
|
||||
}
|
||||
/>
|
||||
{errors.country && (
|
||||
|
||||
@@ -1,40 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Building,
|
||||
Mail,
|
||||
Phone,
|
||||
Save,
|
||||
Globe,
|
||||
BadgeDollarSign,
|
||||
Image,
|
||||
Star,
|
||||
Loader2,
|
||||
ArrowLeft,
|
||||
Building,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileText,
|
||||
Globe,
|
||||
Info,
|
||||
Key,
|
||||
Loader2,
|
||||
Mail,
|
||||
Save,
|
||||
Star,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AddressForm } from "~/components/forms/address-form";
|
||||
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import { AddressForm } from "~/components/forms/address-form";
|
||||
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { api } from "~/trpc/react";
|
||||
import {
|
||||
formatPhoneNumber,
|
||||
formatWebsiteUrl,
|
||||
formatTaxId,
|
||||
formatWebsiteUrl,
|
||||
isValidEmail,
|
||||
VALIDATION_MESSAGES,
|
||||
PLACEHOLDERS,
|
||||
VALIDATION_MESSAGES,
|
||||
} from "~/lib/form-constants";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
interface BusinessFormProps {
|
||||
businessId?: string;
|
||||
@@ -53,8 +56,10 @@ interface FormData {
|
||||
country: string;
|
||||
website: string;
|
||||
taxId: string;
|
||||
logoUrl: string;
|
||||
isDefault: boolean;
|
||||
resendApiKey: string;
|
||||
resendDomain: string;
|
||||
emailFromName: string;
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
@@ -68,6 +73,9 @@ interface FormErrors {
|
||||
country?: string;
|
||||
website?: string;
|
||||
taxId?: string;
|
||||
resendApiKey?: string;
|
||||
resendDomain?: string;
|
||||
emailFromName?: string;
|
||||
}
|
||||
|
||||
const initialFormData: FormData = {
|
||||
@@ -82,8 +90,10 @@ const initialFormData: FormData = {
|
||||
country: "United States",
|
||||
website: "",
|
||||
taxId: "",
|
||||
logoUrl: "",
|
||||
isDefault: false,
|
||||
resendApiKey: "",
|
||||
resendDomain: "",
|
||||
emailFromName: "",
|
||||
};
|
||||
|
||||
export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
@@ -91,6 +101,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
const [formData, setFormData] = useState<FormData>(initialFormData);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
|
||||
// Fetch business data if editing
|
||||
@@ -100,6 +111,23 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
{ enabled: mode === "edit" && !!businessId },
|
||||
);
|
||||
|
||||
// Fetch email configuration if editing
|
||||
const { data: emailConfig, isLoading: isLoadingEmailConfig } =
|
||||
api.businesses.getEmailConfig.useQuery(
|
||||
{ id: businessId! },
|
||||
{ enabled: mode === "edit" && !!businessId },
|
||||
);
|
||||
|
||||
// Update email configuration mutation
|
||||
const updateEmailConfig = api.businesses.updateEmailConfig.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Email configuration updated successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to update email configuration: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const createBusiness = api.businesses.create.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Business created successfully");
|
||||
@@ -135,11 +163,13 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
country: business.country ?? "United States",
|
||||
website: business.website ?? "",
|
||||
taxId: business.taxId ?? "",
|
||||
logoUrl: business.logoUrl ?? "",
|
||||
isDefault: business.isDefault ?? false,
|
||||
resendApiKey: "", // Never pre-fill API key for security
|
||||
resendDomain: emailConfig?.resendDomain ?? "",
|
||||
emailFromName: emailConfig?.emailFromName ?? "",
|
||||
});
|
||||
}
|
||||
}, [business, mode]);
|
||||
}, [business, emailConfig, mode]);
|
||||
|
||||
const handleInputChange = (field: string, value: string | boolean) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
@@ -202,6 +232,36 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Email configuration validation
|
||||
// API Key validation
|
||||
if (formData.resendApiKey && !formData.resendApiKey.startsWith("re_")) {
|
||||
newErrors.resendApiKey = "Resend API key should start with 're_'";
|
||||
}
|
||||
|
||||
// Domain validation
|
||||
if (formData.resendDomain) {
|
||||
const domainRegex =
|
||||
/^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?\.([a-zA-Z]{2,})+$/;
|
||||
if (!domainRegex.test(formData.resendDomain)) {
|
||||
newErrors.resendDomain =
|
||||
"Please enter a valid domain (e.g., yourdomain.com)";
|
||||
}
|
||||
}
|
||||
|
||||
// If API key is provided, domain must also be provided
|
||||
if (formData.resendApiKey && !formData.resendDomain) {
|
||||
newErrors.resendDomain = "Domain is required when API key is provided";
|
||||
}
|
||||
|
||||
// If domain is provided, API key must also be provided
|
||||
if (
|
||||
formData.resendDomain &&
|
||||
!formData.resendApiKey &&
|
||||
!emailConfig?.hasApiKey
|
||||
) {
|
||||
newErrors.resendApiKey = "API key is required when domain is provided";
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
@@ -224,12 +284,73 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
};
|
||||
|
||||
if (mode === "create") {
|
||||
await createBusiness.mutateAsync(dataToSubmit);
|
||||
// Create business data (excluding email config fields)
|
||||
const businessData = {
|
||||
name: dataToSubmit.name,
|
||||
email: dataToSubmit.email,
|
||||
phone: dataToSubmit.phone,
|
||||
addressLine1: dataToSubmit.addressLine1,
|
||||
addressLine2: dataToSubmit.addressLine2,
|
||||
city: dataToSubmit.city,
|
||||
state: dataToSubmit.state,
|
||||
postalCode: dataToSubmit.postalCode,
|
||||
country: dataToSubmit.country,
|
||||
website: dataToSubmit.website,
|
||||
taxId: dataToSubmit.taxId,
|
||||
isDefault: dataToSubmit.isDefault,
|
||||
};
|
||||
|
||||
const newBusiness = await createBusiness.mutateAsync(businessData);
|
||||
|
||||
// Update email configuration separately if any email fields are provided
|
||||
if (
|
||||
newBusiness &&
|
||||
(formData.resendApiKey ||
|
||||
formData.resendDomain ||
|
||||
formData.emailFromName)
|
||||
) {
|
||||
await updateEmailConfig.mutateAsync({
|
||||
id: newBusiness.id,
|
||||
resendApiKey: formData.resendApiKey || undefined,
|
||||
resendDomain: formData.resendDomain || undefined,
|
||||
emailFromName: formData.emailFromName || undefined,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Update business data (excluding email config fields)
|
||||
const businessData = {
|
||||
name: dataToSubmit.name,
|
||||
email: dataToSubmit.email,
|
||||
phone: dataToSubmit.phone,
|
||||
addressLine1: dataToSubmit.addressLine1,
|
||||
addressLine2: dataToSubmit.addressLine2,
|
||||
city: dataToSubmit.city,
|
||||
state: dataToSubmit.state,
|
||||
postalCode: dataToSubmit.postalCode,
|
||||
country: dataToSubmit.country,
|
||||
website: dataToSubmit.website,
|
||||
taxId: dataToSubmit.taxId,
|
||||
isDefault: dataToSubmit.isDefault,
|
||||
};
|
||||
|
||||
await updateBusiness.mutateAsync({
|
||||
id: businessId!,
|
||||
...dataToSubmit,
|
||||
...businessData,
|
||||
});
|
||||
|
||||
// Update email configuration separately if any email fields are provided
|
||||
if (
|
||||
formData.resendApiKey ||
|
||||
formData.resendDomain ||
|
||||
formData.emailFromName
|
||||
) {
|
||||
await updateEmailConfig.mutateAsync({
|
||||
id: businessId!,
|
||||
resendApiKey: formData.resendApiKey || undefined,
|
||||
resendDomain: formData.resendDomain || undefined,
|
||||
emailFromName: formData.emailFromName || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
@@ -246,7 +367,10 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
router.push("/dashboard/businesses");
|
||||
};
|
||||
|
||||
if (mode === "edit" && isLoadingBusiness) {
|
||||
if (
|
||||
(mode === "edit" && isLoadingBusiness) ||
|
||||
(mode === "edit" && isLoadingEmailConfig)
|
||||
) {
|
||||
return (
|
||||
<div className="space-y-6 pb-32">
|
||||
<Card>
|
||||
@@ -488,6 +612,189 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Email Configuration */}
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-brand-muted flex h-10 w-10 items-center justify-center rounded-lg">
|
||||
<Mail className="text-brand-light h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Email Configuration</CardTitle>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
Configure your own Resend API key and domain for sending
|
||||
invoices
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Current Status */}
|
||||
{mode === "edit" && (
|
||||
<div className="flex items-center justify-between rounded-lg bg-gray-50 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
Current Status:
|
||||
</span>
|
||||
{emailConfig?.hasApiKey && emailConfig?.resendDomain ? (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-green-100 text-green-800"
|
||||
>
|
||||
<Key className="mr-1 h-3 w-3" />
|
||||
Custom Configuration Active
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">
|
||||
<Globe className="mr-1 h-3 w-3" />
|
||||
Using System Default
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{emailConfig?.resendDomain && (
|
||||
<span className="text-sm text-gray-600">
|
||||
Domain: {emailConfig.resendDomain}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
To use your own email configuration, you'll need to:
|
||||
<ul className="mt-2 list-inside list-disc space-y-1">
|
||||
<li>
|
||||
Create a free account at{" "}
|
||||
<a
|
||||
href="https://resend.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
resend.com
|
||||
</a>
|
||||
</li>
|
||||
<li>Verify your domain in the Resend dashboard</li>
|
||||
<li>Get your API key from the Resend dashboard</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* API Key */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="resendApiKey"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Key className="h-4 w-4" />
|
||||
Resend API Key
|
||||
{mode === "edit" && emailConfig?.hasApiKey && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Currently Set
|
||||
</Badge>
|
||||
)}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="resendApiKey"
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={formData.resendApiKey}
|
||||
onChange={(e) =>
|
||||
handleInputChange("resendApiKey", e.target.value)
|
||||
}
|
||||
placeholder={
|
||||
mode === "edit" && emailConfig?.hasApiKey
|
||||
? "Enter new API key to update"
|
||||
: "re_..."
|
||||
}
|
||||
className={errors.resendApiKey ? "border-red-500" : ""}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-1/2 right-2 h-6 w-6 -translate-y-1/2 p-0"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{errors.resendApiKey && (
|
||||
<p className="text-sm text-red-600">
|
||||
{errors.resendApiKey}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Domain */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="resendDomain"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
Verified Domain
|
||||
</Label>
|
||||
<Input
|
||||
id="resendDomain"
|
||||
type="text"
|
||||
value={formData.resendDomain}
|
||||
onChange={(e) =>
|
||||
handleInputChange("resendDomain", e.target.value)
|
||||
}
|
||||
placeholder="yourdomain.com"
|
||||
className={errors.resendDomain ? "border-red-500" : ""}
|
||||
/>
|
||||
{errors.resendDomain && (
|
||||
<p className="text-sm text-red-600">
|
||||
{errors.resendDomain}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-600">
|
||||
This domain must be verified in your Resend account before
|
||||
emails can be sent.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* From Name */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="emailFromName"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
From Name (Optional)
|
||||
</Label>
|
||||
<Input
|
||||
id="emailFromName"
|
||||
type="text"
|
||||
value={formData.emailFromName}
|
||||
onChange={(e) =>
|
||||
handleInputChange("emailFromName", e.target.value)
|
||||
}
|
||||
placeholder={formData.name || "Your Business Name"}
|
||||
className={errors.emailFromName ? "border-red-500" : ""}
|
||||
/>
|
||||
{errors.emailFromName && (
|
||||
<p className="text-sm text-red-600">
|
||||
{errors.emailFromName}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-600">
|
||||
This will appear as the sender name in emails. Defaults to
|
||||
your business name.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Settings */}
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
|
||||
345
src/components/forms/email-composer.tsx
Normal file
345
src/components/forms/email-composer.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
"use client";
|
||||
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import { TextAlign } from "@tiptap/extension-text-align";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
List,
|
||||
ListOrdered,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
Palette,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/components/ui/popover";
|
||||
|
||||
interface EmailComposerProps {
|
||||
subject: string;
|
||||
onSubjectChange: (subject: string) => void;
|
||||
content?: string;
|
||||
onContentChange?: (content: string) => void;
|
||||
customMessage?: string;
|
||||
onCustomMessageChange?: (customMessage: string) => void;
|
||||
fromEmail: string;
|
||||
toEmail: string;
|
||||
ccEmail?: string;
|
||||
onCcEmailChange?: (ccEmail: string) => void;
|
||||
bccEmail?: string;
|
||||
onBccEmailChange?: (bccEmail: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MenuButton = ({
|
||||
onClick,
|
||||
isActive,
|
||||
children,
|
||||
title,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
isActive?: boolean;
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
}) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant={isActive ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
|
||||
export function EmailComposer({
|
||||
subject,
|
||||
onSubjectChange,
|
||||
content: _content,
|
||||
onContentChange: _onContentChange,
|
||||
customMessage = "",
|
||||
onCustomMessageChange,
|
||||
fromEmail,
|
||||
toEmail,
|
||||
ccEmail = "",
|
||||
onCcEmailChange,
|
||||
bccEmail = "",
|
||||
onBccEmailChange,
|
||||
className,
|
||||
}: EmailComposerProps) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
TextStyle,
|
||||
Color.configure({
|
||||
types: ["textStyle"],
|
||||
}),
|
||||
TextAlign.configure({
|
||||
types: ["heading", "paragraph"],
|
||||
}),
|
||||
],
|
||||
content: customMessage,
|
||||
immediatelyRender: false,
|
||||
onUpdate: ({ editor }) => {
|
||||
onCustomMessageChange?.(editor.getHTML());
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
"prose prose-sm sm:prose lg:prose-lg xl:prose-2xl mx-auto focus:outline-none min-h-[120px] p-4 border rounded-md bg-background",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Update editor content when customMessage prop changes
|
||||
useEffect(() => {
|
||||
if (editor && customMessage !== undefined) {
|
||||
const currentContent = editor.getHTML();
|
||||
if (currentContent !== customMessage) {
|
||||
editor.commands.setContent(customMessage);
|
||||
}
|
||||
}
|
||||
}, [editor, customMessage]);
|
||||
|
||||
const colors = [
|
||||
"#000000",
|
||||
"#374151",
|
||||
"#DC2626",
|
||||
"#EA580C",
|
||||
"#D97706",
|
||||
"#65A30D",
|
||||
"#16A34A",
|
||||
"#0891B2",
|
||||
"#2563EB",
|
||||
"#7C3AED",
|
||||
"#C026D3",
|
||||
"#DC2626",
|
||||
];
|
||||
|
||||
if (!editor) {
|
||||
return (
|
||||
<div className="bg-muted flex h-[200px] items-center justify-center rounded-md border">
|
||||
<div className="text-center">
|
||||
<div className="border-primary mx-auto mb-2 h-4 w-4 animate-spin rounded-full border-2 border-t-transparent"></div>
|
||||
<p className="text-muted-foreground text-sm">Loading editor...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Email Headers */}
|
||||
<div className="bg-muted/20 space-y-4 rounded-lg border p-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="from-email" className="text-sm font-medium">
|
||||
From
|
||||
</Label>
|
||||
<Input
|
||||
id="from-email"
|
||||
value={fromEmail}
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="to-email" className="text-sm font-medium">
|
||||
To
|
||||
</Label>
|
||||
<Input
|
||||
id="to-email"
|
||||
value={toEmail}
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{(onCcEmailChange ?? onBccEmailChange) && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{onCcEmailChange && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cc-email" className="text-sm font-medium">
|
||||
CC
|
||||
</Label>
|
||||
<Input
|
||||
id="cc-email"
|
||||
value={ccEmail ?? ""}
|
||||
onChange={(e) => onCcEmailChange(e.target.value)}
|
||||
placeholder="CC email addresses..."
|
||||
className="bg-background"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{onBccEmailChange && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bcc-email" className="text-sm font-medium">
|
||||
BCC
|
||||
</Label>
|
||||
<Input
|
||||
id="bcc-email"
|
||||
value={bccEmail}
|
||||
onChange={(e) => onBccEmailChange(e.target.value)}
|
||||
placeholder="BCC email addresses..."
|
||||
className="bg-background"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subject" className="text-sm font-medium">
|
||||
Subject
|
||||
</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
value={subject}
|
||||
onChange={(e) => onSubjectChange(e.target.value)}
|
||||
placeholder="Enter email subject..."
|
||||
className="bg-background"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
{/* Custom Message Field with Rich Text Editor */}
|
||||
{onCustomMessageChange && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium">
|
||||
Custom Message (Optional)
|
||||
</Label>
|
||||
<p className="text-muted-foreground mb-2 text-xs">
|
||||
This message will appear between the greeting and invoice summary
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Editor Toolbar */}
|
||||
<div className="bg-muted/20 flex flex-wrap items-center gap-1 rounded-lg border p-2">
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
isActive={editor.isActive("bold")}
|
||||
title="Bold"
|
||||
>
|
||||
<Bold className="h-4 w-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
isActive={editor.isActive("italic")}
|
||||
title="Italic"
|
||||
>
|
||||
<Italic className="h-4 w-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
isActive={editor.isActive("strike")}
|
||||
title="Strikethrough"
|
||||
>
|
||||
<Underline className="h-4 w-4" />
|
||||
</MenuButton>
|
||||
|
||||
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setTextAlign("left").run()}
|
||||
isActive={editor.isActive({ textAlign: "left" })}
|
||||
title="Align Left"
|
||||
>
|
||||
<AlignLeft className="h-4 w-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() =>
|
||||
editor.chain().focus().setTextAlign("center").run()
|
||||
}
|
||||
isActive={editor.isActive({ textAlign: "center" })}
|
||||
title="Align Center"
|
||||
>
|
||||
<AlignCenter className="h-4 w-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setTextAlign("right").run()}
|
||||
isActive={editor.isActive({ textAlign: "right" })}
|
||||
title="Align Right"
|
||||
>
|
||||
<AlignRight className="h-4 w-4" />
|
||||
</MenuButton>
|
||||
|
||||
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
isActive={editor.isActive("bulletList")}
|
||||
title="Bullet List"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
isActive={editor.isActive("orderedList")}
|
||||
title="Ordered List"
|
||||
>
|
||||
<ListOrdered className="h-4 w-4" />
|
||||
</MenuButton>
|
||||
|
||||
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
title="Text Color"
|
||||
>
|
||||
<Palette className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48 p-2">
|
||||
<div className="grid grid-cols-6 gap-1">
|
||||
{colors.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
className="h-6 w-6 rounded border border-gray-300 hover:scale-110"
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => {
|
||||
editor.chain().focus().setColor(color).run();
|
||||
}}
|
||||
title={color}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Rich Text Editor */}
|
||||
<div>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
167
src/components/forms/email-preview.tsx
Normal file
167
src/components/forms/email-preview.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
|
||||
|
||||
interface EmailPreviewProps {
|
||||
subject: string;
|
||||
fromEmail: string;
|
||||
toEmail: string;
|
||||
ccEmail?: string;
|
||||
bccEmail?: string;
|
||||
content: string;
|
||||
customMessage?: string;
|
||||
invoice?: {
|
||||
invoiceNumber: string;
|
||||
issueDate: Date;
|
||||
dueDate: Date;
|
||||
taxRate: number;
|
||||
status?: string;
|
||||
totalAmount?: number;
|
||||
client?: {
|
||||
name: string;
|
||||
email: string | null;
|
||||
};
|
||||
business?: {
|
||||
name: string;
|
||||
email: string | null;
|
||||
};
|
||||
items?: Array<{
|
||||
id: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
}>;
|
||||
};
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EmailPreview({
|
||||
subject,
|
||||
fromEmail,
|
||||
toEmail,
|
||||
ccEmail,
|
||||
bccEmail,
|
||||
content,
|
||||
customMessage,
|
||||
invoice,
|
||||
className,
|
||||
}: EmailPreviewProps) {
|
||||
// Calculate total from invoice items if available
|
||||
const calculateTotal = () => {
|
||||
if (!invoice?.items) return 0;
|
||||
const subtotal = invoice.items.reduce(
|
||||
(sum, item) => sum + item.hours * item.rate,
|
||||
0,
|
||||
);
|
||||
const taxAmount = subtotal * (invoice.taxRate / 100);
|
||||
return subtotal + taxAmount;
|
||||
};
|
||||
|
||||
// Generate the branded email template if invoice is provided
|
||||
const emailTemplate = invoice
|
||||
? generateInvoiceEmailTemplate({
|
||||
invoice: {
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
issueDate: invoice.issueDate,
|
||||
dueDate: invoice.dueDate,
|
||||
status: invoice.status ?? "draft",
|
||||
totalAmount: invoice.totalAmount ?? calculateTotal(),
|
||||
taxRate: invoice.taxRate,
|
||||
notes: null,
|
||||
client: {
|
||||
name: invoice.client?.name ?? "Client",
|
||||
email: invoice.client?.email ?? null,
|
||||
},
|
||||
business: invoice.business ?? null,
|
||||
items:
|
||||
invoice.items?.map((item) => ({
|
||||
date: new Date(),
|
||||
description: "Service",
|
||||
hours: item.hours,
|
||||
rate: item.rate,
|
||||
amount: item.hours * item.rate,
|
||||
})) ?? [],
|
||||
},
|
||||
customContent: content,
|
||||
customMessage: customMessage,
|
||||
userName: invoice.business?.name ?? "Your Business",
|
||||
userEmail: fromEmail,
|
||||
baseUrl:
|
||||
typeof window !== "undefined"
|
||||
? window.location.origin
|
||||
: "https://beenvoice.app",
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Email Headers */}
|
||||
<div className="bg-muted/20 mb-4 space-y-3 rounded-lg p-4">
|
||||
<div className="grid grid-cols-1 gap-3 text-sm md:grid-cols-3">
|
||||
<div>
|
||||
<span className="text-muted-foreground block text-xs font-medium">
|
||||
From:
|
||||
</span>
|
||||
<span className="font-mono text-sm break-all">{fromEmail}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground block text-xs font-medium">
|
||||
To:
|
||||
</span>
|
||||
<span className="font-mono text-sm break-all">{toEmail}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground block text-xs font-medium">
|
||||
Subject:
|
||||
</span>
|
||||
<span className="text-sm font-semibold break-words">
|
||||
{subject || "No subject"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{(ccEmail ?? bccEmail) && (
|
||||
<div className="grid grid-cols-1 gap-3 text-sm md:grid-cols-2">
|
||||
{ccEmail && (
|
||||
<div>
|
||||
<span className="text-muted-foreground block text-xs font-medium">
|
||||
CC:
|
||||
</span>
|
||||
<span className="font-mono text-sm break-all">{ccEmail}</span>
|
||||
</div>
|
||||
)}
|
||||
{bccEmail && (
|
||||
<div>
|
||||
<span className="text-muted-foreground block text-xs font-medium">
|
||||
BCC:
|
||||
</span>
|
||||
<span className="font-mono text-sm break-all">{bccEmail}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email Content */}
|
||||
{emailTemplate ? (
|
||||
<div className="rounded-lg border bg-gray-50 p-1 shadow-sm">
|
||||
<iframe
|
||||
srcDoc={emailTemplate.html}
|
||||
className="h-[700px] w-full rounded border-0"
|
||||
title="Email Preview"
|
||||
sandbox="allow-same-origin"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex min-h-[400px] items-center justify-center">
|
||||
<p className="text-center text-sm">
|
||||
Email preview will appear here...
|
||||
<br />
|
||||
<span className="text-xs">
|
||||
Professional beenvoice-branded template will be generated
|
||||
automatically
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
src/components/forms/enhanced-send-invoice-button.tsx
Normal file
98
src/components/forms/enhanced-send-invoice-button.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Send, Loader2, Mail, MailCheck } from "lucide-react";
|
||||
|
||||
interface EnhancedSendInvoiceButtonProps {
|
||||
invoiceId: string;
|
||||
variant?: "default" | "outline" | "ghost" | "icon";
|
||||
className?: string;
|
||||
showResend?: boolean;
|
||||
size?: "default" | "sm" | "lg" | "icon";
|
||||
}
|
||||
|
||||
export function EnhancedSendInvoiceButton({
|
||||
invoiceId,
|
||||
variant = "outline",
|
||||
className,
|
||||
showResend = false,
|
||||
size = "default",
|
||||
}: EnhancedSendInvoiceButtonProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// Fetch invoice data
|
||||
const { data: invoiceData, isLoading: invoiceLoading } =
|
||||
api.invoices.getById.useQuery({
|
||||
id: invoiceId,
|
||||
});
|
||||
|
||||
// Check if client has email
|
||||
const hasClientEmail =
|
||||
invoiceData?.client?.email && invoiceData.client.email.trim() !== "";
|
||||
|
||||
const handleSendClick = () => {
|
||||
router.push(`/dashboard/invoices/${invoiceId}/send`);
|
||||
};
|
||||
|
||||
// Icon variant for compact display
|
||||
if (variant === "icon") {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={className}
|
||||
disabled={invoiceLoading || !hasClientEmail}
|
||||
onClick={handleSendClick}
|
||||
title={
|
||||
!hasClientEmail
|
||||
? "Client has no email address"
|
||||
: showResend
|
||||
? "Resend Invoice"
|
||||
: "Send Invoice"
|
||||
}
|
||||
>
|
||||
{invoiceLoading ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin sm:h-4 sm:w-4" />
|
||||
) : hasClientEmail ? (
|
||||
<Send className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
) : (
|
||||
<Mail className="h-3 w-3 opacity-50 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={`shadow-sm ${className}`}
|
||||
disabled={invoiceLoading || !hasClientEmail}
|
||||
onClick={handleSendClick}
|
||||
data-testid="enhanced-send-invoice-button"
|
||||
>
|
||||
{invoiceLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<span>Loading...</span>
|
||||
</>
|
||||
) : !hasClientEmail ? (
|
||||
<>
|
||||
<Mail className="mr-2 h-4 w-4 opacity-50" />
|
||||
<span>No Email Address</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{invoiceData?.status === "sent" ? (
|
||||
<MailCheck className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
<span>{showResend ? "Resend Invoice" : "Send Invoice"}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { useDropzone, type FileRejection } from "react-dropzone";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Upload, FileText, X, CheckCircle, AlertCircle } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
@@ -98,7 +98,7 @@ export function FileUpload({
|
||||
const [errors, setErrors] = React.useState<Record<string, string>>({});
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[], rejectedFiles: any[]) => {
|
||||
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
|
||||
// Handle accepted files
|
||||
const newFiles = [...files, ...acceptedFiles];
|
||||
setFiles(newFiles);
|
||||
@@ -106,19 +106,19 @@ export function FileUpload({
|
||||
|
||||
// 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") {
|
||||
rejectedFiles.forEach(({ file, errors: fileErrors }) => {
|
||||
const errorMessage = fileErrors
|
||||
.map((error) => {
|
||||
if (error.code === "file-too-large") {
|
||||
return `File is too large. Max size is ${(maxSize / 1024 / 1024).toFixed(1)}MB`;
|
||||
}
|
||||
if (e.code === "file-invalid-type") {
|
||||
if (error.code === "file-invalid-type") {
|
||||
return "File type not supported";
|
||||
}
|
||||
if (e.code === "too-many-files") {
|
||||
if (error.code === "too-many-files") {
|
||||
return `Too many files. Max is ${maxFiles}`;
|
||||
}
|
||||
return e.message;
|
||||
return error.message;
|
||||
})
|
||||
.join(", ");
|
||||
newErrors[file.name] = errorMessage;
|
||||
|
||||
@@ -38,7 +38,6 @@ const STATUS_OPTIONS = [
|
||||
{ value: "draft", label: "Draft" },
|
||||
{ value: "sent", label: "Sent" },
|
||||
{ value: "paid", label: "Paid" },
|
||||
{ value: "overdue", label: "Overdue" },
|
||||
];
|
||||
|
||||
interface InvoiceFormProps {
|
||||
@@ -60,7 +59,7 @@ interface FormData {
|
||||
clientId: string;
|
||||
issueDate: Date;
|
||||
dueDate: Date;
|
||||
status: "draft" | "sent" | "paid" | "overdue";
|
||||
status: "draft" | "sent" | "paid";
|
||||
notes: string;
|
||||
taxRate: number;
|
||||
defaultHourlyRate: number;
|
||||
@@ -158,7 +157,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
clientId: existingInvoice.clientId,
|
||||
issueDate: new Date(existingInvoice.issueDate),
|
||||
dueDate: new Date(existingInvoice.dueDate),
|
||||
status: existingInvoice.status as "draft" | "sent" | "paid" | "overdue",
|
||||
status: existingInvoice.status as "draft" | "sent" | "paid",
|
||||
notes: existingInvoice.notes ?? "",
|
||||
taxRate: existingInvoice.taxRate,
|
||||
defaultHourlyRate: 25,
|
||||
@@ -523,9 +522,9 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(
|
||||
value: "draft" | "sent" | "paid" | "overdue",
|
||||
) => updateField("status", value)}
|
||||
onValueChange={(value: "draft" | "sent" | "paid") =>
|
||||
updateField("status", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
|
||||
@@ -1,23 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { DatePicker } from "~/components/ui/date-picker";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import {
|
||||
Trash2,
|
||||
Plus,
|
||||
GripVertical,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
@@ -28,10 +13,24 @@ import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
GripVertical,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { DatePicker } from "~/components/ui/date-picker";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface InvoiceItem {
|
||||
id: string;
|
||||
|
||||
451
src/components/forms/send-email-dialog.tsx
Normal file
451
src/components/forms/send-email-dialog.tsx
Normal file
@@ -0,0 +1,451 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { EmailComposer } from "./email-composer";
|
||||
import { EmailPreview } from "./email-preview";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "~/trpc/react";
|
||||
import {
|
||||
Send,
|
||||
Loader2,
|
||||
Eye,
|
||||
Edit3,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
Mail,
|
||||
} from "lucide-react";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
|
||||
interface SendEmailDialogProps {
|
||||
invoiceId: string;
|
||||
trigger: React.ReactNode;
|
||||
invoice?: {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
issueDate: Date;
|
||||
dueDate: Date;
|
||||
status: string;
|
||||
taxRate: number;
|
||||
client?: {
|
||||
name: string;
|
||||
email: string | null;
|
||||
};
|
||||
business?: {
|
||||
name: string;
|
||||
email: string | null;
|
||||
};
|
||||
items?: Array<{
|
||||
id: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
}>;
|
||||
};
|
||||
onEmailSent?: () => void;
|
||||
}
|
||||
|
||||
export function SendEmailDialog({
|
||||
invoiceId,
|
||||
trigger,
|
||||
invoice,
|
||||
onEmailSent,
|
||||
}: SendEmailDialogProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("compose");
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
|
||||
// Email content state
|
||||
const [subject, setSubject] = useState(() =>
|
||||
invoice
|
||||
? `Invoice ${invoice.invoiceNumber} from ${invoice.business?.name ?? "Your Business"}`
|
||||
: "Invoice from Your Business",
|
||||
);
|
||||
const [ccEmail, setCcEmail] = useState("");
|
||||
const [bccEmail, setBccEmail] = useState("");
|
||||
const [customMessage, setCustomMessage] = useState("");
|
||||
|
||||
const [emailContent, setEmailContent] = useState(() => {
|
||||
const getTimeOfDayGreeting = () => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return "Good morning";
|
||||
if (hour < 17) return "Good afternoon";
|
||||
return "Good evening";
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(new Date(date));
|
||||
};
|
||||
|
||||
if (!invoice) return "";
|
||||
|
||||
const businessName = invoice.business?.name ?? "Your Business";
|
||||
|
||||
const issueDate = formatDate(invoice.issueDate);
|
||||
|
||||
// Calculate total from items
|
||||
const subtotal =
|
||||
invoice.items?.reduce((sum, item) => sum + item.hours * item.rate, 0) ??
|
||||
0;
|
||||
const taxAmount = subtotal * (invoice.taxRate / 100);
|
||||
const total = subtotal + taxAmount;
|
||||
|
||||
return `<p>${getTimeOfDayGreeting()},</p>
|
||||
|
||||
<p>I hope this email finds you well. Please find attached invoice <strong>${invoice.invoiceNumber}</strong> dated ${issueDate}.</p>
|
||||
|
||||
<p>The invoice details are as follows:</p>
|
||||
<ul>
|
||||
<li><strong>Invoice Number:</strong> ${invoice.invoiceNumber}</li>
|
||||
<li><strong>Issue Date:</strong> ${issueDate}</li>
|
||||
<li><strong>Amount Due:</strong> ${new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(total)}</li>
|
||||
</ul>
|
||||
|
||||
<p>Please let me know if you have any questions or need any clarification regarding this invoice. I appreciate your prompt attention to this matter.</p>
|
||||
|
||||
<p>Thank you for your business!</p>
|
||||
|
||||
<p>Best regards,<br><strong>${businessName}</strong></p>`;
|
||||
});
|
||||
|
||||
// Get utils for cache invalidation
|
||||
const utils = api.useUtils();
|
||||
|
||||
// Email sending mutation
|
||||
const sendEmailMutation = api.email.sendInvoice.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success("Email sent successfully!", {
|
||||
description: data.message,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
// Reset state and close dialog
|
||||
setIsOpen(false);
|
||||
setActiveTab("compose");
|
||||
setIsSending(false);
|
||||
setIsConfirming(false);
|
||||
|
||||
// Refresh invoice data
|
||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
||||
|
||||
// Callback for parent component
|
||||
onEmailSent?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Email send error:", error);
|
||||
|
||||
let errorMessage = "Failed to send invoice email";
|
||||
let errorDescription = error.message;
|
||||
|
||||
if (error.message.includes("Invalid recipient")) {
|
||||
errorMessage = "Invalid Email Address";
|
||||
errorDescription =
|
||||
"Please check the client's email address and try again.";
|
||||
} else if (error.message.includes("domain not verified")) {
|
||||
errorMessage = "Email Configuration Issue";
|
||||
errorDescription = "Please contact support to configure email sending.";
|
||||
} else if (error.message.includes("rate limit")) {
|
||||
errorMessage = "Too Many Emails";
|
||||
errorDescription = "Please wait a moment before sending another email.";
|
||||
} else if (error.message.includes("no email address")) {
|
||||
errorMessage = "No Email Address";
|
||||
errorDescription = "This client doesn't have an email address on file.";
|
||||
}
|
||||
|
||||
toast.error(errorMessage, {
|
||||
description: errorDescription,
|
||||
duration: 6000,
|
||||
});
|
||||
|
||||
setIsSending(false);
|
||||
setIsConfirming(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSendEmail = async () => {
|
||||
if (!invoice?.client?.email || invoice.client.email.trim() === "") {
|
||||
toast.error("No email address", {
|
||||
description: "This client doesn't have an email address on file.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!subject.trim()) {
|
||||
toast.error("Subject required", {
|
||||
description: "Please enter an email subject before sending.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!emailContent.trim()) {
|
||||
toast.error("Message required", {
|
||||
description: "Please enter an email message before sending.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSending(true);
|
||||
|
||||
try {
|
||||
// Use the enhanced API with custom subject and content
|
||||
await sendEmailMutation.mutateAsync({
|
||||
invoiceId,
|
||||
customSubject: subject,
|
||||
customContent: emailContent,
|
||||
customMessage: customMessage.trim() || undefined,
|
||||
useHtml: true,
|
||||
ccEmails: ccEmail.trim() || undefined,
|
||||
bccEmails: bccEmail.trim() || undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
// Error handling is done in the mutation's onError
|
||||
console.error("Send email error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmSend = () => {
|
||||
setIsConfirming(true);
|
||||
setActiveTab("confirm");
|
||||
};
|
||||
|
||||
const fromEmail = invoice?.business?.email ?? "noreply@yourdomain.com";
|
||||
const toEmail = invoice?.client?.email ?? "";
|
||||
|
||||
const canSend =
|
||||
!isSending &&
|
||||
subject.trim() &&
|
||||
emailContent.trim() &&
|
||||
toEmail &&
|
||||
toEmail.trim() !== "";
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
|
||||
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5 text-green-600" />
|
||||
Send Invoice Email
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Compose and preview your invoice email before sending to{" "}
|
||||
{invoice?.client?.name ?? "client"}.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Warning for missing email */}
|
||||
{(!toEmail || toEmail.trim() === "") && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
This client doesn't have an email address. Please add an
|
||||
email address to the client before sending the invoice.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Branded Template Info */}
|
||||
<Alert>
|
||||
<Mail className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<strong>Professional Email Template:</strong> Your email will be
|
||||
sent using a beautifully designed, beenvoice-branded template with
|
||||
proper fonts and styling. Any custom content you add will be
|
||||
incorporated into the professional template automatically.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="min-h-0 flex-1"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="compose" className="flex items-center gap-2">
|
||||
<Edit3 className="h-4 w-4" />
|
||||
Compose
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="preview" className="flex items-center gap-2">
|
||||
<Eye className="h-4 w-4" />
|
||||
Preview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="confirm"
|
||||
className="flex items-center gap-2"
|
||||
disabled={!isConfirming}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
Confirm
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<TabsContent
|
||||
value="compose"
|
||||
className="mt-4 h-full overflow-y-auto"
|
||||
>
|
||||
<EmailComposer
|
||||
subject={subject}
|
||||
onSubjectChange={setSubject}
|
||||
content={emailContent}
|
||||
onContentChange={setEmailContent}
|
||||
customMessage={customMessage}
|
||||
onCustomMessageChange={setCustomMessage}
|
||||
fromEmail={fromEmail}
|
||||
toEmail={toEmail}
|
||||
ccEmail={ccEmail}
|
||||
onCcEmailChange={setCcEmail}
|
||||
bccEmail={bccEmail}
|
||||
onBccEmailChange={setBccEmail}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="preview"
|
||||
className="mt-4 h-full overflow-y-auto"
|
||||
>
|
||||
<EmailPreview
|
||||
subject={subject}
|
||||
fromEmail={fromEmail}
|
||||
toEmail={toEmail}
|
||||
ccEmail={ccEmail}
|
||||
bccEmail={bccEmail}
|
||||
content={emailContent}
|
||||
customMessage={customMessage}
|
||||
invoice={invoice}
|
||||
className="pr-2"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="confirm"
|
||||
className="mt-4 h-full overflow-y-auto"
|
||||
>
|
||||
<div className="space-y-6 pr-2">
|
||||
<Alert>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
You're about to send this email to{" "}
|
||||
<strong>{toEmail}</strong>. The invoice PDF will be
|
||||
automatically attached.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<EmailPreview
|
||||
subject={subject}
|
||||
fromEmail={fromEmail}
|
||||
toEmail={toEmail}
|
||||
content={emailContent}
|
||||
customMessage={customMessage}
|
||||
invoice={invoice}
|
||||
/>
|
||||
|
||||
{invoice?.status === "draft" && (
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
This invoice is currently in <strong>draft</strong>{" "}
|
||||
status. Sending it will automatically change the status to{" "}
|
||||
<strong>sent</strong>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{activeTab === "compose" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setActiveTab("preview")}
|
||||
disabled={isSending}
|
||||
>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Preview
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{activeTab === "preview" && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setActiveTab("compose")}
|
||||
disabled={isSending}
|
||||
>
|
||||
<Edit3 className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmSend}
|
||||
disabled={!canSend}
|
||||
variant="default"
|
||||
>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Review & Send
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === "confirm" && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setActiveTab("preview")}
|
||||
disabled={isSending}
|
||||
>
|
||||
Back to Preview
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSendEmail}
|
||||
disabled={!canSend || isSending}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
{isSending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
Send Email
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setIsOpen(false)}
|
||||
disabled={isSending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,18 @@
|
||||
"use client";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
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";
|
||||
import { api } from "~/trpc/react";
|
||||
import { FileText, Edit } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
|
||||
export function Navbar() {
|
||||
const { data: session, status } = useSession();
|
||||
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false);
|
||||
|
||||
|
||||
// Get current open invoice for quick access
|
||||
const { data: currentInvoice } = api.invoices.getCurrentOpen.useQuery();
|
||||
// const { data: currentInvoice } = api.invoices.getCurrentOpen.useQuery();
|
||||
|
||||
return (
|
||||
<header className="fixed top-2 right-2 left-2 z-30 md:top-3 md:right-3 md:left-3">
|
||||
@@ -30,7 +28,6 @@ export function Navbar() {
|
||||
</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" />
|
||||
|
||||
@@ -22,11 +22,11 @@ export function PageHeader({
|
||||
|
||||
switch (variant) {
|
||||
case "gradient":
|
||||
return `${baseClasses} text-3xl text-brand-gradient`;
|
||||
return `${baseClasses} text-3xl text-foreground`;
|
||||
case "large":
|
||||
return `${baseClasses} text-4xl text-foreground`;
|
||||
case "large-gradient":
|
||||
return `${baseClasses} text-4xl text-brand-gradient`;
|
||||
return `${baseClasses} text-4xl text-foreground`;
|
||||
default:
|
||||
return `${baseClasses} text-3xl text-foreground`;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React from "react";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
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";
|
||||
import { getRouteLabel } from "~/lib/pluralize";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
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(
|
||||
@@ -40,7 +40,7 @@ export function DashboardBreadcrumbs() {
|
||||
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'
|
||||
// const action = segments[3]; // e.g., 'edit'
|
||||
|
||||
// Fetch client data if needed
|
||||
const { data: client, isLoading: clientLoading } =
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"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 { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import { navigationConfig } from "~/lib/navigation";
|
||||
|
||||
interface SidebarTriggerProps {
|
||||
|
||||
59
src/components/ui/alert.tsx
Normal file
59
src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -252,7 +252,7 @@ function CalendarDayButton({
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const _defaultClassNames = getDefaultClassNames();
|
||||
// const _defaultClassNames = getDefaultClassNames();
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
import * as React from "react";
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
@@ -15,17 +15,17 @@ function Progress({
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
export { Progress };
|
||||
|
||||
Reference in New Issue
Block a user