feat: invoice generator

This commit is contained in:
Vasily Zubarev
2025-05-07 14:53:13 +02:00
parent 287abbb219
commit 8b5a2e8056
59 changed files with 2606 additions and 124 deletions

View File

@@ -12,7 +12,7 @@ export type AnalyzeAttachment = {
}
export const loadAttachmentsForAI = async (user: User, file: File): Promise<AnalyzeAttachment[]> => {
const fullFilePath = await fullPathForFile(user, file)
const fullFilePath = fullPathForFile(user, file)
const isFileExists = await fileExists(fullFilePath)
if (!isFileExists) {
throw new Error("File not found on disk")

27
app/(app)/apps/common.ts Normal file
View File

@@ -0,0 +1,27 @@
import fs from "fs/promises"
import path from "path"
export type AppManifest = {
name: string
description: string
icon: string
}
export async function getApps(): Promise<{ id: string; manifest: AppManifest }[]> {
const appsDir = path.join(process.cwd(), "app/(app)/apps")
const items = await fs.readdir(appsDir, { withFileTypes: true })
const apps = await Promise.all(
items
.filter((item) => item.isDirectory() && item.name !== "apps")
.map(async (item) => {
const manifestModule = await import(`./${item.name}/manifest`)
return {
id: item.name,
manifest: manifestModule.manifest as AppManifest,
}
})
)
return apps
}

View File

@@ -0,0 +1,126 @@
"use server"
import { getCurrentUser, isSubscriptionExpired } from "@/lib/auth"
import {
getTransactionFileUploadPath,
getUserUploadsDirectory,
isEnoughStorageToUploadFile,
safePathJoin,
} from "@/lib/files"
import { getAppData, setAppData } from "@/models/apps"
import { createFile } from "@/models/files"
import { createTransaction, updateTransactionFiles } from "@/models/transactions"
import { Transaction, User } from "@/prisma/client"
import { renderToBuffer } from "@react-pdf/renderer"
import { randomUUID } from "crypto"
import { mkdir, writeFile } from "fs/promises"
import { revalidatePath } from "next/cache"
import path from "path"
import { createElement } from "react"
import { InvoiceFormData } from "./components/invoice-page"
import { InvoicePDF } from "./components/invoice-pdf"
import { InvoiceTemplate } from "./default-templates"
import { InvoiceAppData } from "./page"
export async function generateInvoicePDF(data: InvoiceFormData): Promise<Uint8Array> {
const pdfElement = createElement(InvoicePDF, { data })
const buffer = await renderToBuffer(pdfElement as any)
return new Uint8Array(buffer)
}
export async function addNewTemplateAction(user: User, template: InvoiceTemplate) {
const appData = (await getAppData(user, "invoices")) as InvoiceAppData | null
const updatedTemplates = [...(appData?.templates || []), template]
const appDataResult = await setAppData(user, "invoices", { ...appData, templates: updatedTemplates })
return { success: true, data: appDataResult }
}
export async function deleteTemplateAction(user: User, templateId: string) {
const appData = (await getAppData(user, "invoices")) as InvoiceAppData | null
if (!appData) return { success: false, error: "No app data found" }
const updatedTemplates = appData.templates.filter((t) => t.id !== templateId)
const appDataResult = await setAppData(user, "invoices", { ...appData, templates: updatedTemplates })
return { success: true, data: appDataResult }
}
export async function saveInvoiceAsTransactionAction(
formData: InvoiceFormData
): Promise<{ success: boolean; error?: string; data?: Transaction }> {
try {
const user = await getCurrentUser()
// Generate PDF
const pdfBuffer = await generateInvoicePDF(formData)
// Calculate total amount from items
const subtotal = formData.items.reduce((sum, item) => sum + item.subtotal, 0)
const taxes = formData.additionalTaxes.reduce((sum, tax) => sum + tax.amount, 0)
const fees = formData.additionalFees.reduce((sum, fee) => sum + fee.amount, 0)
const totalAmount = (formData.taxIncluded ? subtotal : subtotal + taxes) + fees
// Create transaction
const transaction = await createTransaction(user.id, {
name: `Invoice #${formData.invoiceNumber || "unknown"}`,
merchant: `${formData.billTo.split("\n")[0]}`,
total: totalAmount * 100,
currencyCode: formData.currency,
issuedAt: new Date(formData.date),
categoryCode: null,
projectCode: null,
type: "income",
status: "pending",
})
// Check storage limits
if (!isEnoughStorageToUploadFile(user, pdfBuffer.length)) {
return {
success: false,
error: "Insufficient storage to save invoice PDF",
}
}
if (isSubscriptionExpired(user)) {
return {
success: false,
error: "Your subscription has expired, please upgrade your account or buy new subscription plan",
}
}
// Save PDF file
const fileUuid = randomUUID()
const fileName = `invoice-${formData.invoiceNumber}.pdf`
const relativeFilePath = getTransactionFileUploadPath(fileUuid, fileName, transaction)
const userUploadsDirectory = getUserUploadsDirectory(user)
const fullFilePath = safePathJoin(userUploadsDirectory, relativeFilePath)
await mkdir(path.dirname(fullFilePath), { recursive: true })
await writeFile(fullFilePath, pdfBuffer)
// Create file record in database
const fileRecord = await createFile(user.id, {
id: fileUuid,
filename: fileName,
path: relativeFilePath,
mimetype: "application/pdf",
isReviewed: true,
metadata: {
size: pdfBuffer.length,
lastModified: Date.now(),
},
})
// Update transaction with the file ID
await updateTransactionFiles(transaction.id, user.id, [fileRecord.id])
revalidatePath("/transactions")
return { success: true, data: transaction }
} catch (error) {
console.error("Failed to save invoice as transaction:", error)
return {
success: false,
error: `Failed to save invoice as transaction: ${error}`,
}
}
}

View File

@@ -0,0 +1,278 @@
"use client"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { fetchAsBase64 } from "@/lib/utils"
import { SettingsMap } from "@/models/settings"
import { Currency, User } from "@/prisma/client"
import { FileDown, Save, TextSelect, X } from "lucide-react"
import { useRouter } from "next/navigation"
import { startTransition, useMemo, useReducer, useState } from "react"
import {
addNewTemplateAction,
deleteTemplateAction,
generateInvoicePDF,
saveInvoiceAsTransactionAction,
} from "../actions"
import defaultTemplates, { InvoiceTemplate } from "../default-templates"
import { InvoiceAppData } from "../page"
import { InvoiceFormData, InvoicePage } from "./invoice-page"
function invoiceFormReducer(state: InvoiceFormData, action: any): InvoiceFormData {
switch (action.type) {
case "SET_FORM":
return action.payload
case "UPDATE_FIELD":
return { ...state, [action.field]: action.value }
case "ADD_ITEM":
return { ...state, items: [...state.items, { description: "", quantity: 1, unitPrice: 0, subtotal: 0 }] }
case "UPDATE_ITEM": {
const items = [...state.items]
items[action.index] = { ...items[action.index], [action.field]: action.value }
if (action.field === "quantity" || action.field === "unitPrice") {
items[action.index].subtotal = Number(items[action.index].quantity) * Number(items[action.index].unitPrice)
}
return { ...state, items }
}
case "REMOVE_ITEM":
return { ...state, items: state.items.filter((_, i) => i !== action.index) }
case "ADD_TAX":
return { ...state, additionalTaxes: [...state.additionalTaxes, { name: "", rate: 0, amount: 0 }] }
case "UPDATE_TAX": {
const taxes = [...state.additionalTaxes]
taxes[action.index] = { ...taxes[action.index], [action.field]: action.value }
if (action.field === "rate") {
const subtotal = state.items.reduce((sum, item) => sum + item.subtotal, 0)
taxes[action.index].amount = (subtotal * Number(action.value)) / 100
}
return { ...state, additionalTaxes: taxes }
}
case "REMOVE_TAX":
return { ...state, additionalTaxes: state.additionalTaxes.filter((_, i) => i !== action.index) }
case "ADD_FEE":
return { ...state, additionalFees: [...state.additionalFees, { name: "", amount: 0 }] }
case "UPDATE_FEE": {
const fees = [...state.additionalFees]
fees[action.index] = { ...fees[action.index], [action.field]: action.value }
return { ...state, additionalFees: fees }
}
case "REMOVE_FEE":
return { ...state, additionalFees: state.additionalFees.filter((_, i) => i !== action.index) }
default:
return state
}
}
export function InvoiceGenerator({
user,
settings,
currencies,
appData,
}: {
user: User
settings: SettingsMap
currencies: Currency[]
appData: InvoiceAppData | null
}) {
const templates: InvoiceTemplate[] = useMemo(
() => [...defaultTemplates(user, settings), ...(appData?.templates || [])],
[appData]
)
const [selectedTemplate, setSelectedTemplate] = useState<string>(templates[0].name)
const [isTemplateDialogOpen, setIsTemplateDialogOpen] = useState(false)
const [newTemplateName, setNewTemplateName] = useState("")
const [formData, dispatch] = useReducer(invoiceFormReducer, templates[0].formData)
const router = useRouter()
// Function to handle template selection
const handleTemplateSelect = (templateName: string) => {
const template = templates.find((t) => t.name === templateName)
if (template) {
setSelectedTemplate(templateName)
dispatch({ type: "SET_FORM", payload: template.formData })
}
}
const handleGeneratePDF = async (e: React.FormEvent) => {
e.preventDefault()
if (formData.businessLogo) {
formData.businessLogo = await fetchAsBase64(formData.businessLogo)
}
try {
const pdfBuffer = await generateInvoicePDF(formData)
// Create a blob from the buffer
const blob = new Blob([pdfBuffer], { type: "application/pdf" })
// Create a URL for the blob
const url = URL.createObjectURL(blob)
// Create a temporary link element
const link = document.createElement("a")
link.href = url
link.download = `invoice-${formData.invoiceNumber}.pdf`
// Append the link to the document, click it, and remove it
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
// Clean up the URL
URL.revokeObjectURL(url)
} catch (error) {
console.error("Error generating PDF:", error)
alert("Failed to generate PDF. Please try again.")
}
}
const handleSaveTemplate = async () => {
if (!newTemplateName.trim()) {
alert("Please enter a template name")
return
}
if (templates.some((t) => t.name === newTemplateName)) {
alert("A template with this name already exists")
return
}
try {
// Get existing templates
const result = await addNewTemplateAction(user, {
id: `tmpl_${Math.random().toString(36).substring(2, 15)}`,
name: newTemplateName,
formData: formData,
})
if (result.success) {
setIsTemplateDialogOpen(false)
setNewTemplateName("")
router.refresh()
} else {
alert("Failed to save template. Please try again.")
}
} catch (error) {
console.error("Error saving template:", error)
alert("Failed to save template. Please try again.")
}
}
const handleDeleteTemplate = async (templateId: string | undefined, e: React.MouseEvent) => {
e.stopPropagation()
if (!templateId) return // Don't allow deleting default templates
try {
const result = await deleteTemplateAction(user, templateId)
if (result.success) {
router.refresh()
}
} catch (error) {
console.error("Error deleting template:", error)
alert("Failed to delete template. Please try again.")
}
}
// Accept optional event, prevent default only if present
const handleSaveAsTransaction = async (e?: React.FormEvent) => {
if (e) e.preventDefault()
if (formData.businessLogo) {
formData.businessLogo = await fetchAsBase64(formData.businessLogo)
}
try {
const result = await saveInvoiceAsTransactionAction(formData)
if (result.success && result.data?.id) {
console.log("SUCCESS! REDIRECTING TO TRANSACTION", result.data?.id)
startTransition(() => {
router.push(`/transactions/${result.data?.id}`)
})
} else {
alert(result.error || "Failed to save as transaction")
}
} catch (error) {
console.error("Error saving as transaction:", error)
alert("Failed to save as transaction. Please try again.")
}
}
return (
<div className="flex flex-col gap-6">
{/* Templates Section */}
<div className="py-4 flex overflow-x-auto gap-2">
{templates.map((template) => (
<div key={template.name} className="relative group">
<Button
variant={selectedTemplate === template.name ? "default" : "outline"}
className={`
whitespace-nowrap p-4
${selectedTemplate === template.name ? "bg-black hover:bg-gray-900" : "border-gray-300 text-gray-700 hover:bg-gray-100"}
`}
onClick={() => handleTemplateSelect(template.name)}
>
{template.name}
</Button>
{template.id && (
<Button
variant="destructive"
size="icon"
className="absolute -top-2 -right-2 h-5 w-5 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => handleDeleteTemplate(template.id, e)}
>
<X className="h-3 w-3" />
</Button>
)}
</div>
))}
</div>
<div className="flex flex-row flex-wrap justify-start items-start gap-4">
<InvoicePage invoiceData={formData} dispatch={dispatch} currencies={currencies} />
{/* Generate PDF Button */}
<div className="flex flex-col gap-4">
<Button onClick={handleGeneratePDF}>
<FileDown />
Download PDF
</Button>
<Button variant="secondary" onClick={() => setIsTemplateDialogOpen(true)}>
<TextSelect />
Make a Template
</Button>
<Button variant="secondary" onClick={handleSaveAsTransaction}>
<Save />
Save as Transaction
</Button>
</div>
</div>
{/* New Template Dialog */}
<Dialog open={isTemplateDialogOpen} onOpenChange={setIsTemplateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Save as Template</DialogTitle>
</DialogHeader>
<div className="py-4">
<input
type="text"
value={newTemplateName}
onChange={(e) => setNewTemplateName(e.target.value)}
placeholder="Enter template name"
className="w-full px-3 py-2 border rounded-md"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsTemplateDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSaveTemplate}>Save Template</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,584 @@
import { FormSelectCurrency } from "@/components/forms/select-currency"
import { FormAvatar, FormInput, FormTextarea } from "@/components/forms/simple"
import { Button } from "@/components/ui/button"
import { formatCurrency } from "@/lib/utils"
import { Currency } from "@/prisma/client"
import { X } from "lucide-react"
import { InputHTMLAttributes, memo, useCallback, useMemo } from "react"
export interface InvoiceItem {
description: string
quantity: number
unitPrice: number
subtotal: number
}
export interface AdditionalTax {
name: string
rate: number
amount: number
}
export interface AdditionalFee {
name: string
amount: number
}
export interface InvoiceFormData {
title: string
businessLogo: string | null
invoiceNumber: string
date: string
dueDate: string
currency: string
companyDetails: string
companyDetailsLabel: string
billTo: string
billToLabel: string
items: InvoiceItem[]
taxIncluded: boolean
additionalTaxes: AdditionalTax[]
additionalFees: AdditionalFee[]
notes: string
bankDetails: string
issueDateLabel: string
dueDateLabel: string
itemLabel: string
quantityLabel: string
unitPriceLabel: string
subtotalLabel: string
summarySubtotalLabel: string
summaryTotalLabel: string
}
interface InvoicePageProps {
invoiceData: InvoiceFormData
dispatch: React.Dispatch<any>
currencies: Currency[]
}
// Memoized row for invoice items
const ItemRow = memo(function ItemRow({
item,
index,
onChange,
onRemove,
currency,
}: {
item: InvoiceItem
index: number
onChange: (index: number, field: keyof InvoiceItem, value: string | number) => void
onRemove: (index: number) => void
currency: string
}) {
return (
<tr>
<td className="px-4 py-2">
<FormInput
type="text"
value={item.description}
onChange={(e) => onChange(index, "description", e.target.value)}
className="w-full min-w-64"
placeholder="Item description"
required
/>
</td>
<td className="px-4 py-2">
<FormInput
type="number"
min="1"
value={item.quantity}
onChange={(e) => onChange(index, "quantity", Number(e.target.value))}
className="w-20 text-right"
required
/>
</td>
<td className="px-4 py-2">
<FormInput
type="number"
step="0.01"
value={item.unitPrice}
onChange={(e) => onChange(index, "unitPrice", Number(e.target.value))}
className="w-24 text-right"
required
/>
</td>
<td className="px-4 py-2 text-right">{formatCurrency(item.subtotal * 100, currency)}</td>
<td className="px-4 py-2">
<Button variant="destructive" className="rounded-full p-1 h-5 w-5" onClick={() => onRemove(index)}>
<X />
</Button>
</td>
</tr>
)
})
// Memoized row for additional taxes
const TaxRow = memo(function TaxRow({
tax,
index,
onChange,
onRemove,
currency,
}: {
tax: AdditionalTax
index: number
onChange: (index: number, field: keyof AdditionalTax, value: string | number) => void
onRemove: (index: number) => void
currency: string
}) {
return (
<div className="flex justify-between items-center">
<div className="w-full flex flex-row gap-2 items-center">
<Button variant="destructive" className="rounded-full p-1 h-5 w-5" onClick={() => onRemove(index)}>
<X />
</Button>
<FormInput
type="text"
value={tax.name}
onChange={(e) => onChange(index, "name", e.target.value)}
placeholder="Tax name"
/>
<FormInput
type="number"
max="100"
value={tax.rate}
onChange={(e) => onChange(index, "rate", Number(e.target.value))}
className="w-12 text-right"
/>
<span className="text-sm text-gray-600">%</span>
<span className="text-sm text-nowrap">{formatCurrency(tax.amount * 100, currency)}</span>
</div>
</div>
)
})
// Memoized row for additional fees
const FeeRow = memo(function FeeRow({
fee,
index,
onChange,
onRemove,
currency,
}: {
fee: AdditionalFee
index: number
onChange: (index: number, field: keyof AdditionalFee, value: string | number) => void
onRemove: (index: number) => void
currency: string
}) {
return (
<div className="w-full flex justify-between items-center">
<div className="w-full flex flex-row gap-2 items-center justify-between">
<Button variant="destructive" className="rounded-full p-1 h-5 w-5" onClick={() => onRemove(index)}>
<X />
</Button>
<FormInput
type="text"
value={fee.name}
onChange={(e) => onChange(index, "name", e.target.value)}
placeholder="Fee or discount name"
/>
<FormInput
type="number"
step="0.01"
value={fee.amount}
onChange={(e) => onChange(index, "amount", Number(e.target.value))}
className="w-16 text-right"
/>
<span className="text-sm text-nowrap">{formatCurrency(fee.amount * 100, currency)}</span>
</div>
</div>
)
})
export function InvoicePage({ invoiceData, dispatch, currencies }: InvoicePageProps) {
const addItem = useCallback(() => dispatch({ type: "ADD_ITEM" }), [dispatch])
const removeItem = useCallback((index: number) => dispatch({ type: "REMOVE_ITEM", index }), [dispatch])
const updateItem = useCallback(
(index: number, field: keyof InvoiceItem, value: string | number) =>
dispatch({ type: "UPDATE_ITEM", index, field, value }),
[dispatch]
)
const addAdditionalTax = useCallback(() => dispatch({ type: "ADD_TAX" }), [dispatch])
const removeAdditionalTax = useCallback((index: number) => dispatch({ type: "REMOVE_TAX", index }), [dispatch])
const updateAdditionalTax = useCallback(
(index: number, field: keyof AdditionalTax, value: string | number) =>
dispatch({ type: "UPDATE_TAX", index, field, value }),
[dispatch]
)
const addAdditionalFee = useCallback(() => dispatch({ type: "ADD_FEE" }), [dispatch])
const removeAdditionalFee = useCallback((index: number) => dispatch({ type: "REMOVE_FEE", index }), [dispatch])
const updateAdditionalFee = useCallback(
(index: number, field: keyof AdditionalFee, value: string | number) =>
dispatch({ type: "UPDATE_FEE", index, field, value }),
[dispatch]
)
const subtotal = useMemo(() => invoiceData.items.reduce((sum, item) => sum + item.subtotal, 0), [invoiceData.items])
const taxes = useMemo(
() => invoiceData.additionalTaxes.reduce((sum, tax) => sum + tax.amount, 0),
[invoiceData.additionalTaxes]
)
const fees = useMemo(
() => invoiceData.additionalFees.reduce((sum, fee) => sum + fee.amount, 0),
[invoiceData.additionalFees]
)
const total = useMemo(
() => (invoiceData.taxIncluded ? subtotal : subtotal + taxes) + fees,
[invoiceData.taxIncluded, subtotal, taxes, fees]
)
return (
<div className="relative w-full max-w-[794px] sm:w-[794px] min-h-[297mm] bg-white shadow-lg p-2 sm:p-8 mb-8">
{/* Gradient Background */}
<div className="absolute top-0 left-0 right-0 h-[25%] bg-gradient-to-b from-indigo-100 to-indigo-0 opacity-70" />
{/* Invoice Header */}
<div className="flex flex-col sm:flex-row gap-4 sm:gap-8 justify-between items-start mb-8 relative">
<div className="w-full flex flex-col space-y-2">
<ShadyFormInput
type="text"
value={invoiceData.title}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "title", value: e.target.value })}
className="text-2xl sm:text-4xl font-extrabold"
placeholder="INVOICE"
required
/>
<FormInput
placeholder="Invoice ID or subtitle"
value={invoiceData.invoiceNumber}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "invoiceNumber", value: e.target.value })}
className="w-full sm:w-[200px] font-medium"
/>
</div>
<div className="flex flex-row items-center justify-end mt-4 sm:mt-0">
<FormAvatar
name="businessLogo"
className="w-[60px] h-[60px] sm:w-[100px] sm:h-[100px]"
defaultValue={invoiceData.businessLogo || ""}
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
const objectUrl = URL.createObjectURL(file)
dispatch({ type: "UPDATE_FIELD", field: "businessLogo", value: objectUrl })
} else {
dispatch({ type: "UPDATE_FIELD", field: "businessLogo", value: null })
}
}}
/>
</div>
</div>
{/* Company and Bill To */}
<div className="relative grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-8 mb-8">
<div className="flex flex-col gap-1">
<ShadyFormInput
type="text"
value={invoiceData.companyDetailsLabel}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "companyDetailsLabel", value: e.target.value })}
className="text-xs sm:text-sm font-medium"
/>
<FormTextarea
value={invoiceData.companyDetails}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "companyDetails", value: e.target.value })}
rows={4}
placeholder="Your Company Name, Address, City, State, ZIP, Country, Tax ID"
required
/>
</div>
<div className="flex flex-col gap-1">
<ShadyFormInput
type="text"
value={invoiceData.billToLabel}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "billToLabel", value: e.target.value })}
className="text-xs sm:text-sm font-medium"
/>
<FormTextarea
value={invoiceData.billTo}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "billTo", value: e.target.value })}
rows={4}
placeholder="Client Name, Address, City, State, ZIP, Country, Tax ID"
required
/>
</div>
</div>
<div className="relative flex flex-col sm:flex-row items-start sm:items-end justify-between mb-8 gap-4">
<div className="flex flex-row items-center gap-4 w-full sm:w-auto">
<div className="flex flex-col gap-1 w-full">
<ShadyFormInput
type="text"
value={invoiceData.issueDateLabel}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "issueDateLabel", value: e.target.value })}
className="text-xs sm:text-sm font-medium"
/>
<FormInput
type="date"
value={invoiceData.date}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "date", value: e.target.value })}
className="w-full border-b border-gray-300 py-1"
required
/>
</div>
<div className="flex flex-col gap-1 w-full">
<ShadyFormInput
type="text"
value={invoiceData.dueDateLabel}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "dueDateLabel", value: e.target.value })}
className="text-xs sm:text-sm font-medium"
/>
<FormInput
type="date"
value={invoiceData.dueDate}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "dueDate", value: e.target.value })}
required
/>
</div>
</div>
<div className="w-full sm:w-auto flex justify-end">
<FormSelectCurrency
currencies={currencies}
value={invoiceData.currency}
onValueChange={(value) => dispatch({ type: "UPDATE_FIELD", field: "currency", value })}
/>
</div>
</div>
{/* Items Table */}
<div className="mb-8">
<div className="border rounded-lg overflow-hidden">
{/* Table for desktop/tablet */}
<div className="overflow-x-auto sm:block hidden">
<table className="min-w-[600px] w-full divide-y divide-gray-200 text-xs sm:text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-2 sm:px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<ShadyFormInput
type="text"
value={invoiceData.itemLabel}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "itemLabel", value: e.target.value })}
className="text-xs font-medium text-gray-500 uppercase tracking-wider"
/>
</th>
<th className="px-2 sm:px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
<ShadyFormInput
type="text"
value={invoiceData.quantityLabel}
onChange={(e) =>
dispatch({ type: "UPDATE_FIELD", field: "quantityLabel", value: e.target.value })
}
className="text-xs font-medium text-gray-500 uppercase tracking-wider"
/>
</th>
<th className="px-2 sm:px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
<ShadyFormInput
type="text"
value={invoiceData.unitPriceLabel}
onChange={(e) =>
dispatch({ type: "UPDATE_FIELD", field: "unitPriceLabel", value: e.target.value })
}
className="text-xs font-medium text-gray-500 uppercase tracking-wider"
/>
</th>
<th className="px-2 sm:px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
<ShadyFormInput
type="text"
value={invoiceData.subtotalLabel}
onChange={(e) =>
dispatch({ type: "UPDATE_FIELD", field: "subtotalLabel", value: e.target.value })
}
className="text-xs font-medium text-gray-500 uppercase tracking-wider"
/>
</th>
<th className="px-2 sm:px-4 py-2"></th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{invoiceData.items.map((item, index) => (
<ItemRow
key={index}
item={item}
index={index}
onChange={updateItem}
onRemove={removeItem}
currency={invoiceData.currency}
/>
))}
</tbody>
</table>
</div>
{/* Flex list for mobile */}
<div className="sm:hidden flex flex-col gap-2 p-2">
{invoiceData.items.map((item, index) => (
<div key={index} className="flex flex-col gap-1 border rounded-lg p-3 bg-gray-50 relative">
<button
type="button"
className="absolute top-2 right-2 text-gray-400 hover:text-red-500"
onClick={() => removeItem(index)}
aria-label="Remove item"
>
<X size={18} />
</button>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500 font-medium">{invoiceData.itemLabel}</label>
<FormInput
type="text"
value={item.description}
onChange={(e) => updateItem(index, "description", e.target.value)}
className="w-full min-w-0"
placeholder="Item description"
required
/>
</div>
<div className="flex gap-2 mt-2">
<div className="flex-1 flex flex-col">
<label className="text-xs text-gray-500 font-medium">{invoiceData.quantityLabel}</label>
<FormInput
type="number"
min="1"
value={item.quantity}
onChange={(e) => updateItem(index, "quantity", Number(e.target.value))}
className="w-full text-right"
required
/>
</div>
<div className="flex-1 flex flex-col">
<label className="text-xs text-gray-500 font-medium">{invoiceData.unitPriceLabel}</label>
<FormInput
type="number"
step="0.01"
value={item.unitPrice}
onChange={(e) => updateItem(index, "unitPrice", Number(e.target.value))}
className="w-full text-right"
required
/>
</div>
<div className="flex-1 flex flex-col items-end">
<label className="text-xs text-gray-500 font-medium">{invoiceData.subtotalLabel}</label>
<span className="text-sm font-semibold mt-2">
{formatCurrency(item.subtotal * 100, invoiceData.currency)}
</span>
</div>
</div>
</div>
))}
</div>
<Button onClick={addItem} className="m-2 sm:m-3 w-full sm:w-auto">
+ Add Item
</Button>
</div>
</div>
{/* Notes */}
<div className="mb-8">
<FormTextarea
value={invoiceData.notes}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "notes", value: e.target.value })}
className="w-full border border-gray-300 rounded p-2 text-xs sm:text-sm"
rows={3}
placeholder="Additional notes or terms"
/>
</div>
{/* Summary */}
<div className="flex justify-end">
<div className="w-full sm:w-72 space-y-2">
<div className="flex justify-between">
<ShadyFormInput
type="text"
value={invoiceData.summarySubtotalLabel}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "summarySubtotalLabel", value: e.target.value })}
className="text-xs sm:text-sm font-medium text-gray-600"
/>
<span className="text-xs sm:text-sm">{formatCurrency(subtotal * 100, invoiceData.currency)}</span>
</div>
<div className="flex flex-col gap-2 items-start">
{/* Additional Taxes */}
{invoiceData.additionalTaxes.map((tax, index) => (
<TaxRow
key={index}
tax={tax}
index={index}
onChange={updateAdditionalTax}
onRemove={removeAdditionalTax}
currency={invoiceData.currency}
/>
))}
<div className="w-full flex justify-end">
<Button onClick={addAdditionalTax} className="w-full sm:w-auto">
+ Add Tax
</Button>
</div>
{invoiceData.additionalFees.map((fee, index) => (
<FeeRow
key={index}
fee={fee}
index={index}
onChange={updateAdditionalFee}
onRemove={removeAdditionalFee}
currency={invoiceData.currency}
/>
))}
<div className="w-full flex justify-end">
<Button onClick={addAdditionalFee} className="w-full sm:w-auto">
+ Add Fee or Discount
</Button>
</div>
</div>
<label className="flex items-center space-x-1">
<input
type="checkbox"
checked={invoiceData.taxIncluded}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "taxIncluded", value: e.target.checked })}
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
/>
<span className="text-gray-600 text-xs sm:text-sm">Taxes are included in price</span>
</label>
<div className="flex justify-between border-t pt-2">
<ShadyFormInput
type="text"
value={invoiceData.summaryTotalLabel}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "summaryTotalLabel", value: e.target.value })}
className="text-sm sm:text-md font-bold"
/>
<span className="text-sm sm:text-md font-bold text-nowrap">
{formatCurrency(total * 100, invoiceData.currency)}
</span>
</div>
</div>
</div>
{/* Bank Details Footer */}
<div className="mt-8 pt-8 border-t">
<textarea
value={invoiceData.bankDetails}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "bankDetails", value: e.target.value })}
className="text-center text-xs sm:text-sm text-muted-foreground w-full mx-auto border border-gray-300 rounded p-2"
rows={3}
placeholder="Bank and Payment Details: Account number, Bank name, IBAN, SWIFT/BIC, Your Email (optional)"
required
/>
</div>
</div>
)
}
function ShadyFormInput({ className = "", ...props }: { className?: string } & InputHTMLAttributes<HTMLInputElement>) {
return (
<input
className={`bg-transparent border border-transparent outline-none p-0 w-full hover:border-dashed hover:border-gray-200 hover:bg-gray-50 focus:bg-gray-50 hover:rounded-sm ${className}`}
{...props}
/>
)
}

