mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-13 13:25:18 +00:00
feat: invoice generator
This commit is contained in:
@@ -12,7 +12,7 @@ export type AnalyzeAttachment = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const loadAttachmentsForAI = async (user: User, file: File): Promise<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)
|
const isFileExists = await fileExists(fullFilePath)
|
||||||
if (!isFileExists) {
|
if (!isFileExists) {
|
||||||
throw new Error("File not found on disk")
|
throw new Error("File not found on disk")
|
||||||
|
|||||||
27
app/(app)/apps/common.ts
Normal file
27
app/(app)/apps/common.ts
Normal 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
|
||||||
|
}
|
||||||
126
app/(app)/apps/invoices/actions.ts
Normal file
126
app/(app)/apps/invoices/actions.ts
Normal 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}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
278
app/(app)/apps/invoices/components/invoice-generator.tsx
Normal file
278
app/(app)/apps/invoices/components/invoice-generator.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
584
app/(app)/apps/invoices/components/invoice-page.tsx
Normal file
584
app/(app)/apps/invoices/components/invoice-page.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
403
app/(app)/apps/invoices/components/invoice-pdf.tsx
Normal file
403
app/(app)/apps/invoices/components/invoice-pdf.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
app/(app)/apps/invoices/default-templates.ts
Normal file
71
app/(app)/apps/invoices/default-templates.ts
Normal 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 },
|
||||||
|
]
|
||||||
|
}
|
||||||
7
app/(app)/apps/invoices/manifest.ts
Normal file
7
app/(app)/apps/invoices/manifest.ts
Normal 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: "🧾",
|
||||||
|
}
|
||||||
31
app/(app)/apps/invoices/page.tsx
Normal file
31
app/(app)/apps/invoices/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
app/(app)/apps/layout.tsx
Normal file
3
app/(app)/apps/layout.tsx
Normal 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
35
app/(app)/apps/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -97,7 +97,7 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const file of transactionFiles) {
|
for (const file of transactionFiles) {
|
||||||
const fullFilePath = await fullPathForFile(user, file)
|
const fullFilePath = fullPathForFile(user, file)
|
||||||
if (await fileExists(fullFilePath)) {
|
if (await fileExists(fullFilePath)) {
|
||||||
const fileData = await fs.readFile(fullFilePath)
|
const fileData = await fs.readFile(fullFilePath)
|
||||||
const fileExtension = path.extname(fullFilePath)
|
const fileExtension = path.extname(fullFilePath)
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
|
|
||||||
import { ActionState } from "@/lib/actions"
|
import { ActionState } from "@/lib/actions"
|
||||||
import { getCurrentUser, isSubscriptionExpired } from "@/lib/auth"
|
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 { createFile } from "@/models/files"
|
||||||
import { updateUser } from "@/models/users"
|
import { updateUser } from "@/models/users"
|
||||||
import { randomUUID } from "crypto"
|
import { randomUUID } from "crypto"
|
||||||
@@ -15,7 +21,7 @@ export async function uploadFilesAction(formData: FormData): Promise<ActionState
|
|||||||
const files = formData.getAll("files") as File[]
|
const files = formData.getAll("files") as File[]
|
||||||
|
|
||||||
// Make sure upload dir exists
|
// Make sure upload dir exists
|
||||||
const userUploadsDirectory = await getUserUploadsDirectory(user)
|
const userUploadsDirectory = getUserUploadsDirectory(user)
|
||||||
|
|
||||||
// Check limits
|
// Check limits
|
||||||
const totalFileSize = files.reduce((acc, file) => acc + file.size, 0)
|
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
|
// Save file to filesystem
|
||||||
const fileUuid = randomUUID()
|
const fileUuid = randomUUID()
|
||||||
const relativeFilePath = await unsortedFilePath(fileUuid, file.name)
|
const relativeFilePath = unsortedFilePath(fileUuid, file.name)
|
||||||
const arrayBuffer = await file.arrayBuffer()
|
const arrayBuffer = await file.arrayBuffer()
|
||||||
const buffer = Buffer.from(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 mkdir(path.dirname(fullFilePath), { recursive: true })
|
||||||
|
|
||||||
await writeFile(fullFilePath, buffer)
|
await writeFile(fullFilePath, buffer)
|
||||||
@@ -65,7 +71,7 @@ export async function uploadFilesAction(formData: FormData): Promise<ActionState
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Update user storage used
|
// Update user storage used
|
||||||
const storageUsed = await getDirectorySize(await getUserUploadsDirectory(user))
|
const storageUsed = await getDirectorySize(getUserUploadsDirectory(user))
|
||||||
await updateUser(user.id, { storageUsed })
|
await updateUser(user.id, { storageUsed })
|
||||||
|
|
||||||
console.log("uploadedFiles", uploadedFiles)
|
console.log("uploadedFiles", uploadedFiles)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ file
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if file exists
|
// Check if file exists
|
||||||
const fullFilePath = await fullPathForFile(user, file)
|
const fullFilePath = fullPathForFile(user, file)
|
||||||
const isFileExists = await fileExists(fullFilePath)
|
const isFileExists = await fileExists(fullFilePath)
|
||||||
if (!isFileExists) {
|
if (!isFileExists) {
|
||||||
return new NextResponse(`File not found on disk: ${file.path}`, { status: 404 })
|
return new NextResponse(`File not found on disk: ${file.path}`, { status: 404 })
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ file
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if file exists on disk
|
// Check if file exists on disk
|
||||||
const fullFilePath = await fullPathForFile(user, file)
|
const fullFilePath = fullPathForFile(user, file)
|
||||||
const isFileExists = await fileExists(fullFilePath)
|
const isFileExists = await fileExists(fullFilePath)
|
||||||
if (!isFileExists) {
|
if (!isFileExists) {
|
||||||
return new NextResponse(`File not found on disk: ${file.path}`, { status: 404 })
|
return new NextResponse(`File not found on disk: ${file.path}`, { status: 404 })
|
||||||
|
|||||||
35
app/(app)/files/static/[filename]/route.ts
Normal file
35
app/(app)/files/static/[filename]/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
name: user.name || "",
|
name: user.name || "",
|
||||||
email: user.email,
|
email: user.email,
|
||||||
avatar: user.avatar || undefined,
|
avatar: user.avatar || undefined,
|
||||||
|
membershipPlan: user.membershipPlan || "unlimited",
|
||||||
storageUsed: user.storageUsed || 0,
|
storageUsed: user.storageUsed || 0,
|
||||||
storageLimit: user.storageLimit || -1,
|
storageLimit: user.storageLimit || -1,
|
||||||
aiBalance: user.aiBalance || 0,
|
aiBalance: user.aiBalance || 0,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { userFormSchema } from "@/forms/users"
|
import { userFormSchema } from "@/forms/users"
|
||||||
import { ActionState } from "@/lib/actions"
|
import { ActionState } from "@/lib/actions"
|
||||||
import { getCurrentUser } from "@/lib/auth"
|
import { getCurrentUser } from "@/lib/auth"
|
||||||
|
import { uploadStaticImage } from "@/lib/uploads"
|
||||||
import { codeFromName, randomHexColor } from "@/lib/utils"
|
import { codeFromName, randomHexColor } from "@/lib/utils"
|
||||||
import { createCategory, deleteCategory, updateCategory } from "@/models/categories"
|
import { createCategory, deleteCategory, updateCategory } from "@/models/categories"
|
||||||
import { createCurrency, deleteCurrency, updateCurrency } from "@/models/currencies"
|
import { createCurrency, deleteCurrency, updateCurrency } from "@/models/currencies"
|
||||||
@@ -19,7 +20,7 @@ import { SettingsMap, updateSettings } from "@/models/settings"
|
|||||||
import { updateUser } from "@/models/users"
|
import { updateUser } from "@/models/users"
|
||||||
import { Prisma, User } from "@/prisma/client"
|
import { Prisma, User } from "@/prisma/client"
|
||||||
import { revalidatePath } from "next/cache"
|
import { revalidatePath } from "next/cache"
|
||||||
import { redirect } from "next/navigation"
|
import path from "path"
|
||||||
|
|
||||||
export async function saveSettingsAction(
|
export async function saveSettingsAction(
|
||||||
_prevState: ActionState<SettingsMap> | null,
|
_prevState: ActionState<SettingsMap> | null,
|
||||||
@@ -40,8 +41,7 @@ export async function saveSettingsAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/settings")
|
revalidatePath("/settings")
|
||||||
redirect("/settings")
|
return { success: true }
|
||||||
// return { success: true }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveProfileAction(
|
export async function saveProfileAction(
|
||||||
@@ -55,12 +55,47 @@ export async function saveProfileAction(
|
|||||||
return { success: false, error: validatedForm.error.message }
|
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, {
|
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")
|
revalidatePath("/settings/profile")
|
||||||
redirect("/settings/profile")
|
revalidatePath("/settings/business")
|
||||||
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addProjectAction(userId: string, data: Prisma.ProjectCreateInput) {
|
export async function addProjectAction(userId: string, data: Prisma.ProjectCreateInput) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { ActionState } from "@/lib/actions"
|
import { ActionState } from "@/lib/actions"
|
||||||
import { getCurrentUser } from "@/lib/auth"
|
import { getCurrentUser } from "@/lib/auth"
|
||||||
import { prisma } from "@/lib/db"
|
import { prisma } from "@/lib/db"
|
||||||
import { getUserUploadsDirectory } from "@/lib/files"
|
import { getUserUploadsDirectory, safePathJoin } from "@/lib/files"
|
||||||
import { MODEL_BACKUP, modelFromJSON } from "@/models/backups"
|
import { MODEL_BACKUP, modelFromJSON } from "@/models/backups"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import JSZip from "jszip"
|
import JSZip from "jszip"
|
||||||
@@ -22,7 +22,7 @@ export async function restoreBackupAction(
|
|||||||
formData: FormData
|
formData: FormData
|
||||||
): Promise<ActionState<BackupRestoreResult>> {
|
): Promise<ActionState<BackupRestoreResult>> {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
const userUploadsDirectory = await getUserUploadsDirectory(user)
|
const userUploadsDirectory = getUserUploadsDirectory(user)
|
||||||
const file = formData.get("file") as File
|
const file = formData.get("file") as File
|
||||||
|
|
||||||
if (!file || file.size === 0) {
|
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) {
|
for (const file of files) {
|
||||||
const filePathWithoutPrefix = path.normalize(file.path.replace(/^.*\/uploads\//, ""))
|
const filePathWithoutPrefix = path.normalize(file.path.replace(/^.*\/uploads\//, ""))
|
||||||
@@ -110,7 +110,7 @@ export async function restoreBackupAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fileContents = await zipFile.async("nodebuffer")
|
const fileContents = await zipFile.async("nodebuffer")
|
||||||
const fullFilePath = path.join(userUploadsDirectory, filePathWithoutPrefix)
|
const fullFilePath = safePathJoin(userUploadsDirectory, filePathWithoutPrefix)
|
||||||
if (!fullFilePath.startsWith(path.normalize(userUploadsDirectory))) {
|
if (!fullFilePath.startsWith(path.normalize(userUploadsDirectory))) {
|
||||||
console.error(`Attempted path traversal detected for file ${file.path}`)
|
console.error(`Attempted path traversal detected for file ${file.path}`)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const BACKUP_VERSION = "1.0"
|
|||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
const userUploadsDirectory = await getUserUploadsDirectory(user)
|
const userUploadsDirectory = getUserUploadsDirectory(user)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const zip = new JSZip()
|
const zip = new JSZip()
|
||||||
|
|||||||
14
app/(app)/settings/business/page.tsx
Normal file
14
app/(app)/settings/business/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -16,6 +16,10 @@ const settingsCategories = [
|
|||||||
title: "Profile & Plan",
|
title: "Profile & Plan",
|
||||||
href: "/settings/profile",
|
href: "/settings/profile",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Business Details",
|
||||||
|
href: "/settings/business",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "LLM settings",
|
title: "LLM settings",
|
||||||
href: "/settings/llm",
|
href: "/settings/llm",
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export default async function TransactionPage({ params }: { params: Promise<{ tr
|
|||||||
<Card className="flex items-stretch p-2 max-w-6xl">
|
<Card className="flex items-stretch p-2 max-w-6xl">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<FormTextarea
|
<FormTextarea
|
||||||
title=""
|
|
||||||
name="text"
|
name="text"
|
||||||
defaultValue={transaction.text || ""}
|
defaultValue={transaction.text || ""}
|
||||||
hideIfEmpty={true}
|
hideIfEmpty={true}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
getTransactionFileUploadPath,
|
getTransactionFileUploadPath,
|
||||||
getUserUploadsDirectory,
|
getUserUploadsDirectory,
|
||||||
isEnoughStorageToUploadFile,
|
isEnoughStorageToUploadFile,
|
||||||
|
safePathJoin,
|
||||||
} from "@/lib/files"
|
} from "@/lib/files"
|
||||||
import { updateField } from "@/models/fields"
|
import { updateField } from "@/models/fields"
|
||||||
import { createFile, deleteFile } from "@/models/files"
|
import { createFile, deleteFile } from "@/models/files"
|
||||||
@@ -114,7 +115,7 @@ export async function deleteTransactionFileAction(
|
|||||||
await deleteFile(fileId, user.id)
|
await deleteFile(fileId, user.id)
|
||||||
|
|
||||||
// Update user storage used
|
// Update user storage used
|
||||||
const storageUsed = await getDirectorySize(await getUserUploadsDirectory(user))
|
const storageUsed = await getDirectorySize(getUserUploadsDirectory(user))
|
||||||
await updateUser(user.id, { storageUsed })
|
await updateUser(user.id, { storageUsed })
|
||||||
|
|
||||||
revalidatePath(`/transactions/${transactionId}`)
|
revalidatePath(`/transactions/${transactionId}`)
|
||||||
@@ -136,7 +137,7 @@ export async function uploadTransactionFilesAction(formData: FormData): Promise<
|
|||||||
return { success: false, error: "Transaction not found" }
|
return { success: false, error: "Transaction not found" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const userUploadsDirectory = await getUserUploadsDirectory(user)
|
const userUploadsDirectory = getUserUploadsDirectory(user)
|
||||||
|
|
||||||
// Check limits
|
// Check limits
|
||||||
const totalFileSize = files.reduce((acc, file) => acc + file.size, 0)
|
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(
|
const fileRecords = await Promise.all(
|
||||||
files.map(async (file) => {
|
files.map(async (file) => {
|
||||||
const fileUuid = randomUUID()
|
const fileUuid = randomUUID()
|
||||||
const relativeFilePath = await getTransactionFileUploadPath(fileUuid, file.name, transaction)
|
const relativeFilePath = getTransactionFileUploadPath(fileUuid, file.name, transaction)
|
||||||
const arrayBuffer = await file.arrayBuffer()
|
const arrayBuffer = await file.arrayBuffer()
|
||||||
const buffer = Buffer.from(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 mkdir(path.dirname(fullFilePath), { recursive: true })
|
||||||
|
|
||||||
console.log("userUploadsDirectory", userUploadsDirectory)
|
|
||||||
console.log("relativeFilePath", relativeFilePath)
|
|
||||||
console.log("fullFilePath", fullFilePath)
|
|
||||||
|
|
||||||
await writeFile(fullFilePath, buffer)
|
await writeFile(fullFilePath, buffer)
|
||||||
|
|
||||||
// Create file record in database
|
// Create file record in database
|
||||||
@@ -194,7 +191,7 @@ export async function uploadTransactionFilesAction(formData: FormData): Promise<
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Update user storage used
|
// Update user storage used
|
||||||
const storageUsed = await getDirectorySize(await getUserUploadsDirectory(user))
|
const storageUsed = await getDirectorySize(getUserUploadsDirectory(user))
|
||||||
await updateUser(user.id, { storageUsed })
|
await updateUser(user.id, { storageUsed })
|
||||||
|
|
||||||
revalidatePath(`/transactions/${transactionId}`)
|
revalidatePath(`/transactions/${transactionId}`)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { transactionFormSchema } from "@/forms/transactions"
|
|||||||
import { ActionState } from "@/lib/actions"
|
import { ActionState } from "@/lib/actions"
|
||||||
import { getCurrentUser, isAiBalanceExhausted, isSubscriptionExpired } from "@/lib/auth"
|
import { getCurrentUser, isAiBalanceExhausted, isSubscriptionExpired } from "@/lib/auth"
|
||||||
import config from "@/lib/config"
|
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 { DEFAULT_PROMPT_ANALYSE_NEW_FILE } from "@/models/defaults"
|
||||||
import { deleteFile, getFileById, updateFile } from "@/models/files"
|
import { deleteFile, getFileById, updateFile } from "@/models/files"
|
||||||
import { createTransaction, updateTransactionFiles } from "@/models/transactions"
|
import { createTransaction, updateTransactionFiles } from "@/models/transactions"
|
||||||
@@ -99,13 +99,13 @@ export async function saveFileAsTransactionAction(
|
|||||||
const transaction = await createTransaction(user.id, validatedForm.data)
|
const transaction = await createTransaction(user.id, validatedForm.data)
|
||||||
|
|
||||||
// Move file to processed location
|
// Move file to processed location
|
||||||
const userUploadsDirectory = await getUserUploadsDirectory(user)
|
const userUploadsDirectory = getUserUploadsDirectory(user)
|
||||||
const originalFileName = path.basename(file.path)
|
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
|
// Move file to new location and name
|
||||||
const oldFullFilePath = path.join(userUploadsDirectory, path.normalize(file.path))
|
const oldFullFilePath = safePathJoin(userUploadsDirectory, file.path)
|
||||||
const newFullFilePath = path.join(userUploadsDirectory, path.normalize(newRelativeFilePath))
|
const newFullFilePath = safePathJoin(userUploadsDirectory, newRelativeFilePath)
|
||||||
await mkdir(path.dirname(newFullFilePath), { recursive: true })
|
await mkdir(path.dirname(newFullFilePath), { recursive: true })
|
||||||
await rename(path.resolve(oldFullFilePath), path.resolve(newFullFilePath))
|
await rename(path.resolve(oldFullFilePath), path.resolve(newFullFilePath))
|
||||||
|
|
||||||
|
|||||||
@@ -82,4 +82,10 @@
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export function ExportTransactionsDialog({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [exportFilters, setExportFilters] = useTransactionFilters()
|
const [exportFilters, setExportFilters] = useTransactionFilters()
|
||||||
const [exportFields, setExportFields] = useState<string[]>(
|
const [exportFields, setExportFields] = useState<string[]>(
|
||||||
fields.map((field) => (deselectedFields.includes(field.code) ? "" : field.code))
|
fields.map((field) => (deselectedFields.includes(field.code) ? "" : field.code))
|
||||||
@@ -41,6 +42,7 @@ export function ExportTransactionsDialog({
|
|||||||
const [includeAttachments, setIncludeAttachments] = useState(true)
|
const [includeAttachments, setIncludeAttachments] = useState(true)
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
|
setIsLoading(true)
|
||||||
router.push(
|
router.push(
|
||||||
`/export/transactions?${new URLSearchParams({
|
`/export/transactions?${new URLSearchParams({
|
||||||
search: exportFilters?.search || "",
|
search: exportFilters?.search || "",
|
||||||
@@ -53,6 +55,9 @@ export function ExportTransactionsDialog({
|
|||||||
includeAttachments: includeAttachments.toString(),
|
includeAttachments: includeAttachments.toString(),
|
||||||
}).toString()}`
|
}).toString()}`
|
||||||
)
|
)
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsLoading(false)
|
||||||
|
}, 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -155,8 +160,8 @@ export function ExportTransactionsDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="sm:justify-end">
|
<DialogFooter className="sm:justify-end">
|
||||||
<Button type="button" onClick={handleSubmit}>
|
<Button type="button" onClick={handleSubmit} disabled={isLoading}>
|
||||||
Export Transactions
|
{isLoading ? "Exporting..." : "Export Transactions"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -3,15 +3,15 @@ import { useMemo } from "react"
|
|||||||
import { FormSelect } from "./simple"
|
import { FormSelect } from "./simple"
|
||||||
|
|
||||||
export const FormSelectCurrency = ({
|
export const FormSelectCurrency = ({
|
||||||
title,
|
|
||||||
currencies,
|
currencies,
|
||||||
|
title,
|
||||||
emptyValue,
|
emptyValue,
|
||||||
placeholder,
|
placeholder,
|
||||||
hideIfEmpty = false,
|
hideIfEmpty = false,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
title: string
|
|
||||||
currencies: { code: string; name: string }[]
|
currencies: { code: string; name: string }[]
|
||||||
|
title?: string
|
||||||
emptyValue?: string
|
emptyValue?: string
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
hideIfEmpty?: boolean
|
hideIfEmpty?: boolean
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ import { Textarea } from "@/components/ui/textarea"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { SelectProps } from "@radix-ui/react-select"
|
import { SelectProps } from "@radix-ui/react-select"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
import { CalendarIcon } from "lucide-react"
|
import { CalendarIcon, Upload } from "lucide-react"
|
||||||
import { InputHTMLAttributes, TextareaHTMLAttributes, useState } from "react"
|
import { InputHTMLAttributes, TextareaHTMLAttributes, useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
type FormInputProps = InputHTMLAttributes<HTMLInputElement> & {
|
type FormInputProps = InputHTMLAttributes<HTMLInputElement> & {
|
||||||
title: string
|
title?: string
|
||||||
hideIfEmpty?: boolean
|
hideIfEmpty?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,40 +25,57 @@ export function FormInput({ title, hideIfEmpty = false, ...props }: FormInputPro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<label className="flex flex-col gap-1">
|
<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)} />
|
<Input {...props} className={cn("bg-background", props.className)} />
|
||||||
</label>
|
</label>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type FormTextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
type FormTextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
||||||
title: string
|
title?: string
|
||||||
hideIfEmpty?: boolean
|
hideIfEmpty?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormTextarea({ title, hideIfEmpty = false, ...props }: FormTextareaProps) {
|
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) {
|
if (hideIfEmpty && (!props.defaultValue || props.defaultValue.toString().trim() === "") && !props.value) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label className="flex flex-col gap-1">
|
<label className="flex flex-col gap-1">
|
||||||
<span className="text-sm font-medium">{title}</span>
|
{title && <span className="text-sm font-medium">{title}</span>}
|
||||||
<Textarea {...props} className={cn("bg-background", props.className)} />
|
<Textarea ref={textareaRef} {...props} className={cn("bg-background", props.className)} />
|
||||||
</label>
|
</label>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FormSelect = ({
|
export const FormSelect = ({
|
||||||
title,
|
|
||||||
items,
|
items,
|
||||||
|
title,
|
||||||
emptyValue,
|
emptyValue,
|
||||||
placeholder,
|
placeholder,
|
||||||
hideIfEmpty = false,
|
hideIfEmpty = false,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
title: string
|
|
||||||
items: Array<{ code: string; name: string; color?: string; badge?: string }>
|
items: Array<{ code: string; name: string; color?: string; badge?: string }>
|
||||||
|
title?: string
|
||||||
emptyValue?: string
|
emptyValue?: string
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
hideIfEmpty?: boolean
|
hideIfEmpty?: boolean
|
||||||
@@ -69,7 +86,7 @@ export const FormSelect = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="flex flex-col gap-1">
|
<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}>
|
<Select {...props}>
|
||||||
<SelectTrigger className="w-full min-w-[150px] bg-background">
|
<SelectTrigger className="w-full min-w-[150px] bg-background">
|
||||||
<SelectValue placeholder={placeholder} />
|
<SelectValue placeholder={placeholder} />
|
||||||
@@ -94,14 +111,14 @@ export const FormSelect = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const FormDate = ({
|
export const FormDate = ({
|
||||||
title,
|
|
||||||
name,
|
name,
|
||||||
|
title,
|
||||||
placeholder = "Select date",
|
placeholder = "Select date",
|
||||||
defaultValue,
|
defaultValue,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
title: string
|
|
||||||
name: string
|
name: string
|
||||||
|
title?: string
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
defaultValue?: Date
|
defaultValue?: Date
|
||||||
}) => {
|
}) => {
|
||||||
@@ -126,7 +143,7 @@ export const FormDate = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<label className="flex flex-col gap-1">
|
<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">
|
<div className="relative">
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
@@ -157,3 +174,61 @@ export const FormDate = ({
|
|||||||
</label>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
61
components/settings/business-settings-form.tsx
Normal file
61
components/settings/business-settings-form.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { saveProfileAction } from "@/app/(app)/settings/actions"
|
import { saveProfileAction } from "@/app/(app)/settings/actions"
|
||||||
import { FormError } from "@/components/forms/error"
|
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 { Button } from "@/components/ui/button"
|
||||||
import { User } from "@/prisma/client"
|
import { User } from "@/prisma/client"
|
||||||
import { CircleCheckBig } from "lucide-react"
|
import { CircleCheckBig } from "lucide-react"
|
||||||
@@ -15,7 +15,9 @@ export default function ProfileSettingsForm({ user }: { user: User }) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<form action={saveAction} className="space-y-4">
|
<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">
|
<div className="flex flex-row items-center gap-4">
|
||||||
<Button type="submit" disabled={pending}>
|
<Button type="submit" disabled={pending}>
|
||||||
|
|||||||
@@ -4,14 +4,16 @@ import {
|
|||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuGroup,
|
DropdownMenuGroup,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { SidebarMenuButton } from "@/components/ui/sidebar"
|
import { SidebarMenuButton } from "@/components/ui/sidebar"
|
||||||
import { UserProfile } from "@/lib/auth"
|
import { UserProfile } from "@/lib/auth"
|
||||||
import { authClient } from "@/lib/auth-client"
|
import { authClient } from "@/lib/auth-client"
|
||||||
|
import { PLANS } from "@/lib/stripe"
|
||||||
import { formatBytes } from "@/lib/utils"
|
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 Link from "next/link"
|
||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
@@ -40,26 +42,50 @@ export default function SidebarUser({ profile, isSelfHosted }: { profile: UserPr
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||||
side={"top"}
|
side="top"
|
||||||
align="center"
|
align="center"
|
||||||
sideOffset={4}
|
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>
|
<DropdownMenuGroup>
|
||||||
{/* <DropdownMenuItem>
|
|
||||||
<ThemeToggle />
|
|
||||||
</DropdownMenuItem> */}
|
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href="/settings/profile" className="flex items-center gap-2">
|
<Link href="/settings/profile" className="flex items-center gap-2">
|
||||||
<User className="h-4 w-4" />
|
<Sparkles />
|
||||||
Profile & Plan
|
<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>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href="/settings/profile" className="flex items-center gap-2">
|
<Link href="/settings" className="flex items-center gap-2">
|
||||||
<HardDrive className="h-4 w-4" />
|
<Settings className="h-4 w-4" />
|
||||||
Storage: {formatBytes(profile.storageUsed)}
|
Settings
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</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>
|
</DropdownMenuGroup>
|
||||||
{!isSelfHosted && (
|
{!isSelfHosted && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
import { UserProfile } from "@/lib/auth"
|
import { UserProfile } from "@/lib/auth"
|
||||||
import config from "@/lib/config"
|
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 Image from "next/image"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
@@ -72,7 +72,7 @@ export function AppSidebar({
|
|||||||
<SidebarMenuItemWithHighlight href="/dashboard">
|
<SidebarMenuItemWithHighlight href="/dashboard">
|
||||||
<SidebarMenuButton asChild>
|
<SidebarMenuButton asChild>
|
||||||
<Link href="/dashboard">
|
<Link href="/dashboard">
|
||||||
<LayoutDashboard />
|
<House />
|
||||||
<span>Home</span>
|
<span>Home</span>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
@@ -106,7 +106,14 @@ export function AppSidebar({
|
|||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItemWithHighlight>
|
</SidebarMenuItemWithHighlight>
|
||||||
|
<SidebarMenuItemWithHighlight href="/apps">
|
||||||
|
<SidebarMenuButton asChild>
|
||||||
|
<Link href="/apps">
|
||||||
|
<LayoutDashboard />
|
||||||
|
<span>Apps</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItemWithHighlight>
|
||||||
<SidebarMenuItemWithHighlight href="/settings">
|
<SidebarMenuItemWithHighlight href="/settings">
|
||||||
<SidebarMenuButton asChild>
|
<SidebarMenuButton asChild>
|
||||||
<Link href="/settings">
|
<Link href="/settings">
|
||||||
@@ -136,7 +143,7 @@ export function AppSidebar({
|
|||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton asChild>
|
<SidebarMenuButton asChild>
|
||||||
<Link href="https://vas3k.com/donate/" target="_blank">
|
<Link href="https://vas3k.com/donate/" target="_blank">
|
||||||
<Sparkles />
|
<Gift />
|
||||||
Thank the author
|
Thank the author
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
|
|||||||
@@ -2,5 +2,9 @@ import { z } from "zod"
|
|||||||
|
|
||||||
export const userFormSchema = z.object({
|
export const userFormSchema = z.object({
|
||||||
name: z.string().max(128).optional(),
|
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(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export type UserProfile = {
|
|||||||
name: string
|
name: string
|
||||||
email: string
|
email: string
|
||||||
avatar?: string
|
avatar?: string
|
||||||
|
membershipPlan: string
|
||||||
storageUsed: number
|
storageUsed: number
|
||||||
storageLimit: number
|
storageLimit: number
|
||||||
aiBalance: number
|
aiBalance: number
|
||||||
@@ -37,7 +38,7 @@ export const auth = betterAuth({
|
|||||||
updateAge: 24 * 60 * 60, // 24 hours
|
updateAge: 24 * 60 * 60, // 24 hours
|
||||||
cookieCache: {
|
cookieCache: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
maxAge: 24 * 60 * 60, // 24 hours
|
maxAge: 7 * 24 * 60 * 60, // 7 days
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
advanced: {
|
advanced: {
|
||||||
|
|||||||
@@ -30,6 +30,18 @@ const config = {
|
|||||||
},
|
},
|
||||||
upload: {
|
upload: {
|
||||||
acceptedMimeTypes: "image/*,.pdf,.doc,.docx,.xls,.xlsx",
|
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: {
|
selfHosted: {
|
||||||
isEnabled: env.SELF_HOSTED_MODE === "true",
|
isEnabled: env.SELF_HOSTED_MODE === "true",
|
||||||
|
|||||||
37
lib/files.ts
37
lib/files.ts
@@ -6,34 +6,39 @@ import config from "./config"
|
|||||||
export const FILE_UPLOAD_PATH = path.resolve(process.env.UPLOAD_PATH || "./uploads")
|
export const FILE_UPLOAD_PATH = path.resolve(process.env.UPLOAD_PATH || "./uploads")
|
||||||
export const FILE_UNSORTED_DIRECTORY_NAME = "unsorted"
|
export const FILE_UNSORTED_DIRECTORY_NAME = "unsorted"
|
||||||
export const FILE_PREVIEWS_DIRECTORY_NAME = "previews"
|
export const FILE_PREVIEWS_DIRECTORY_NAME = "previews"
|
||||||
|
export const FILE_STATIC_DIRECTORY_NAME = "static"
|
||||||
export const FILE_IMPORT_CSV_DIRECTORY_NAME = "csv"
|
export const FILE_IMPORT_CSV_DIRECTORY_NAME = "csv"
|
||||||
|
|
||||||
export async function getUserUploadsDirectory(user: User) {
|
export function getUserUploadsDirectory(user: User) {
|
||||||
return path.join(FILE_UPLOAD_PATH, user.email)
|
return safePathJoin(FILE_UPLOAD_PATH, user.email)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserPreviewsDirectory(user: User) {
|
export function getStaticDirectory(user: User) {
|
||||||
return path.join(FILE_UPLOAD_PATH, user.email, FILE_PREVIEWS_DIRECTORY_NAME)
|
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)
|
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> {
|
export function previewFilePath(fileUuid: string, page: number) {
|
||||||
return path.join(FILE_PREVIEWS_DIRECTORY_NAME, `${fileUuid}.${page}.webp`)
|
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 fileExtension = path.extname(filename)
|
||||||
const storedFileName = `${fileUuid}${fileExtension}`
|
const storedFileName = `${fileUuid}${fileExtension}`
|
||||||
return formatFilePath(storedFileName, transaction.issuedAt || new Date())
|
return formatFilePath(storedFileName, transaction.issuedAt || new Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fullPathForFile(user: User, file: File) {
|
export function fullPathForFile(user: User, file: File) {
|
||||||
const userUploadsDirectory = await getUserUploadsDirectory(user)
|
const userUploadsDirectory = getUserUploadsDirectory(user)
|
||||||
return path.join(userUploadsDirectory, path.normalize(file.path))
|
return safePathJoin(userUploadsDirectory, file.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFilePath(filename: string, date: Date, format = "{YYYY}/{MM}/{name}{ext}") {
|
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)
|
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) {
|
export async function fileExists(filePath: string) {
|
||||||
try {
|
try {
|
||||||
await access(path.normalize(filePath), constants.F_OK)
|
await access(path.normalize(filePath), constants.F_OK)
|
||||||
|
|||||||
@@ -1,27 +1,25 @@
|
|||||||
"use server"
|
"use server"
|
||||||
|
|
||||||
import { fileExists, getUserPreviewsDirectory } from "@/lib/files"
|
import { fileExists, getUserPreviewsDirectory, safePathJoin } from "@/lib/files"
|
||||||
import { User } from "@/prisma/client"
|
import { User } from "@/prisma/client"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import sharp from "sharp"
|
import sharp from "sharp"
|
||||||
|
import config from "../config"
|
||||||
const MAX_WIDTH = 1800
|
|
||||||
const MAX_HEIGHT = 1800
|
|
||||||
const QUALITY = 90
|
|
||||||
|
|
||||||
export async function resizeImage(
|
export async function resizeImage(
|
||||||
user: User,
|
user: User,
|
||||||
origFilePath: string,
|
origFilePath: string,
|
||||||
maxWidth: number = MAX_WIDTH,
|
maxWidth: number = config.upload.images.maxWidth,
|
||||||
maxHeight: number = MAX_HEIGHT
|
maxHeight: number = config.upload.images.maxHeight,
|
||||||
|
quality: number = config.upload.images.quality
|
||||||
): Promise<{ contentType: string; resizedPath: string }> {
|
): Promise<{ contentType: string; resizedPath: string }> {
|
||||||
try {
|
try {
|
||||||
const userPreviewsDirectory = await getUserPreviewsDirectory(user)
|
const userPreviewsDirectory = getUserPreviewsDirectory(user)
|
||||||
await fs.mkdir(userPreviewsDirectory, { recursive: true })
|
await fs.mkdir(userPreviewsDirectory, { recursive: true })
|
||||||
|
|
||||||
const basename = path.basename(origFilePath, path.extname(origFilePath))
|
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)) {
|
if (await fileExists(outputPath)) {
|
||||||
const metadata = await sharp(outputPath).metadata()
|
const metadata = await sharp(outputPath).metadata()
|
||||||
@@ -37,7 +35,7 @@ export async function resizeImage(
|
|||||||
fit: "inside",
|
fit: "inside",
|
||||||
withoutEnlargement: true,
|
withoutEnlargement: true,
|
||||||
})
|
})
|
||||||
.webp({ quality: QUALITY })
|
.webp({ quality: quality })
|
||||||
.toFile(outputPath)
|
.toFile(outputPath)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,26 +1,21 @@
|
|||||||
"use server"
|
"use server"
|
||||||
|
|
||||||
import { fileExists, getUserPreviewsDirectory } from "@/lib/files"
|
import { fileExists, getUserPreviewsDirectory, safePathJoin } from "@/lib/files"
|
||||||
import { User } from "@/prisma/client"
|
import { User } from "@/prisma/client"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { fromPath } from "pdf2pic"
|
import { fromPath } from "pdf2pic"
|
||||||
|
import config from "../config"
|
||||||
const MAX_PAGES = 10
|
|
||||||
const DPI = 150
|
|
||||||
const QUALITY = 90
|
|
||||||
const MAX_WIDTH = 1500
|
|
||||||
const MAX_HEIGHT = 1500
|
|
||||||
|
|
||||||
export async function pdfToImages(user: User, origFilePath: string): Promise<{ contentType: string; pages: string[] }> {
|
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 })
|
await fs.mkdir(userPreviewsDirectory, { recursive: true })
|
||||||
|
|
||||||
const basename = path.basename(origFilePath, path.extname(origFilePath))
|
const basename = path.basename(origFilePath, path.extname(origFilePath))
|
||||||
// Check if converted pages already exist
|
// Check if converted pages already exist
|
||||||
const existingPages: string[] = []
|
const existingPages: string[] = []
|
||||||
for (let i = 1; i <= MAX_PAGES; i++) {
|
for (let i = 1; i <= config.upload.pdfs.maxPages; i++) {
|
||||||
const convertedFilePath = path.join(userPreviewsDirectory, `${basename}.${i}.webp`)
|
const convertedFilePath = safePathJoin(userPreviewsDirectory, `${basename}.${i}.webp`)
|
||||||
if (await fileExists(convertedFilePath)) {
|
if (await fileExists(convertedFilePath)) {
|
||||||
existingPages.push(convertedFilePath)
|
existingPages.push(convertedFilePath)
|
||||||
} else {
|
} 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
|
// If not — convert the file as store in previews folder
|
||||||
const pdf2picOptions = {
|
const pdf2picOptions = {
|
||||||
density: DPI,
|
density: config.upload.pdfs.dpi,
|
||||||
saveFilename: basename,
|
saveFilename: basename,
|
||||||
savePath: userPreviewsDirectory,
|
savePath: userPreviewsDirectory,
|
||||||
format: "webp",
|
format: "webp",
|
||||||
quality: QUALITY,
|
quality: config.upload.pdfs.quality,
|
||||||
width: MAX_WIDTH,
|
width: config.upload.pdfs.maxWidth,
|
||||||
height: MAX_HEIGHT,
|
height: config.upload.pdfs.maxHeight,
|
||||||
preserveAspectRatio: true,
|
preserveAspectRatio: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
60
lib/uploads.ts
Normal file
60
lib/uploads.ts
Normal 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
|
||||||
|
}
|
||||||
42
lib/utils.ts
42
lib/utils.ts
@@ -8,14 +8,19 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatCurrency(total: number, currency: string) {
|
export function formatCurrency(total: number, currency: string, separator: string = "") {
|
||||||
return new Intl.NumberFormat(LOCALE, {
|
try {
|
||||||
style: "currency",
|
return new Intl.NumberFormat(LOCALE, {
|
||||||
currency: currency,
|
style: "currency",
|
||||||
minimumFractionDigits: 2,
|
currency: currency,
|
||||||
maximumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
useGrouping: true,
|
maximumFractionDigits: 2,
|
||||||
}).format(total / 100)
|
useGrouping: true,
|
||||||
|
}).format(total / 100)
|
||||||
|
} catch (error) {
|
||||||
|
// can happen with custom currencies and crypto
|
||||||
|
return `${currency} ${total / 100}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatBytes(bytes: number) {
|
export function formatBytes(bytes: number) {
|
||||||
@@ -49,3 +54,24 @@ export function codeFromName(name: string, maxLength: number = 16) {
|
|||||||
export function randomHexColor() {
|
export function randomHexColor() {
|
||||||
return "#" + Math.floor(Math.random() * 16777215).toString(16)
|
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
18
models/apps.ts
Normal 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
500
package-lock.json
generated
@@ -22,7 +22,9 @@
|
|||||||
"@radix-ui/react-separator": "^1.1.2",
|
"@radix-ui/react-separator": "^1.1.2",
|
||||||
"@radix-ui/react-slot": "^1.2.0",
|
"@radix-ui/react-slot": "^1.2.0",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
|
"@react-pdf/renderer": "^4.3.0",
|
||||||
"@sentry/nextjs": "^9.11.0",
|
"@sentry/nextjs": "^9.11.0",
|
||||||
|
"@types/mime-types": "^2.1.4",
|
||||||
"@types/sharp": "^0.31.1",
|
"@types/sharp": "^0.31.1",
|
||||||
"better-auth": "^1.2.5",
|
"better-auth": "^1.2.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -30,6 +32,7 @@
|
|||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^0.475.0",
|
"lucide-react": "^0.475.0",
|
||||||
|
"mime-types": "^3.0.1",
|
||||||
"next": "^15.2.4",
|
"next": "^15.2.4",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"openai": "^4.85.4",
|
"openai": "^4.85.4",
|
||||||
@@ -294,6 +297,15 @@
|
|||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.27.0",
|
"version": "7.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz",
|
"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"
|
"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": {
|
"node_modules/@rollup/plugin-commonjs": {
|
||||||
"version": "28.0.1",
|
"version": "28.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.1.tgz",
|
||||||
@@ -4418,6 +4610,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/mysql": {
|
||||||
"version": "2.15.26",
|
"version": "2.15.26",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.26.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.26.tgz",
|
||||||
@@ -5090,6 +5288,12 @@
|
|||||||
"node": ">=6.5"
|
"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": {
|
"node_modules/acorn": {
|
||||||
"version": "8.14.1",
|
"version": "8.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
|
||||||
@@ -5534,6 +5738,26 @@
|
|||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/better-auth": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.2.5.tgz",
|
||||||
@@ -5565,6 +5789,15 @@
|
|||||||
"uncrypto": "^0.1.3"
|
"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": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
@@ -5600,6 +5833,24 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.24.4",
|
"version": "4.24.4",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz",
|
||||||
@@ -5824,6 +6075,15 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
@@ -5934,6 +6194,12 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
@@ -6123,6 +6389,12 @@
|
|||||||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/didyoumean": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.8.x"
|
"node": ">=0.8.x"
|
||||||
}
|
}
|
||||||
@@ -7113,6 +7384,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/for-each": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||||
@@ -7166,6 +7454,27 @@
|
|||||||
"integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==",
|
"integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/formdata-node": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
|
||||||
@@ -7574,6 +7883,21 @@
|
|||||||
"react-is": "^16.7.0"
|
"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": {
|
"node_modules/html-to-text": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
||||||
@@ -7631,6 +7955,12 @@
|
|||||||
"ms": "^2.0.0"
|
"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": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -8081,6 +8411,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/is-weakmap": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
||||||
@@ -8172,6 +8508,15 @@
|
|||||||
"@pkgjs/parseargs": "^0.11.0"
|
"@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": {
|
"node_modules/jest-worker": {
|
||||||
"version": "27.5.1",
|
"version": "27.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
|
||||||
@@ -8404,6 +8749,25 @@
|
|||||||
"url": "https://github.com/sponsors/antonk52"
|
"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": {
|
"node_modules/lines-and-columns": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||||
@@ -8495,7 +8859,6 @@
|
|||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||||
@@ -8537,6 +8900,12 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/merge-stream": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||||
@@ -8567,21 +8936,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mime-db": {
|
"node_modules/mime-db": {
|
||||||
"version": "1.52.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mime-types": {
|
"node_modules/mime-types": {
|
||||||
"version": "2.1.35",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
|
||||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mime-db": "1.52.0"
|
"mime-db": "^1.54.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
@@ -8835,6 +9204,15 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -9101,6 +9479,12 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/parseley": {
|
||||||
"version": "0.12.1",
|
"version": "0.12.1",
|
||||||
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
|
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
|
||||||
@@ -9511,7 +9895,6 @@
|
|||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.4.0",
|
"loose-envify": "^1.4.0",
|
||||||
@@ -9568,6 +9951,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"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",
|
"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==",
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -9889,6 +10280,12 @@
|
|||||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
"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": {
|
"node_modules/reusify": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||||
@@ -10780,6 +11177,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/tailwind-merge": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.0.2.tgz",
|
||||||
@@ -10956,6 +11359,12 @@
|
|||||||
"node": ">=0.8"
|
"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": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.12",
|
"version": "0.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.12.tgz",
|
||||||
@@ -11201,6 +11610,32 @@
|
|||||||
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
|
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/unplugin": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.0.1.tgz",
|
"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": {
|
"node_modules/watchpack": {
|
||||||
"version": "2.4.2",
|
"version": "2.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz",
|
||||||
@@ -11444,6 +11893,29 @@
|
|||||||
"node": ">=4.0"
|
"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": {
|
"node_modules/whatwg-url": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
@@ -11702,6 +12174,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/zod": {
|
||||||
"version": "3.24.2",
|
"version": "3.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz",
|
||||||
|
|||||||
@@ -24,7 +24,9 @@
|
|||||||
"@radix-ui/react-separator": "^1.1.2",
|
"@radix-ui/react-separator": "^1.1.2",
|
||||||
"@radix-ui/react-slot": "^1.2.0",
|
"@radix-ui/react-slot": "^1.2.0",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
|
"@react-pdf/renderer": "^4.3.0",
|
||||||
"@sentry/nextjs": "^9.11.0",
|
"@sentry/nextjs": "^9.11.0",
|
||||||
|
"@types/mime-types": "^2.1.4",
|
||||||
"@types/sharp": "^0.31.1",
|
"@types/sharp": "^0.31.1",
|
||||||
"better-auth": "^1.2.5",
|
"better-auth": "^1.2.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -32,6 +34,7 @@
|
|||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^0.475.0",
|
"lucide-react": "^0.475.0",
|
||||||
|
"mime-types": "^3.0.1",
|
||||||
"next": "^15.2.4",
|
"next": "^15.2.4",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"openai": "^4.85.4",
|
"openai": "^4.85.4",
|
||||||
|
|||||||
@@ -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;
|
||||||
15
prisma/migrations/20250507100532_add_app_data/migration.sql
Normal file
15
prisma/migrations/20250507100532_add_app_data/migration.sql
Normal 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;
|
||||||
@@ -32,8 +32,13 @@ model User {
|
|||||||
storageUsed Int @default(0) @map("storage_used")
|
storageUsed Int @default(0) @map("storage_used")
|
||||||
storageLimit Int @default(-1) @map("storage_limit")
|
storageLimit Int @default(-1) @map("storage_limit")
|
||||||
aiBalance Int @default(0) @map("ai_balance")
|
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[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
|
appData AppData[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -203,3 +208,14 @@ model Currency {
|
|||||||
@@unique([userId, code])
|
@@unique([userId, code])
|
||||||
@@map("currencies")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
BIN
public/fonts/Inter/Inter-Black.otf
Normal file
BIN
public/fonts/Inter/Inter-Black.otf
Normal file
Binary file not shown.
BIN
public/fonts/Inter/Inter-BlackItalic.otf
Normal file
BIN
public/fonts/Inter/Inter-BlackItalic.otf
Normal file
Binary file not shown.
BIN
public/fonts/Inter/Inter-Bold.otf
Normal file
BIN
public/fonts/Inter/Inter-Bold.otf
Normal file
Binary file not shown.
BIN
public/fonts/Inter/Inter-BoldItalic.otf
Normal file
BIN
public/fonts/Inter/Inter-BoldItalic.otf
Normal file
Binary file not shown.
BIN
public/fonts/Inter/Inter-ExtraBold.otf
Normal file
BIN
public/fonts/Inter/Inter-ExtraBold.otf
Normal file
Binary file not shown.
BIN
public/fonts/Inter/Inter-ExtraBoldItalic.otf
Normal file
BIN
public/fonts/Inter/Inter-ExtraBoldItalic.otf
Normal file
Binary file not shown.
BIN
public/fonts/Inter/Inter-Italic.otf
Normal file
BIN
public/fonts/Inter/Inter-Italic.otf
Normal file
Binary file not shown.
BIN
public/fonts/Inter/Inter-Medium.otf
Normal file
BIN
public/fonts/Inter/Inter-Medium.otf
Normal file
Binary file not shown.
BIN
public/fonts/Inter/Inter-MediumItalic.otf
Normal file
BIN
public/fonts/Inter/Inter-MediumItalic.otf
Normal file
Binary file not shown.
BIN
public/fonts/Inter/Inter-Regular.otf
Normal file
BIN
public/fonts/Inter/Inter-Regular.otf
Normal file
Binary file not shown.
BIN
public/fonts/Inter/Inter-SemiBold.otf
Normal file
BIN
public/fonts/Inter/Inter-SemiBold.otf
Normal file
Binary file not shown.
BIN
public/fonts/Inter/Inter-SemiBoldItalic.otf
Normal file
BIN
public/fonts/Inter/Inter-SemiBoldItalic.otf
Normal file
Binary file not shown.
Reference in New Issue
Block a user