View File

@@ -0,0 +1,403 @@
import { formatCurrency } from "@/lib/utils"
import { Document, Font, Image, Page, StyleSheet, Text, View } from "@react-pdf/renderer"
import { formatDate } from "date-fns"
import { ReactElement } from "react"
import { AdditionalFee, AdditionalTax, InvoiceFormData, InvoiceItem } from "./invoice-page"
Font.register({
family: "Inter",
fonts: [
{
src: "public/fonts/Inter/Inter-Regular.otf",
fontWeight: 400,
fontStyle: "normal",
},
{
src: "public/fonts/Inter/Inter-Medium.otf",
fontWeight: 500,
fontStyle: "normal",
},
{
src: "public/fonts/Inter/Inter-SemiBold.otf",
fontWeight: 600,
fontStyle: "normal",
},
{
src: "public/fonts/Inter/Inter-Bold.otf",
fontWeight: 700,
fontStyle: "normal",
},
{
src: "public/fonts/Inter/Inter-ExtraBold.otf",
fontWeight: 800,
fontStyle: "normal",
},
{
src: "public/fonts/Inter/Inter-Black.otf",
fontWeight: 900,
fontStyle: "normal",
},
{
src: "public/fonts/Inter-Italic.otf",
fontWeight: 400,
fontStyle: "italic",
},
{
src: "public/fonts/Inter/Inter-MediumItalic.otf",
fontWeight: 500,
fontStyle: "italic",
},
{
src: "public/fonts/Inter/Inter-SemiBoldItalic.otf",
fontWeight: 600,
fontStyle: "italic",
},
{
src: "public/fonts/Inter/Inter-BoldItalic.otf",
fontWeight: 700,
fontStyle: "italic",
},
{
src: "public/fonts/Inter/Inter-ExtraBoldItalic.otf",
fontWeight: 800,
fontStyle: "italic",
},
{
src: "public/fonts/Inter/Inter-BlackItalic.otf",
fontWeight: 900,
fontStyle: "italic",
},
],
})
Font.registerEmojiSource({
format: "png",
url: "https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/72x72/",
})
const styles = StyleSheet.create({
page: {
fontFamily: "Inter",
padding: 30,
backgroundColor: "#ffffff",
},
header: {
marginBottom: 30,
position: "relative",
},
gradientBackground: {
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "120px",
backgroundColor: "#eef2ff",
opacity: 0.8,
},
headerContent: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 30,
},
headerLeft: {
flex: 1,
},
headerRight: {
width: 110,
height: 60,
},
title: {
fontSize: 28,
marginBottom: 10,
fontWeight: "extrabold",
color: "#000000",
},
subtitle: {
fontSize: 13,
color: "#666666",
},
logo: {
width: "100%",
height: "100%",
objectFit: "contain",
},
companyDetails: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 30,
},
companySection: {
flex: 1,
marginRight: 30,
},
sectionLabel: {
fontSize: 12,
fontWeight: "bold",
marginBottom: 8,
color: "#000000",
},
sectionContent: {
fontSize: 12,
lineHeight: 1.3,
color: "#000000",
},
datesAndCurrency: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-end",
marginBottom: 30,
},
dateGroup: {
flexDirection: "row",
gap: 20,
},
dateItem: {
marginRight: 20,
},
dateLabel: {
fontSize: 12,
fontWeight: "bold",
marginBottom: 8,
color: "#000000",
},
dateValue: {
fontSize: 12,
color: "#000000",
},
itemsTable: {
marginBottom: 30,
},
tableHeader: {
flexDirection: "row",
borderBottomWidth: 1,
borderBottomColor: "#E5E7EB",
paddingVertical: 8,
backgroundColor: "#F9FAFB",
textTransform: "uppercase",
},
tableRow: {
flexDirection: "row",
borderBottomWidth: 1,
borderBottomColor: "#E5E7EB",
paddingVertical: 8,
},
colDescription: {
flex: 2,
paddingHorizontal: 10,
},
colQuantity: {
flex: 1,
paddingHorizontal: 10,
textAlign: "right",
},
colPrice: {
flex: 1,
paddingHorizontal: 10,
textAlign: "right",
},
colSubtotal: {
flex: 1,
paddingHorizontal: 10,
textAlign: "right",
},
colHeader: {
fontSize: 10,
fontWeight: "bold",
color: "#6B7280",
},
colValue: {
fontSize: 12,
color: "#000000",
},
notes: {
marginBottom: 30,
fontSize: 12,
},
notesLabel: {
fontWeight: "bold",
marginBottom: 5,
color: "#000000",
},
summary: {
width: "50%",
alignSelf: "flex-end",
},
summaryRow: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 5,
},
summaryLabel: {
fontSize: 12,
color: "#4B5563",
},
summaryValue: {
fontSize: 12,
color: "#000000",
},
taxRow: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 5,
},
totalRow: {
flexDirection: "row",
justifyContent: "space-between",
marginTop: 10,
paddingTop: 10,
borderTopWidth: 1,
borderTopColor: "#000000",
},
totalLabel: {
fontSize: 14,
fontWeight: "bold",
color: "#000000",
},
totalValue: {
fontSize: 14,
fontWeight: "bold",
color: "#000000",
},
bankDetails: {
marginTop: 30,
paddingTop: 20,
borderTopWidth: 1,
borderTopColor: "#E5E7EB",
textAlign: "center",
fontSize: 11,
color: "#6B7280",
},
})
export function InvoicePDF({ data }: { data: InvoiceFormData }): ReactElement {
const calculateSubtotal = (): number => {
return data.items.reduce((sum: number, item: InvoiceItem) => sum + item.subtotal, 0)
}
const calculateTaxes = (): number => {
return data.additionalTaxes.reduce((sum: number, tax: AdditionalTax) => sum + tax.amount, 0)
}
const calculateTotal = (): number => {
const subtotal = calculateSubtotal()
const taxes = calculateTaxes()
return data.taxIncluded ? subtotal : subtotal + taxes
}
return (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.gradientBackground} />
{/* Header */}
<View style={styles.header}>
<View style={styles.headerContent}>
<View style={styles.headerLeft}>
<Text style={styles.title}>{data.title}</Text>
<Text style={styles.subtitle}>{data.invoiceNumber}</Text>
</View>
{data.businessLogo && (
<View style={styles.headerRight}>
<Image src={data.businessLogo} style={styles.logo} />
</View>
)}
</View>
</View>
{/* Company Details and Bill To */}
<View style={styles.companyDetails}>
<View style={styles.companySection}>
<Text style={styles.sectionLabel}>{data.companyDetailsLabel}</Text>
<Text style={styles.sectionContent}>{data.companyDetails}</Text>
</View>
<View style={styles.companySection}>
<Text style={styles.sectionLabel}>{data.billToLabel}</Text>
<Text style={styles.sectionContent}>{data.billTo}</Text>
</View>
</View>
{/* Dates and Currency */}
<View style={styles.datesAndCurrency}>
<View style={styles.dateGroup}>
<View style={styles.dateItem}>
<Text style={styles.dateLabel}>{data.issueDateLabel}</Text>
<Text style={styles.dateValue}>{formatDate(data.date, "yyyy-MM-dd")}</Text>
</View>
<View style={styles.dateItem}>
<Text style={styles.dateLabel}>{data.dueDateLabel}</Text>
<Text style={styles.dateValue}>{formatDate(data.dueDate, "yyyy-MM-dd")}</Text>
</View>
</View>
</View>
{/* Items Table */}
<View style={styles.itemsTable}>
<View style={styles.tableHeader}>
<Text style={[styles.colHeader, styles.colDescription]}>{data.itemLabel}</Text>
<Text style={[styles.colHeader, styles.colQuantity]}>{data.quantityLabel}</Text>
<Text style={[styles.colHeader, styles.colPrice]}>{data.unitPriceLabel}</Text>
<Text style={[styles.colHeader, styles.colSubtotal]}>{data.subtotalLabel}</Text>
</View>
{data.items.map((item: InvoiceItem, index: number) => (
<View key={index} style={styles.tableRow}>
<Text style={[styles.colValue, styles.colDescription]}>{item.description}</Text>
<Text style={[styles.colValue, styles.colQuantity]}>{item.quantity}</Text>
<Text style={[styles.colValue, styles.colPrice]}>
{formatCurrency(item.unitPrice * 100, data.currency)}
</Text>
<Text style={[styles.colValue, styles.colSubtotal]}>
{formatCurrency(item.subtotal * 100, data.currency)}
</Text>
</View>
))}
</View>
{/* Notes */}
{data.notes && (
<View style={styles.notes}>
<Text style={styles.notesLabel}>Notes:</Text>
<Text>{data.notes}</Text>
</View>
)}
{/* Summary */}
<View style={styles.summary}>
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>{data.summarySubtotalLabel}</Text>
<Text style={styles.summaryValue}>{formatCurrency(calculateSubtotal() * 100, data.currency)}</Text>
</View>
{data.additionalTaxes.map((tax: AdditionalTax, index: number) => (
<View key={index} style={styles.taxRow}>
<Text style={styles.summaryLabel}>
{tax.name} ({tax.rate}%):
</Text>
<Text style={styles.summaryValue}>{formatCurrency(tax.amount * 100, data.currency)}</Text>
</View>
))}
{data.additionalFees.map((fee: AdditionalFee, index: number) => (
<View key={index} style={styles.taxRow}>
<Text style={styles.summaryLabel}>{fee.name}</Text>
<Text style={styles.summaryValue}>{formatCurrency(fee.amount * 100, data.currency)}</Text>
</View>
))}
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>{data.summaryTotalLabel}</Text>
<Text style={styles.totalValue}>{formatCurrency(calculateTotal() * 100, data.currency)}</Text>
</View>
</View>
{/* Bank Details */}
{data.bankDetails && (
<View style={styles.bankDetails}>
<Text>{data.bankDetails}</Text>
</View>
)}
</Page>
</Document>
)
}

View File

@@ -0,0 +1,71 @@
import { SettingsMap } from "@/models/settings"
import { User } from "@/prisma/client"
import { addDays, format } from "date-fns"
import { InvoiceFormData } from "./components/invoice-page"
export interface InvoiceTemplate {
id?: string
name: string
formData: InvoiceFormData
}
export default function defaultTemplates(user: User, settings: SettingsMap): InvoiceTemplate[] {
const defaultTemplate: InvoiceFormData = {
title: "INVOICE",
businessLogo: user.businessLogo,
invoiceNumber: "",
date: format(new Date(), "yyyy-MM-dd"),
dueDate: format(addDays(new Date(), 30), "yyyy-MM-dd"),
currency: settings.default_currency || "EUR",
companyDetails: `${user.businessName}\n${user.businessAddress || ""}`,
companyDetailsLabel: "Bill From",
billTo: "",
billToLabel: "Bill To",
items: [{ description: "", quantity: 1, unitPrice: 0, subtotal: 0 }],
taxIncluded: true,
additionalTaxes: [{ name: "VAT", rate: 0, amount: 0 }],
additionalFees: [],
notes: "",
bankDetails: user.businessBankDetails || "",
issueDateLabel: "Issue Date",
dueDateLabel: "Due Date",
itemLabel: "Item",
quantityLabel: "Quantity",
unitPriceLabel: "Unit Price",
subtotalLabel: "Subtotal",
summarySubtotalLabel: "Subtotal:",
summaryTotalLabel: "Total:",
}
const germanTemplate: InvoiceFormData = {
title: "RECHNUNG",
businessLogo: user.businessLogo,
invoiceNumber: "",
date: format(new Date(), "yyyy-MM-dd"),
dueDate: format(addDays(new Date(), 30), "yyyy-MM-dd"),
currency: "EUR",
companyDetails: `${user.businessName}\n${user.businessAddress || ""}`,
companyDetailsLabel: "Rechnungssteller",
billTo: "",
billToLabel: "Rechnungsempfänger",
items: [{ description: "", quantity: 1, unitPrice: 0, subtotal: 0 }],
taxIncluded: true,
additionalTaxes: [{ name: "MwSt", rate: 19, amount: 0 }],
additionalFees: [],
notes: "",
bankDetails: user.businessBankDetails || "",
issueDateLabel: "Rechnungsdatum",
dueDateLabel: "Fälligkeitsdatum",
itemLabel: "Position",
quantityLabel: "Menge",
unitPriceLabel: "Einzelpreis",
subtotalLabel: "Zwischensumme",
summarySubtotalLabel: "Zwischensumme:",
summaryTotalLabel: "Gesamtbetrag:",
}
return [
{ name: "Default", formData: defaultTemplate },
{ name: "DE", formData: germanTemplate },
]
}

View File

@@ -0,0 +1,7 @@
import { AppManifest } from "../common"
export const manifest: AppManifest = {
name: "Invoice Generator",
description: "Generate custom invoices and send them to your customers",
icon: "🧾",
}

View File

@@ -0,0 +1,31 @@
import { getCurrentUser } from "@/lib/auth"
import { getAppData } from "@/models/apps"
import { getCurrencies } from "@/models/currencies"
import { getSettings } from "@/models/settings"
import { InvoiceGenerator } from "./components/invoice-generator"
import { InvoiceTemplate } from "./default-templates"
import { manifest } from "./manifest"
export type InvoiceAppData = {
templates: InvoiceTemplate[]
}
export default async function InvoicesApp() {
const user = await getCurrentUser()
const settings = await getSettings(user.id)
const currencies = await getCurrencies(user.id)
const appData = (await getAppData(user, "invoices")) as InvoiceAppData | null
return (
<div>
<header className="flex flex-wrap items-center justify-between gap-2 mb-8">
<h2 className="flex flex-row gap-3 md:gap-5">
<span className="text-3xl font-bold tracking-tight">
{manifest.icon} {manifest.name}
</span>
</h2>
</header>
<InvoiceGenerator user={user} settings={settings} currencies={currencies} appData={appData} />
</div>
)
}

View File

@@ -0,0 +1,3 @@
export default async function AppsLayout({ children }: { children: React.ReactNode }) {
return <div className="flex flex-col gap-4 p-4">{children}</div>
}

35
app/(app)/apps/page.tsx Normal file
View File

@@ -0,0 +1,35 @@
import Link from "next/link"
import { getApps } from "./common"
export default async function AppsPage() {
const apps = await getApps()
return (
<>
<header className="flex flex-wrap items-center justify-between gap-2 mb-8">
<h2 className="flex flex-row gap-3 md:gap-5">
<span className="text-3xl font-bold tracking-tight">Apps</span>
<span className="text-3xl tracking-tight opacity-20">{apps.length}</span>
</h2>
</header>
<main className="flex flex-row gap-4 flex-wrap">
{apps.map((app) => (
<Link
key={app.id}
href={`/apps/${app.id}`}
className="block shadow-xl max-w-[320px] p-6 bg-white rounded-lg hover:shadow-md transition-shadow border-4 border-gray-100"
>
<div className="flex flex-col gap-4">
<div className="flex flex-row items-center gap-4">
<div className="text-4xl">{app.manifest.icon}</div>
<div className="text-2xl font-semibold">{app.manifest.name}</div>
</div>
<div className="text-sm">{app.manifest.description}</div>
</div>
</Link>
))}
</main>
</>
)
}

View File

@@ -97,7 +97,7 @@ export async function GET(request: Request) {
}
for (const file of transactionFiles) {
const fullFilePath = await fullPathForFile(user, file)
const fullFilePath = fullPathForFile(user, file)
if (await fileExists(fullFilePath)) {
const fileData = await fs.readFile(fullFilePath)
const fileExtension = path.extname(fullFilePath)

View File

@@ -2,7 +2,13 @@
import { ActionState } from "@/lib/actions"
import { getCurrentUser, isSubscriptionExpired } from "@/lib/auth"
import { getDirectorySize, getUserUploadsDirectory, isEnoughStorageToUploadFile, unsortedFilePath } from "@/lib/files"
import {
getDirectorySize,
getUserUploadsDirectory,
isEnoughStorageToUploadFile,
safePathJoin,
unsortedFilePath,
} from "@/lib/files"
import { createFile } from "@/models/files"
import { updateUser } from "@/models/users"
import { randomUUID } from "crypto"
@@ -15,7 +21,7 @@ export async function uploadFilesAction(formData: FormData): Promise<ActionState
const files = formData.getAll("files") as File[]
// Make sure upload dir exists
const userUploadsDirectory = await getUserUploadsDirectory(user)
const userUploadsDirectory = getUserUploadsDirectory(user)
// Check limits
const totalFileSize = files.reduce((acc, file) => acc + file.size, 0)
@@ -39,11 +45,11 @@ export async function uploadFilesAction(formData: FormData): Promise<ActionState
// Save file to filesystem
const fileUuid = randomUUID()
const relativeFilePath = await unsortedFilePath(fileUuid, file.name)
const relativeFilePath = unsortedFilePath(fileUuid, file.name)
const arrayBuffer = await file.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
const fullFilePath = path.join(userUploadsDirectory, relativeFilePath)
const fullFilePath = safePathJoin(userUploadsDirectory, relativeFilePath)
await mkdir(path.dirname(fullFilePath), { recursive: true })
await writeFile(fullFilePath, buffer)
@@ -65,7 +71,7 @@ export async function uploadFilesAction(formData: FormData): Promise<ActionState
)
// Update user storage used
const storageUsed = await getDirectorySize(await getUserUploadsDirectory(user))
const storageUsed = await getDirectorySize(getUserUploadsDirectory(user))
await updateUser(user.id, { storageUsed })
console.log("uploadedFiles", uploadedFiles)

View File

@@ -21,7 +21,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ file
}
// Check if file exists
const fullFilePath = await fullPathForFile(user, file)
const fullFilePath = fullPathForFile(user, file)
const isFileExists = await fileExists(fullFilePath)
if (!isFileExists) {
return new NextResponse(`File not found on disk: ${file.path}`, { status: 404 })

View File

@@ -26,7 +26,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ file
}
// Check if file exists on disk
const fullFilePath = await fullPathForFile(user, file)
const fullFilePath = fullPathForFile(user, file)
const isFileExists = await fileExists(fullFilePath)
if (!isFileExists) {
return new NextResponse(`File not found on disk: ${file.path}`, { status: 404 })

View File

@@ -0,0 +1,35 @@
import { getCurrentUser } from "@/lib/auth"
import { fileExists, getStaticDirectory, safePathJoin } from "@/lib/files"
import fs from "fs/promises"
import lookup from "mime-types"
import { NextResponse } from "next/server"
export async function GET(request: Request, { params }: { params: Promise<{ filename: string }> }) {
const { filename } = await params
const user = await getCurrentUser()
if (!filename) {
return new NextResponse("No filename provided", { status: 400 })
}
const staticFilesDirectory = getStaticDirectory(user)
try {
const fullFilePath = safePathJoin(staticFilesDirectory, filename)
const isFileExists = await fileExists(fullFilePath)
if (!isFileExists) {
return new NextResponse(`File not found for user: ${filename}`, { status: 404 })
}
const fileBuffer = await fs.readFile(fullFilePath)
return new NextResponse(fileBuffer, {
headers: {
"Content-Type": lookup.lookup(filename) || "application/octet-stream",
},
})
} catch (error) {
console.error("Error serving file:", error)
return new NextResponse("Internal Server Error", { status: 500 })
}
}

View File

@@ -38,6 +38,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
name: user.name || "",
email: user.email,
avatar: user.avatar || undefined,
membershipPlan: user.membershipPlan || "unlimited",
storageUsed: user.storageUsed || 0,
storageLimit: user.storageLimit || -1,
aiBalance: user.aiBalance || 0,

View File

@@ -10,6 +10,7 @@ import {
import { userFormSchema } from "@/forms/users"
import { ActionState } from "@/lib/actions"
import { getCurrentUser } from "@/lib/auth"
import { uploadStaticImage } from "@/lib/uploads"
import { codeFromName, randomHexColor } from "@/lib/utils"
import { createCategory, deleteCategory, updateCategory } from "@/models/categories"
import { createCurrency, deleteCurrency, updateCurrency } from "@/models/currencies"
@@ -19,7 +20,7 @@ import { SettingsMap, updateSettings } from "@/models/settings"
import { updateUser } from "@/models/users"
import { Prisma, User } from "@/prisma/client"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
import path from "path"
export async function saveSettingsAction(
_prevState: ActionState<SettingsMap> | null,
@@ -40,8 +41,7 @@ export async function saveSettingsAction(
}
revalidatePath("/settings")
redirect("/settings")
// return { success: true }
return { success: true }
}
export async function saveProfileAction(
@@ -55,12 +55,47 @@ export async function saveProfileAction(
return { success: false, error: validatedForm.error.message }
}
// Upload avatar
let avatarUrl = user.avatar
const avatarFile = formData.get("avatar") as File | null
if (avatarFile instanceof File && avatarFile.size > 0) {
try {
const uploadedAvatarPath = await uploadStaticImage(user, avatarFile, "avatar.webp", 500, 500)
avatarUrl = `/files/static/${path.basename(uploadedAvatarPath)}`
} catch (error) {
return { success: false, error: "Failed to upload avatar: " + error }
}
}
// Upload business logo
let businessLogoUrl = user.businessLogo
const businessLogoFile = formData.get("businessLogo") as File | null
if (businessLogoFile instanceof File && businessLogoFile.size > 0) {
try {
const uploadedBusinessLogoPath = await uploadStaticImage(user, businessLogoFile, "businessLogo.png", 500, 500)
businessLogoUrl = `/files/static/${path.basename(uploadedBusinessLogoPath)}`
} catch (error) {
return { success: false, error: "Failed to upload business logo: " + error }
}
}
// Update user
await updateUser(user.id, {
name: validatedForm.data.name,
name: validatedForm.data.name !== undefined ? validatedForm.data.name : user.name,
avatar: avatarUrl,
businessName: validatedForm.data.businessName !== undefined ? validatedForm.data.businessName : user.businessName,
businessAddress:
validatedForm.data.businessAddress !== undefined ? validatedForm.data.businessAddress : user.businessAddress,
businessBankDetails:
validatedForm.data.businessBankDetails !== undefined
? validatedForm.data.businessBankDetails
: user.businessBankDetails,
businessLogo: businessLogoUrl,
})
revalidatePath("/settings/profile")
redirect("/settings/profile")
revalidatePath("/settings/business")
return { success: true }
}
export async function addProjectAction(userId: string, data: Prisma.ProjectCreateInput) {

View File

@@ -3,7 +3,7 @@
import { ActionState } from "@/lib/actions"
import { getCurrentUser } from "@/lib/auth"
import { prisma } from "@/lib/db"
import { getUserUploadsDirectory } from "@/lib/files"
import { getUserUploadsDirectory, safePathJoin } from "@/lib/files"
import { MODEL_BACKUP, modelFromJSON } from "@/models/backups"
import fs from "fs/promises"
import JSZip from "jszip"
@@ -22,7 +22,7 @@ export async function restoreBackupAction(
formData: FormData
): Promise<ActionState<BackupRestoreResult>> {
const user = await getCurrentUser()
const userUploadsDirectory = await getUserUploadsDirectory(user)
const userUploadsDirectory = getUserUploadsDirectory(user)
const file = formData.get("file") as File
if (!file || file.size === 0) {
@@ -98,7 +98,7 @@ export async function restoreBackupAction(
},
})
const userUploadsDirectory = await getUserUploadsDirectory(user)
const userUploadsDirectory = getUserUploadsDirectory(user)
for (const file of files) {
const filePathWithoutPrefix = path.normalize(file.path.replace(/^.*\/uploads\//, ""))
@@ -110,7 +110,7 @@ export async function restoreBackupAction(
}
const fileContents = await zipFile.async("nodebuffer")
const fullFilePath = path.join(userUploadsDirectory, filePathWithoutPrefix)
const fullFilePath = safePathJoin(userUploadsDirectory, filePathWithoutPrefix)
if (!fullFilePath.startsWith(path.normalize(userUploadsDirectory))) {
console.error(`Attempted path traversal detected for file ${file.path}`)
continue

View File

@@ -11,7 +11,7 @@ const BACKUP_VERSION = "1.0"
export async function GET() {
const user = await getCurrentUser()
const userUploadsDirectory = await getUserUploadsDirectory(user)
const userUploadsDirectory = getUserUploadsDirectory(user)
try {
const zip = new JSZip()

View File

@@ -0,0 +1,14 @@
import BusinessSettingsForm from "@/components/settings/business-settings-form"
import { getCurrentUser } from "@/lib/auth"
export default async function BusinessSettingsPage() {
const user = await getCurrentUser()
return (
<>
<div className="w-full max-w-2xl">
<BusinessSettingsForm user={user} />
</div>
</>
)
}

View File

@@ -16,6 +16,10 @@ const settingsCategories = [
title: "Profile & Plan",
href: "/settings/profile",
},
{
title: "Business Details",
href: "/settings/business",
},
{
title: "LLM settings",
href: "/settings/llm",

View File

@@ -46,7 +46,6 @@ export default async function TransactionPage({ params }: { params: Promise<{ tr
<Card className="flex items-stretch p-2 max-w-6xl">
<div className="flex-1">
<FormTextarea
title=""
name="text"
defaultValue={transaction.text || ""}
hideIfEmpty={true}

View File

@@ -8,6 +8,7 @@ import {
getTransactionFileUploadPath,
getUserUploadsDirectory,
isEnoughStorageToUploadFile,
safePathJoin,
} from "@/lib/files"
import { updateField } from "@/models/fields"
import { createFile, deleteFile } from "@/models/files"
@@ -114,7 +115,7 @@ export async function deleteTransactionFileAction(
await deleteFile(fileId, user.id)
// Update user storage used
const storageUsed = await getDirectorySize(await getUserUploadsDirectory(user))
const storageUsed = await getDirectorySize(getUserUploadsDirectory(user))
await updateUser(user.id, { storageUsed })
revalidatePath(`/transactions/${transactionId}`)
@@ -136,7 +137,7 @@ export async function uploadTransactionFilesAction(formData: FormData): Promise<
return { success: false, error: "Transaction not found" }
}
const userUploadsDirectory = await getUserUploadsDirectory(user)
const userUploadsDirectory = getUserUploadsDirectory(user)
// Check limits
const totalFileSize = files.reduce((acc, file) => acc + file.size, 0)
@@ -154,17 +155,13 @@ export async function uploadTransactionFilesAction(formData: FormData): Promise<
const fileRecords = await Promise.all(
files.map(async (file) => {
const fileUuid = randomUUID()
const relativeFilePath = await getTransactionFileUploadPath(fileUuid, file.name, transaction)
const relativeFilePath = getTransactionFileUploadPath(fileUuid, file.name, transaction)
const arrayBuffer = await file.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
const fullFilePath = path.join(userUploadsDirectory, relativeFilePath)
const fullFilePath = safePathJoin(userUploadsDirectory, relativeFilePath)
await mkdir(path.dirname(fullFilePath), { recursive: true })
console.log("userUploadsDirectory", userUploadsDirectory)
console.log("relativeFilePath", relativeFilePath)
console.log("fullFilePath", fullFilePath)
await writeFile(fullFilePath, buffer)
// Create file record in database
@@ -194,7 +191,7 @@ export async function uploadTransactionFilesAction(formData: FormData): Promise<
)
// Update user storage used
const storageUsed = await getDirectorySize(await getUserUploadsDirectory(user))
const storageUsed = await getDirectorySize(getUserUploadsDirectory(user))
await updateUser(user.id, { storageUsed })
revalidatePath(`/transactions/${transactionId}`)

View File

@@ -8,7 +8,7 @@ import { transactionFormSchema } from "@/forms/transactions"
import { ActionState } from "@/lib/actions"
import { getCurrentUser, isAiBalanceExhausted, isSubscriptionExpired } from "@/lib/auth"
import config from "@/lib/config"
import { getTransactionFileUploadPath, getUserUploadsDirectory } from "@/lib/files"
import { getTransactionFileUploadPath, getUserUploadsDirectory, safePathJoin } from "@/lib/files"
import { DEFAULT_PROMPT_ANALYSE_NEW_FILE } from "@/models/defaults"
import { deleteFile, getFileById, updateFile } from "@/models/files"
import { createTransaction, updateTransactionFiles } from "@/models/transactions"
@@ -99,13 +99,13 @@ export async function saveFileAsTransactionAction(
const transaction = await createTransaction(user.id, validatedForm.data)
// Move file to processed location
const userUploadsDirectory = await getUserUploadsDirectory(user)
const userUploadsDirectory = getUserUploadsDirectory(user)
const originalFileName = path.basename(file.path)
const newRelativeFilePath = await getTransactionFileUploadPath(file.id, originalFileName, transaction)
const newRelativeFilePath = getTransactionFileUploadPath(file.id, originalFileName, transaction)
// Move file to new location and name
const oldFullFilePath = path.join(userUploadsDirectory, path.normalize(file.path))
const newFullFilePath = path.join(userUploadsDirectory, path.normalize(newRelativeFilePath))
const oldFullFilePath = safePathJoin(userUploadsDirectory, file.path)
const newFullFilePath = safePathJoin(userUploadsDirectory, newRelativeFilePath)
await mkdir(path.dirname(newFullFilePath), { recursive: true })
await rename(path.resolve(oldFullFilePath), path.resolve(newFullFilePath))

View File

@@ -82,4 +82,10 @@
body {
@apply bg-background text-foreground;
}
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
appearance: none;
margin: 0;
}
}

View File

@@ -34,6 +34,7 @@ export function ExportTransactionsDialog({
children: React.ReactNode
}) {
const router = useRouter()
const [isLoading, setIsLoading] = useState(false)
const [exportFilters, setExportFilters] = useTransactionFilters()
const [exportFields, setExportFields] = useState<string[]>(
fields.map((field) => (deselectedFields.includes(field.code) ? "" : field.code))
@@ -41,6 +42,7 @@ export function ExportTransactionsDialog({
const [includeAttachments, setIncludeAttachments] = useState(true)
const handleSubmit = () => {
setIsLoading(true)
router.push(
`/export/transactions?${new URLSearchParams({
search: exportFilters?.search || "",
@@ -53,6 +55,9 @@ export function ExportTransactionsDialog({
includeAttachments: includeAttachments.toString(),
}).toString()}`
)
setTimeout(() => {
setIsLoading(false)
}, 3000)
}
return (
@@ -155,8 +160,8 @@ export function ExportTransactionsDialog({
</div>
</div>
<DialogFooter className="sm:justify-end">
<Button type="button" onClick={handleSubmit}>
Export Transactions
<Button type="button" onClick={handleSubmit} disabled={isLoading}>
{isLoading ? "Exporting..." : "Export Transactions"}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -3,15 +3,15 @@ import { useMemo } from "react"
import { FormSelect } from "./simple"
export const FormSelectCurrency = ({
title,
currencies,
title,
emptyValue,
placeholder,
hideIfEmpty = false,
...props
}: {
title: string
currencies: { code: string; name: string }[]
title?: string
emptyValue?: string
placeholder?: string
hideIfEmpty?: boolean

View File

@@ -10,11 +10,11 @@ import { Textarea } from "@/components/ui/textarea"
import { cn } from "@/lib/utils"
import { SelectProps } from "@radix-ui/react-select"
import { format } from "date-fns"
import { CalendarIcon } from "lucide-react"
import { InputHTMLAttributes, TextareaHTMLAttributes, useState } from "react"
import { CalendarIcon, Upload } from "lucide-react"
import { InputHTMLAttributes, TextareaHTMLAttributes, useEffect, useRef, useState } from "react"
type FormInputProps = InputHTMLAttributes<HTMLInputElement> & {
title: string
title?: string
hideIfEmpty?: boolean
}
@@ -25,40 +25,57 @@ export function FormInput({ title, hideIfEmpty = false, ...props }: FormInputPro
return (
<label className="flex flex-col gap-1">
<span className="text-sm font-medium">{title}</span>
{title && <span className="text-sm font-medium">{title}</span>}
<Input {...props} className={cn("bg-background", props.className)} />
</label>
)
}
type FormTextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
title: string
title?: string
hideIfEmpty?: boolean
}
export function FormTextarea({ title, hideIfEmpty = false, ...props }: FormTextareaProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
const textarea = textareaRef.current
if (!textarea) return
const resize = () => {
textarea.style.height = "auto"
textarea.style.height = `${textarea.scrollHeight + 5}px`
}
resize() // initial resize
textarea.addEventListener("input", resize)
return () => textarea.removeEventListener("input", resize)
}, [props.value, props.defaultValue])
if (hideIfEmpty && (!props.defaultValue || props.defaultValue.toString().trim() === "") && !props.value) {
return null
}
return (
<label className="flex flex-col gap-1">
<span className="text-sm font-medium">{title}</span>
<Textarea {...props} className={cn("bg-background", props.className)} />
{title && <span className="text-sm font-medium">{title}</span>}
<Textarea ref={textareaRef} {...props} className={cn("bg-background", props.className)} />
</label>
)
}
export const FormSelect = ({
title,
items,
title,
emptyValue,
placeholder,
hideIfEmpty = false,
...props
}: {
title: string
items: Array<{ code: string; name: string; color?: string; badge?: string }>
title?: string
emptyValue?: string
placeholder?: string
hideIfEmpty?: boolean
@@ -69,7 +86,7 @@ export const FormSelect = ({
return (
<span className="flex flex-col gap-1">
<span className="text-sm font-medium">{title}</span>
{title && <span className="text-sm font-medium">{title}</span>}
<Select {...props}>
<SelectTrigger className="w-full min-w-[150px] bg-background">
<SelectValue placeholder={placeholder} />
@@ -94,14 +111,14 @@ export const FormSelect = ({
}
export const FormDate = ({
title,
name,
title,
placeholder = "Select date",
defaultValue,
...props
}: {
title: string
name: string
title?: string
placeholder?: string
defaultValue?: Date
}) => {
@@ -126,7 +143,7 @@ export const FormDate = ({
return (
<label className="flex flex-col gap-1">
<span className="text-sm font-medium">{title}</span>
{title && <span className="text-sm font-medium">{title}</span>}
<div className="relative">
<Popover>
<PopoverTrigger asChild>
@@ -157,3 +174,61 @@ export const FormDate = ({
</label>
)
}
export const FormAvatar = ({
title,
defaultValue,
className,
onChange,
...props
}: {
title?: string
defaultValue?: string
className?: string
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
} & InputHTMLAttributes<HTMLInputElement>) => {
const [preview, setPreview] = useState<string | null>(defaultValue || null)
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
const reader = new FileReader()
reader.onloadend = () => {
setPreview(reader.result as string)
}
reader.readAsDataURL(file)
}
// Call the original onChange if provided
if (onChange) {
onChange(e)
}
}
return (
<label className="inline-block">
{title && <span className="text-sm font-medium">{title}</span>}
<div className={cn("relative group", className)}>
<div className="absolute inset-0 flex items-center justify-center bg-background rounded-lg overflow-hidden">
{preview ? (
<img src={preview} alt="Avatar preview" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-muted flex items-center justify-center">
<span className="text-muted-foreground">No image</span>
</div>
)}
</div>
<div className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg">
<input
type="file"
accept="image/*"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
onChange={handleFileChange}
{...props}
/>
<Upload className="z-10 bg-white/30 text-white p-1 rounded-sm h-7 w-8 cursor-pointer" />
</div>
</div>
</label>
)
}

View File

@@ -0,0 +1,61 @@
"use client"
import { saveProfileAction } from "@/app/(app)/settings/actions"
import { FormError } from "@/components/forms/error"
import { FormAvatar, FormInput, FormTextarea } from "@/components/forms/simple"
import { Button } from "@/components/ui/button"
import { User } from "@/prisma/client"
import { CircleCheckBig } from "lucide-react"
import { useActionState } from "react"
export default function BusinessSettingsForm({ user }: { user: User }) {
const [saveState, saveAction, pending] = useActionState(saveProfileAction, null)
return (
<div>
<form action={saveAction} className="space-y-4">
<FormInput
title="Business Name"
name="businessName"
placeholder="Acme Inc."
defaultValue={user.businessName ?? ""}
/>
<FormTextarea
title="Business Address"
name="businessAddress"
placeholder="Street, City, State, Zip Code, Country, Tax ID"
defaultValue={user.businessAddress ?? ""}
/>
<FormTextarea
title="Bank Details"
name="businessBankDetails"
placeholder="Bank Name, Account Number, BIC, IBAN, details of payment, etc."
defaultValue={user.businessBankDetails ?? ""}
/>
<FormAvatar
title="Business Logo"
name="businessLogo"
className="w-52 h-52"
defaultValue={user.businessLogo ?? ""}
/>
<div className="flex flex-row items-center gap-4">
<Button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save"}
</Button>
{saveState?.success && (
<p className="text-green-500 flex flex-row items-center gap-2">
<CircleCheckBig />
Saved!
</p>
)}
</div>
{saveState?.error && <FormError>{saveState.error}</FormError>}
</form>
</div>
)
}

View File

@@ -2,7 +2,7 @@
import { saveProfileAction } from "@/app/(app)/settings/actions"
import { FormError } from "@/components/forms/error"
import { FormInput } from "@/components/forms/simple"
import { FormAvatar, FormInput } from "@/components/forms/simple"
import { Button } from "@/components/ui/button"
import { User } from "@/prisma/client"
import { CircleCheckBig } from "lucide-react"
@@ -15,7 +15,9 @@ export default function ProfileSettingsForm({ user }: { user: User }) {
return (
<div>
<form action={saveAction} className="space-y-4">
<FormInput title="Your Name" name="name" defaultValue={user.name || ""} />
<FormAvatar title="Avatar" name="avatar" className="w-24 h-24" defaultValue={user.avatar || ""} />
<FormInput title="Account Name" name="name" defaultValue={user.name || ""} />
<div className="flex flex-row items-center gap-4">
<Button type="submit" disabled={pending}>

View File

@@ -4,14 +4,16 @@ import {
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { SidebarMenuButton } from "@/components/ui/sidebar"
import { UserProfile } from "@/lib/auth"
import { authClient } from "@/lib/auth-client"
import { PLANS } from "@/lib/stripe"
import { formatBytes } from "@/lib/utils"
import { HardDrive, LogOut, MoreVertical, User } from "lucide-react"
import { CreditCard, LogOut, MoreVertical, Settings, Sparkles, User } from "lucide-react"
import Link from "next/link"
import { redirect } from "next/navigation"
@@ -40,26 +42,50 @@ export default function SidebarUser({ profile, isSelfHosted }: { profile: UserPr
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
side={"top"}
side="top"
align="center"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={profile.avatar} alt={profile.name || ""} />
<AvatarFallback className="rounded-lg">
<User className="h-4 w-4" />
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{profile.name || profile.email}</span>
<span className="truncate text-xs">{profile.email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{/* <DropdownMenuItem>
<ThemeToggle />
</DropdownMenuItem> */}
<DropdownMenuItem asChild>
<Link href="/settings/profile" className="flex items-center gap-2">
<User className="h-4 w-4" />
Profile & Plan
<Sparkles />
<span className="truncate">{PLANS[profile.membershipPlan as keyof typeof PLANS].name}</span>
<span className="ml-auto text-xs text-muted-foreground">{formatBytes(profile.storageUsed)} used</span>
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link href="/settings/profile" className="flex items-center gap-2">
<HardDrive className="h-4 w-4" />
Storage: {formatBytes(profile.storageUsed)}
<Link href="/settings" className="flex items-center gap-2">
<Settings className="h-4 w-4" />
Settings
</Link>
</DropdownMenuItem>
{!isSelfHosted && (
<DropdownMenuItem asChild>
<Link href="/api/stripe/portal" className="flex items-center gap-2">
<CreditCard className="h-4 w-4" />
Billing
</Link>
</DropdownMenuItem>
)}
</DropdownMenuGroup>
{!isSelfHosted && (
<>

View File

@@ -18,7 +18,7 @@ import {
} from "@/components/ui/sidebar"
import { UserProfile } from "@/lib/auth"
import config from "@/lib/config"
import { ClockArrowUp, FileText, Import, LayoutDashboard, Settings, Sparkles, Upload } from "lucide-react"
import { ClockArrowUp, FileText, Gift, House, Import, LayoutDashboard, Settings, Upload } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import { usePathname } from "next/navigation"
@@ -72,7 +72,7 @@ export function AppSidebar({
<SidebarMenuItemWithHighlight href="/dashboard">
<SidebarMenuButton asChild>
<Link href="/dashboard">
<LayoutDashboard />
<House />
<span>Home</span>
</Link>
</SidebarMenuButton>
@@ -106,7 +106,14 @@ export function AppSidebar({
</Link>
</SidebarMenuButton>
</SidebarMenuItemWithHighlight>
<SidebarMenuItemWithHighlight href="/apps">
<SidebarMenuButton asChild>
<Link href="/apps">
<LayoutDashboard />
<span>Apps</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItemWithHighlight>
<SidebarMenuItemWithHighlight href="/settings">
<SidebarMenuButton asChild>
<Link href="/settings">
@@ -136,7 +143,7 @@ export function AppSidebar({
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Link href="https://vas3k.com/donate/" target="_blank">
<Sparkles />
<Gift />
Thank the author
</Link>
</SidebarMenuButton>

View File

@@ -2,5 +2,9 @@ import { z } from "zod"
export const userFormSchema = z.object({
name: z.string().max(128).optional(),
avatar: z.string().optional(),
avatar: z.instanceof(File).optional(),
businessName: z.string().max(128).optional(),
businessAddress: z.string().max(1024).optional(),
businessBankDetails: z.string().max(1024).optional(),
businessLogo: z.instanceof(File).optional(),
})

View File

@@ -16,6 +16,7 @@ export type UserProfile = {
name: string
email: string
avatar?: string
membershipPlan: string
storageUsed: number
storageLimit: number
aiBalance: number
@@ -37,7 +38,7 @@ export const auth = betterAuth({
updateAge: 24 * 60 * 60, // 24 hours
cookieCache: {
enabled: true,
maxAge: 24 * 60 * 60, // 24 hours
maxAge: 7 * 24 * 60 * 60, // 7 days
},
},
advanced: {

View File

@@ -30,6 +30,18 @@ const config = {
},
upload: {
acceptedMimeTypes: "image/*,.pdf,.doc,.docx,.xls,.xlsx",
images: {
maxWidth: 1800,
maxHeight: 1800,
quality: 90,
},
pdfs: {
maxPages: 10,
dpi: 150,
quality: 90,
maxWidth: 1500,
maxHeight: 1500,
},
},
selfHosted: {
isEnabled: env.SELF_HOSTED_MODE === "true",

View File

@@ -6,34 +6,39 @@ import config from "./config"
export const FILE_UPLOAD_PATH = path.resolve(process.env.UPLOAD_PATH || "./uploads")
export const FILE_UNSORTED_DIRECTORY_NAME = "unsorted"
export const FILE_PREVIEWS_DIRECTORY_NAME = "previews"
export const FILE_STATIC_DIRECTORY_NAME = "static"
export const FILE_IMPORT_CSV_DIRECTORY_NAME = "csv"
export async function getUserUploadsDirectory(user: User) {
return path.join(FILE_UPLOAD_PATH, user.email)
export function getUserUploadsDirectory(user: User) {
return safePathJoin(FILE_UPLOAD_PATH, user.email)
}
export async function getUserPreviewsDirectory(user: User) {
return path.join(FILE_UPLOAD_PATH, user.email, FILE_PREVIEWS_DIRECTORY_NAME)
export function getStaticDirectory(user: User) {
return safePathJoin(getUserUploadsDirectory(user), FILE_STATIC_DIRECTORY_NAME)
}
export async function unsortedFilePath(fileUuid: string, filename: string): Promise<string> {
export function getUserPreviewsDirectory(user: User) {
return safePathJoin(getUserUploadsDirectory(user), FILE_PREVIEWS_DIRECTORY_NAME)
}
export function unsortedFilePath(fileUuid: string, filename: string) {
const fileExtension = path.extname(filename)
return path.join(FILE_UNSORTED_DIRECTORY_NAME, `${fileUuid}${fileExtension}`)
return safePathJoin(FILE_UNSORTED_DIRECTORY_NAME, `${fileUuid}${fileExtension}`)
}
export async function previewFilePath(fileUuid: string, page: number): Promise<string> {
return path.join(FILE_PREVIEWS_DIRECTORY_NAME, `${fileUuid}.${page}.webp`)
export function previewFilePath(fileUuid: string, page: number) {
return safePathJoin(FILE_PREVIEWS_DIRECTORY_NAME, `${fileUuid}.${page}.webp`)
}
export async function getTransactionFileUploadPath(fileUuid: string, filename: string, transaction: Transaction) {
export function getTransactionFileUploadPath(fileUuid: string, filename: string, transaction: Transaction) {
const fileExtension = path.extname(filename)
const storedFileName = `${fileUuid}${fileExtension}`
return formatFilePath(storedFileName, transaction.issuedAt || new Date())
}
export async function fullPathForFile(user: User, file: File) {
const userUploadsDirectory = await getUserUploadsDirectory(user)
return path.join(userUploadsDirectory, path.normalize(file.path))
export function fullPathForFile(user: User, file: File) {
const userUploadsDirectory = getUserUploadsDirectory(user)
return safePathJoin(userUploadsDirectory, file.path)
}
function formatFilePath(filename: string, date: Date, format = "{YYYY}/{MM}/{name}{ext}") {
@@ -45,6 +50,14 @@ function formatFilePath(filename: string, date: Date, format = "{YYYY}/{MM}/{nam
return format.replace("{YYYY}", String(year)).replace("{MM}", month).replace("{name}", name).replace("{ext}", ext)
}
export function safePathJoin(basePath: string, ...paths: string[]) {
const joinedPath = path.join(basePath, path.normalize(path.join(...paths)))
if (!joinedPath.startsWith(basePath)) {
throw new Error("Path traversal detected")
}
return joinedPath
}
export async function fileExists(filePath: string) {
try {
await access(path.normalize(filePath), constants.F_OK)

View File

@@ -1,27 +1,25 @@
"use server"
import { fileExists, getUserPreviewsDirectory } from "@/lib/files"
import { fileExists, getUserPreviewsDirectory, safePathJoin } from "@/lib/files"
import { User } from "@/prisma/client"
import fs from "fs/promises"
import path from "path"
import sharp from "sharp"
const MAX_WIDTH = 1800
const MAX_HEIGHT = 1800
const QUALITY = 90
import config from "../config"
export async function resizeImage(
user: User,
origFilePath: string,
maxWidth: number = MAX_WIDTH,
maxHeight: number = MAX_HEIGHT
maxWidth: number = config.upload.images.maxWidth,
maxHeight: number = config.upload.images.maxHeight,
quality: number = config.upload.images.quality
): Promise<{ contentType: string; resizedPath: string }> {
try {
const userPreviewsDirectory = await getUserPreviewsDirectory(user)
const userPreviewsDirectory = getUserPreviewsDirectory(user)
await fs.mkdir(userPreviewsDirectory, { recursive: true })
const basename = path.basename(origFilePath, path.extname(origFilePath))
const outputPath = path.join(userPreviewsDirectory, `${basename}.webp`)
const outputPath = safePathJoin(userPreviewsDirectory, `${basename}.webp`)
if (await fileExists(outputPath)) {
const metadata = await sharp(outputPath).metadata()
@@ -37,7 +35,7 @@ export async function resizeImage(
fit: "inside",
withoutEnlargement: true,
})
.webp({ quality: QUALITY })
.webp({ quality: quality })
.toFile(outputPath)
return {

View File

@@ -1,26 +1,21 @@
"use server"
import { fileExists, getUserPreviewsDirectory } from "@/lib/files"
import { fileExists, getUserPreviewsDirectory, safePathJoin } from "@/lib/files"
import { User } from "@/prisma/client"
import fs from "fs/promises"
import path from "path"
import { fromPath } from "pdf2pic"
const MAX_PAGES = 10
const DPI = 150
const QUALITY = 90
const MAX_WIDTH = 1500
const MAX_HEIGHT = 1500
import config from "../config"
export async function pdfToImages(user: User, origFilePath: string): Promise<{ contentType: string; pages: string[] }> {
const userPreviewsDirectory = await getUserPreviewsDirectory(user)
const userPreviewsDirectory = getUserPreviewsDirectory(user)
await fs.mkdir(userPreviewsDirectory, { recursive: true })
const basename = path.basename(origFilePath, path.extname(origFilePath))
// Check if converted pages already exist
const existingPages: string[] = []
for (let i = 1; i <= MAX_PAGES; i++) {
const convertedFilePath = path.join(userPreviewsDirectory, `${basename}.${i}.webp`)
for (let i = 1; i <= config.upload.pdfs.maxPages; i++) {
const convertedFilePath = safePathJoin(userPreviewsDirectory, `${basename}.${i}.webp`)
if (await fileExists(convertedFilePath)) {
existingPages.push(convertedFilePath)
} else {
@@ -34,13 +29,13 @@ export async function pdfToImages(user: User, origFilePath: string): Promise<{ c
// If not — convert the file as store in previews folder
const pdf2picOptions = {
density: DPI,
density: config.upload.pdfs.dpi,
saveFilename: basename,
savePath: userPreviewsDirectory,
format: "webp",
quality: QUALITY,
width: MAX_WIDTH,
height: MAX_HEIGHT,
quality: config.upload.pdfs.quality,
width: config.upload.pdfs.maxWidth,
height: config.upload.pdfs.maxHeight,
preserveAspectRatio: true,
}

60
lib/uploads.ts Normal file
View File

@@ -0,0 +1,60 @@
import { User } from "@/prisma/client"
import { mkdir } from "fs/promises"
import path from "path"
import sharp from "sharp"
import config from "./config"
import { getStaticDirectory, isEnoughStorageToUploadFile, safePathJoin } from "./files"
export async function uploadStaticImage(
user: User,
file: File,
saveFileName: string,
maxWidth: number = config.upload.images.maxWidth,
maxHeight: number = config.upload.images.maxHeight,
quality: number = config.upload.images.quality
) {
const uploadDirectory = getStaticDirectory(user)
if (!isEnoughStorageToUploadFile(user, file.size)) {
throw Error("Not enough space to upload the file")
}
await mkdir(uploadDirectory, { recursive: true })
// Get target format from saveFileName extension
const targetFormat = path.extname(saveFileName).slice(1).toLowerCase()
if (!targetFormat) {
throw Error("Target filename must have an extension")
}
// Convert image and save to static folder
const uploadFilePath = safePathJoin(uploadDirectory, saveFileName)
const arrayBuffer = await file.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
const sharpInstance = sharp(buffer).rotate().resize(maxWidth, maxHeight, {
fit: "inside",
withoutEnlargement: true,
})
// Set output format and quality
switch (targetFormat) {
case "png":
await sharpInstance.png().toFile(uploadFilePath)
break
case "jpg":
case "jpeg":
await sharpInstance.jpeg({ quality }).toFile(uploadFilePath)
break
case "webp":
await sharpInstance.webp({ quality }).toFile(uploadFilePath)
break
case "avif":
await sharpInstance.avif({ quality }).toFile(uploadFilePath)
break
default:
throw Error(`Unsupported target format: ${targetFormat}`)
}
return uploadFilePath
}

View File

@@ -8,14 +8,19 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatCurrency(total: number, currency: string) {
return new Intl.NumberFormat(LOCALE, {
style: "currency",
currency: currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
useGrouping: true,
}).format(total / 100)
export function formatCurrency(total: number, currency: string, separator: string = "") {
try {
return new Intl.NumberFormat(LOCALE, {
style: "currency",
currency: currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
useGrouping: true,
}).format(total / 100)
} catch (error) {
// can happen with custom currencies and crypto
return `${currency} ${total / 100}`
}
}
export function formatBytes(bytes: number) {
@@ -49,3 +54,24 @@ export function codeFromName(name: string, maxLength: number = 16) {
export function randomHexColor() {
return "#" + Math.floor(Math.random() * 16777215).toString(16)
}
export async function fetchAsBase64(url: string): Promise<string | null> {
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.statusText}`)
}
const blob = await response.blob()
return await new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(blob)
})
} catch (error) {
console.error("Error fetching image as data URL:", error)
return null
}
}

18
models/apps.ts Normal file
View File

@@ -0,0 +1,18 @@
import { prisma } from "@/lib/db"
import { User } from "@prisma/client"
export const getAppData = async (user: User, app: string) => {
const appData = await prisma.appData.findUnique({
where: { userId_app: { userId: user.id, app } },
})
return appData?.data
}
export const setAppData = async (user: User, app: string, data: any) => {
await prisma.appData.upsert({
where: { userId_app: { userId: user.id, app } },
update: { data },
create: { userId: user.id, app, data },
})
}

500
package-lock.json generated
View File

@@ -22,7 +22,9 @@
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-tooltip": "^1.1.8",
"@react-pdf/renderer": "^4.3.0",
"@sentry/nextjs": "^9.11.0",
"@types/mime-types": "^2.1.4",
"@types/sharp": "^0.31.1",
"better-auth": "^1.2.5",
"class-variance-authority": "^0.7.1",
@@ -30,6 +32,7 @@
"date-fns": "^3.6.0",
"jszip": "^3.10.1",
"lucide-react": "^0.475.0",
"mime-types": "^3.0.1",
"next": "^15.2.4",
"next-themes": "^0.4.4",
"openai": "^4.85.4",
@@ -294,6 +297,15 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz",
"integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz",
@@ -3478,6 +3490,186 @@
"react-dom": "^18.0 || ^19.0 || ^19.0.0-rc"
}
},
"node_modules/@react-pdf/fns": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.2.tgz",
"integrity": "sha512-qTKGUf0iAMGg2+OsUcp9ffKnKi41RukM/zYIWMDJ4hRVYSr89Q7e3wSDW/Koqx3ea3Uy/z3h2y3wPX6Bdfxk6g==",
"license": "MIT"
},
"node_modules/@react-pdf/font": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-4.0.2.tgz",
"integrity": "sha512-/dAWu7Y2RD1RxarDZ9SkYPHgBYOhmcDnet4W/qN/m8k+A2Hr3ja54GymSR7GGxWBtxjKtNauVKrTa9LS1n8WUw==",
"license": "MIT",
"dependencies": {
"@react-pdf/pdfkit": "^4.0.3",
"@react-pdf/types": "^2.9.0",
"fontkit": "^2.0.2",
"is-url": "^1.2.4"
}
},
"node_modules/@react-pdf/image": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-3.0.3.tgz",
"integrity": "sha512-lvP5ryzYM3wpbO9bvqLZYwEr5XBDX9jcaRICvtnoRqdJOo7PRrMnmB4MMScyb+Xw10mGeIubZAAomNAG5ONQZQ==",
"license": "MIT",
"dependencies": {
"@react-pdf/png-js": "^3.0.0",
"jay-peg": "^1.1.1"
}
},
"node_modules/@react-pdf/layout": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.4.0.tgz",
"integrity": "sha512-Aq+Cc6JYausWLoks2FvHe3PwK9cTuvksB2uJ0AnkKJEUtQbvCq8eCRb1bjbbwIji9OzFRTTzZij7LzkpKHjIeA==",
"license": "MIT",
"dependencies": {
"@react-pdf/fns": "3.1.2",
"@react-pdf/image": "^3.0.3",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/stylesheet": "^6.1.0",
"@react-pdf/textkit": "^6.0.0",
"@react-pdf/types": "^2.9.0",
"emoji-regex": "^10.3.0",
"queue": "^6.0.1",
"yoga-layout": "^3.2.1"
}
},
"node_modules/@react-pdf/layout/node_modules/emoji-regex": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz",
"integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==",
"license": "MIT"
},
"node_modules/@react-pdf/pdfkit": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-4.0.3.tgz",
"integrity": "sha512-k+Lsuq8vTwWsCqTp+CCB4+2N+sOTFrzwGA7aw3H9ix/PDWR9QksbmNg0YkzGbLAPI6CeawmiLHcf4trZ5ecLPQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/png-js": "^3.0.0",
"browserify-zlib": "^0.2.0",
"crypto-js": "^4.2.0",
"fontkit": "^2.0.2",
"jay-peg": "^1.1.1",
"linebreak": "^1.1.0",
"vite-compatible-readable-stream": "^3.6.1"
}
},
"node_modules/@react-pdf/png-js": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-pdf/png-js/-/png-js-3.0.0.tgz",
"integrity": "sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA==",
"license": "MIT",
"dependencies": {
"browserify-zlib": "^0.2.0"
}
},
"node_modules/@react-pdf/primitives": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.1.1.tgz",
"integrity": "sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ==",
"license": "MIT"
},
"node_modules/@react-pdf/reconciler": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@react-pdf/reconciler/-/reconciler-1.1.4.tgz",
"integrity": "sha512-oTQDiR/t4Z/Guxac88IavpU2UgN7eR0RMI9DRKvKnvPz2DUasGjXfChAdMqDNmJJxxV26mMy9xQOUV2UU5/okg==",
"license": "MIT",
"dependencies": {
"object-assign": "^4.1.1",
"scheduler": "0.25.0-rc-603e6108-20241029"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-pdf/reconciler/node_modules/scheduler": {
"version": "0.25.0-rc-603e6108-20241029",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz",
"integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==",
"license": "MIT"
},
"node_modules/@react-pdf/render": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.3.0.tgz",
"integrity": "sha512-MdWfWaqO6d7SZD75TZ2z5L35V+cHpyA43YNRlJNG0RJ7/MeVGDQv12y/BXOJgonZKkeEGdzM3EpAt9/g4E22WA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.2",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/textkit": "^6.0.0",
"@react-pdf/types": "^2.9.0",
"abs-svg-path": "^0.1.1",
"color-string": "^1.9.1",
"normalize-svg-path": "^1.1.0",
"parse-svg-path": "^0.1.2",
"svg-arc-to-cubic-bezier": "^3.2.0"
}
},
"node_modules/@react-pdf/renderer": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.3.0.tgz",
"integrity": "sha512-28gpA69fU9ZQrDzmd5xMJa1bDf8t0PT3ApUKBl2PUpoE/x4JlvCB5X66nMXrfFrgF2EZrA72zWQAkvbg7TE8zw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.2",
"@react-pdf/font": "^4.0.2",
"@react-pdf/layout": "^4.4.0",
"@react-pdf/pdfkit": "^4.0.3",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/reconciler": "^1.1.4",
"@react-pdf/render": "^4.3.0",
"@react-pdf/types": "^2.9.0",
"events": "^3.3.0",
"object-assign": "^4.1.1",
"prop-types": "^15.6.2",
"queue": "^6.0.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-pdf/stylesheet": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.1.0.tgz",
"integrity": "sha512-BGZ2sYNUp38VJUegjva/jsri3iiRGnVNjWI+G9dTwAvLNOmwFvSJzqaCsEnqQ/DW5mrTBk/577FhDY7pv6AidA==",
"license": "MIT",
"dependencies": {
"@react-pdf/fns": "3.1.2",
"@react-pdf/types": "^2.9.0",
"color-string": "^1.9.1",
"hsl-to-hex": "^1.0.0",
"media-engine": "^1.0.3",
"postcss-value-parser": "^4.1.0"
}
},
"node_modules/@react-pdf/textkit": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-6.0.0.tgz",
"integrity": "sha512-fDt19KWaJRK/n2AaFoVm31hgGmpygmTV7LsHGJNGZkgzXcFyLsx+XUl63DTDPH3iqxj3xUX128t104GtOz8tTw==",
"license": "MIT",
"dependencies": {
"@react-pdf/fns": "3.1.2",
"bidi-js": "^1.0.2",
"hyphen": "^1.6.4",
"unicode-properties": "^1.4.1"
}
},
"node_modules/@react-pdf/types": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.9.0.tgz",
"integrity": "sha512-ckj80vZLlvl9oYrQ4tovEaqKWP3O06Eb1D48/jQWbdwz1Yh7Y9v1cEmwlP8ET+a1Whp8xfdM0xduMexkuPANCQ==",
"license": "MIT",
"dependencies": {
"@react-pdf/font": "^4.0.2",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/stylesheet": "^6.1.0"
}
},
"node_modules/@rollup/plugin-commonjs": {
"version": "28.0.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.1.tgz",
@@ -4418,6 +4610,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/mime-types": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz",
"integrity": "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==",
"license": "MIT"
},
"node_modules/@types/mysql": {
"version": "2.15.26",
"resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.26.tgz",
@@ -5090,6 +5288,12 @@
"node": ">=6.5"
}
},
"node_modules/abs-svg-path": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz",
"integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==",
"license": "MIT"
},
"node_modules/acorn": {
"version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
@@ -5534,6 +5738,26 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/better-auth": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.2.5.tgz",
@@ -5565,6 +5789,15 @@
"uncrypto": "^0.1.3"
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -5600,6 +5833,24 @@
"node": ">=8"
}
},
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/browserify-zlib": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
"license": "MIT",
"dependencies": {
"pako": "~1.0.5"
}
},
"node_modules/browserslist": {
"version": "4.24.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz",
@@ -5824,6 +6075,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -5934,6 +6194,12 @@
"node": ">= 8"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -6123,6 +6389,12 @@
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -6970,7 +7242,6 @@
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.8.x"
}
@@ -7113,6 +7384,23 @@
"dev": true,
"license": "ISC"
},
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -7166,6 +7454,27 @@
"integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==",
"license": "MIT"
},
"node_modules/form-data/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/form-data/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/formdata-node": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
@@ -7574,6 +7883,21 @@
"react-is": "^16.7.0"
}
},
"node_modules/hsl-to-hex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz",
"integrity": "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==",
"license": "MIT",
"dependencies": {
"hsl-to-rgb-for-reals": "^1.1.0"
}
},
"node_modules/hsl-to-rgb-for-reals": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz",
"integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==",
"license": "ISC"
},
"node_modules/html-to-text": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
@@ -7631,6 +7955,12 @@
"ms": "^2.0.0"
}
},
"node_modules/hyphen": {
"version": "1.10.6",
"resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.10.6.tgz",
"integrity": "sha512-fXHXcGFTXOvZTSkPJuGOQf5Lv5T/R2itiiCVPg9LxAje5D00O0pP83yJShFq5V89Ly//Gt6acj7z8pbBr34stw==",
"license": "ISC"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -8081,6 +8411,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/is-weakmap": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
@@ -8172,6 +8508,15 @@
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/jay-peg": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.1.1.tgz",
"integrity": "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==",
"license": "MIT",
"dependencies": {
"restructure": "^3.0.0"
}
},
"node_modules/jest-worker": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
@@ -8404,6 +8749,25 @@
"url": "https://github.com/sponsors/antonk52"
}
},
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -8495,7 +8859,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@@ -8537,6 +8900,12 @@
"node": ">= 0.4"
}
},
"node_modules/media-engine": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz",
"integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==",
"license": "MIT"
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@@ -8567,21 +8936,21 @@
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
"mime-db": "^1.54.0"
},
"engines": {
"node": ">= 0.6"
@@ -8835,6 +9204,15 @@
"node": ">=0.10.0"
}
},
"node_modules/normalize-svg-path": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
"integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==",
"license": "MIT",
"dependencies": {
"svg-arc-to-cubic-bezier": "^3.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -9101,6 +9479,12 @@
"node": ">=6"
}
},
"node_modules/parse-svg-path": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
"license": "MIT"
},
"node_modules/parseley": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
@@ -9511,7 +9895,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@@ -9568,6 +9951,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"license": "MIT",
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -9818,7 +10210,6 @@
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -9889,6 +10280,12 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT"
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -10780,6 +11177,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svg-arc-to-cubic-bezier": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz",
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==",
"license": "ISC"
},
"node_modules/tailwind-merge": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.0.2.tgz",
@@ -10956,6 +11359,12 @@
"node": ">=0.8"
}
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.12.tgz",
@@ -11201,6 +11610,32 @@
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"license": "MIT"
},
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unicode-trie/node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/unplugin": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.0.1.tgz",
@@ -11329,6 +11764,20 @@
}
}
},
"node_modules/vite-compatible-readable-stream": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz",
"integrity": "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/watchpack": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz",
@@ -11444,6 +11893,29 @@
"node": ">=4.0"
}
},
"node_modules/webpack/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
},
"node_modules/webpack/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
@@ -11702,6 +12174,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yoga-layout": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
"license": "MIT"
},
"node_modules/zod": {
"version": "3.24.2",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz",

View File

@@ -24,7 +24,9 @@
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-tooltip": "^1.1.8",
"@react-pdf/renderer": "^4.3.0",
"@sentry/nextjs": "^9.11.0",
"@types/mime-types": "^2.1.4",
"@types/sharp": "^0.31.1",
"better-auth": "^1.2.5",
"class-variance-authority": "^0.7.1",
@@ -32,6 +34,7 @@
"date-fns": "^3.6.0",
"jszip": "^3.10.1",
"lucide-react": "^0.475.0",
"mime-types": "^3.0.1",
"next": "^15.2.4",
"next-themes": "^0.4.4",
"openai": "^4.85.4",

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "business_address" TEXT,
ADD COLUMN "business_bank_details" TEXT,
ADD COLUMN "business_logo" TEXT,
ADD COLUMN "business_name" TEXT;

View File

@@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "app_data" (
"id" UUID NOT NULL,
"app" TEXT NOT NULL,
"user_id" UUID NOT NULL,
"data" JSONB NOT NULL,
CONSTRAINT "app_data_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "app_data_user_id_app_key" ON "app_data"("user_id", "app");
-- AddForeignKey
ALTER TABLE "app_data" ADD CONSTRAINT "app_data_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -32,8 +32,13 @@ model User {
storageUsed Int @default(0) @map("storage_used")
storageLimit Int @default(-1) @map("storage_limit")
aiBalance Int @default(0) @map("ai_balance")
businessName String? @map("business_name")
businessAddress String? @map("business_address")
businessBankDetails String? @map("business_bank_details")
businessLogo String? @map("business_logo")
accounts Account[]
sessions Session[]
appData AppData[]
@@map("users")
}
@@ -203,3 +208,14 @@ model Currency {
@@unique([userId, code])
@@map("currencies")
}
model AppData {
id String @id @default(uuid()) @db.Uuid
app String
userId String @map("user_id") @db.Uuid
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
data Json
@@unique([userId, app])
@@map("app_data")
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.