From 8d925af2f94a2bdfa05cf00079915e7caa20b40e Mon Sep 17 00:00:00 2001 From: Jayrodri088 Date: Wed, 25 Feb 2026 15:51:39 +0100 Subject: [PATCH 1/2] feat: add invoice template system with custom branding --- app/api/cron/cancel-overdue-invoices/route.ts | 32 +- .../generate-subscription-invoices/route.ts | 32 +- .../routes-d/branding/logo-upload/route.ts | 60 ++ app/api/routes-d/branding/logo/route.ts | 75 ++ .../routes-d/branding/templates/[id]/route.ts | 198 +++++ app/api/routes-d/branding/templates/route.ts | 172 ++-- app/api/routes-d/bulk-invoices/_shared.ts | 25 + app/api/routes-d/invoices/[id]/pdf/route.ts | 63 +- components/settings/TemplateEditor.tsx | 739 ++++++++++++++++++ .../invoice-auto-cancelled.tsx | 119 ++- lib/email.ts | 21 +- lib/file-storage.ts | 73 ++ lib/invoice-renderer.tsx | 351 +++++++++ lib/pdf.tsx | 308 +------- package-lock.json | 55 +- prisma/schema.prisma | 69 +- 16 files changed, 1916 insertions(+), 476 deletions(-) create mode 100644 app/api/routes-d/branding/logo-upload/route.ts create mode 100644 app/api/routes-d/branding/logo/route.ts create mode 100644 app/api/routes-d/branding/templates/[id]/route.ts create mode 100644 components/settings/TemplateEditor.tsx create mode 100644 lib/invoice-renderer.tsx diff --git a/app/api/cron/cancel-overdue-invoices/route.ts b/app/api/cron/cancel-overdue-invoices/route.ts index 760d5a3..73ca3a5 100644 --- a/app/api/cron/cancel-overdue-invoices/route.ts +++ b/app/api/cron/cancel-overdue-invoices/route.ts @@ -28,7 +28,16 @@ export async function GET(request: Request) { escrowEnabled: false, }, include: { - user: true, + user: { + include: { + brandingSettings: true, + invoiceTemplates: { + where: { isDefault: true }, + orderBy: { createdAt: 'asc' }, + take: 1, + }, + }, + }, }, }) @@ -63,6 +72,26 @@ export async function GET(request: Request) { // Send cancellation email to freelancer if (invoice.user?.name && invoice.user?.email) { + const brandingSettings = invoice.user.brandingSettings + const defaultTemplate = invoice.user.invoiceTemplates?.[0] + + const branding = defaultTemplate + ? { + logoUrl: defaultTemplate.logoUrl ?? brandingSettings?.logoUrl ?? null, + primaryColor: defaultTemplate.primaryColor, + accentColor: defaultTemplate.accentColor, + footerText: + defaultTemplate.footerText ?? brandingSettings?.footerText ?? null, + } + : brandingSettings + ? { + logoUrl: brandingSettings.logoUrl ?? null, + primaryColor: brandingSettings.primaryColor, + accentColor: '#059669', + footerText: brandingSettings.footerText ?? null, + } + : undefined + const emailSent = await sendInvoiceCancelledEmail({ to: invoice.user.email, freelancerName: invoice.user.name, @@ -71,6 +100,7 @@ export async function GET(request: Request) { dueDate: invoice.dueDate!, daysOverdue, clientEmail: invoice.clientEmail, + branding, }) if (!emailSent || !emailSent.success) { diff --git a/app/api/cron/generate-subscription-invoices/route.ts b/app/api/cron/generate-subscription-invoices/route.ts index c9ab2a7..3c20d9b 100644 --- a/app/api/cron/generate-subscription-invoices/route.ts +++ b/app/api/cron/generate-subscription-invoices/route.ts @@ -23,7 +23,16 @@ export async function GET(request: Request) { }, }, include: { - user: true, + user: { + include: { + brandingSettings: true, + invoiceTemplates: { + where: { isDefault: true }, + orderBy: { createdAt: 'asc' }, + take: 1, + }, + }, + }, }, }) @@ -68,6 +77,26 @@ export async function GET(request: Request) { }) // Send notification email + const brandingSettings = sub.user.brandingSettings + const defaultTemplate = sub.user.invoiceTemplates?.[0] + + const branding = defaultTemplate + ? { + logoUrl: defaultTemplate.logoUrl ?? brandingSettings?.logoUrl ?? null, + primaryColor: defaultTemplate.primaryColor, + accentColor: defaultTemplate.accentColor, + footerText: + defaultTemplate.footerText ?? brandingSettings?.footerText ?? null, + } + : brandingSettings + ? { + logoUrl: brandingSettings.logoUrl ?? null, + primaryColor: brandingSettings.primaryColor, + accentColor: '#059669', + footerText: brandingSettings.footerText ?? null, + } + : undefined + await sendInvoiceCreatedEmail({ to: sub.clientEmail, clientName: sub.clientName || undefined, @@ -78,6 +107,7 @@ export async function GET(request: Request) { currency: invoice.currency, paymentLink: invoice.paymentLink, dueDate: invoice.dueDate!, + branding, }) // Log audit event diff --git a/app/api/routes-d/branding/logo-upload/route.ts b/app/api/routes-d/branding/logo-upload/route.ts new file mode 100644 index 0000000..0d5f69c --- /dev/null +++ b/app/api/routes-d/branding/logo-upload/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/db' +import { verifyAuthToken } from '@/lib/auth' +import { storeBrandingLogoFile, validateLogoFile } from '@/lib/file-storage' + +async function getAuthenticatedUser(request: NextRequest) { + const authToken = request.headers.get('authorization')?.replace('Bearer ', '') + if (!authToken) { + return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) as const } + } + + const claims = await verifyAuthToken(authToken) + if (!claims) { + return { error: NextResponse.json({ error: 'Invalid token' }, { status: 401 }) as const } + } + + const user = await prisma.user.findUnique({ where: { privyId: claims.userId } }) + if (!user) { + return { error: NextResponse.json({ error: 'User not found' }, { status: 404 }) as const } + } + + return { user } +} + +export async function POST(request: NextRequest) { + try { + const auth = await getAuthenticatedUser(request) + if ('error' in auth) return auth.error + + const { user } = auth + const formData = await request.formData() + const file = formData.get('logo') + + if (!(file instanceof File)) { + return NextResponse.json({ error: 'Logo file is required' }, { status: 400 }) + } + + const validation = validateLogoFile(file) + if (!validation.valid) { + return NextResponse.json( + { error: validation.error || 'Invalid logo file' }, + { status: 400 }, + ) + } + + const logoUrl = await storeBrandingLogoFile(user.id, file) + + return NextResponse.json({ logoUrl }, { status: 201 }) + } catch (error) { + console.error('Error uploading branding logo:', error) + return NextResponse.json( + { + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 }, + ) + } +} + diff --git a/app/api/routes-d/branding/logo/route.ts b/app/api/routes-d/branding/logo/route.ts new file mode 100644 index 0000000..7863640 --- /dev/null +++ b/app/api/routes-d/branding/logo/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/db' +import { verifyAuthToken } from '@/lib/auth' +import { getBrandingLogoAbsolutePath } from '@/lib/file-storage' +import { readFile } from 'fs/promises' +import { existsSync } from 'fs' +import path from 'path' + +const MIME_TYPES: Record = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.webp': 'image/webp', + '.heic': 'image/heic', +} + +export async function GET(request: NextRequest) { + try { + const authToken = request.headers.get('authorization')?.replace('Bearer ', '') + if (!authToken) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const claims = await verifyAuthToken(authToken) + if (!claims) { + return NextResponse.json({ error: 'Invalid token' }, { status: 401 }) + } + + const user = await prisma.user.findUnique({ + where: { privyId: claims.userId }, + }) + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + + const pathParam = request.nextUrl.searchParams.get('path') + if (!pathParam || pathParam.includes('..')) { + return NextResponse.json({ error: 'Invalid path' }, { status: 400 }) + } + + // path must be userId/filename so user can only access their own logos + const [pathUserId, ...rest] = pathParam.split('/') + if (pathUserId !== user.id || rest.length === 0) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const logoUrl = '/branding-logos/' + pathParam + const absolutePath = getBrandingLogoAbsolutePath(logoUrl) + if (!absolutePath) { + return NextResponse.json({ error: 'Invalid logo path' }, { status: 400 }) + } + + if (!existsSync(absolutePath)) { + return NextResponse.json({ error: 'Logo not found' }, { status: 404 }) + } + + const fileBuffer = await readFile(absolutePath) + const ext = path.extname(absolutePath).toLowerCase() + const contentType = MIME_TYPES[ext] || 'application/octet-stream' + + return new NextResponse(fileBuffer, { + headers: { + 'Content-Type': contentType, + 'Content-Disposition': `inline; filename="${path.basename(absolutePath)}"`, + 'Cache-Control': 'private, max-age=3600', + }, + }) + } catch (error) { + console.error('Branding logo serve error:', error) + return NextResponse.json( + { error: 'Failed to retrieve logo' }, + { status: 500 } + ) + } +} diff --git a/app/api/routes-d/branding/templates/[id]/route.ts b/app/api/routes-d/branding/templates/[id]/route.ts new file mode 100644 index 0000000..f597d70 --- /dev/null +++ b/app/api/routes-d/branding/templates/[id]/route.ts @@ -0,0 +1,198 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/db' +import { verifyAuthToken } from '@/lib/auth' +import { z } from 'zod' + +const hexColorSchema = z + .string() + .regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, 'Invalid hex color') + +const layoutSchema = z.enum(['modern', 'classic', 'minimal']) + +const updateTemplateSchema = z.object({ + name: z.string().min(1).max(100).optional(), + logoUrl: z.string().url().optional().nullable(), + primaryColor: hexColorSchema.optional(), + accentColor: hexColorSchema.optional(), + showLogo: z.boolean().optional(), + showFooter: z.boolean().optional(), + footerText: z.string().max(500).optional().nullable(), + layout: layoutSchema.optional(), + isDefault: z.boolean().optional(), +}) + +async function getAuthenticatedUser(request: NextRequest) { + const authToken = request.headers.get('authorization')?.replace('Bearer ', '') + if (!authToken) { + return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) as const } + } + + const claims = await verifyAuthToken(authToken) + if (!claims) { + return { error: NextResponse.json({ error: 'Invalid token' }, { status: 401 }) as const } + } + + const user = await prisma.user.findUnique({ where: { privyId: claims.userId } }) + if (!user) { + return { error: NextResponse.json({ error: 'User not found' }, { status: 404 }) as const } + } + + return { user } +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const auth = await getAuthenticatedUser(request) + if ('error' in auth) return auth.error + + const { user } = auth + const { id } = await params + + const template = await prisma.invoiceTemplate.findFirst({ + where: { id, userId: user.id }, + }) + + if (!template) { + return NextResponse.json({ error: 'Template not found' }, { status: 404 }) + } + + return NextResponse.json({ template }) + } catch (error) { + console.error('Error fetching invoice template:', error) + return NextResponse.json( + { + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 }, + ) + } +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const auth = await getAuthenticatedUser(request) + if ('error' in auth) return auth.error + + const { user } = auth + const { id } = await params + + const body = await request.json() + const parsed = updateTemplateSchema.safeParse(body) + + if (!parsed.success) { + const firstIssue = parsed.error.issues[0] + return NextResponse.json( + { + error: 'Validation failed', + message: firstIssue?.message ?? 'Invalid payload', + details: parsed.error.flatten().fieldErrors, + }, + { status: 400 }, + ) + } + + const data = parsed.data + + const existing = await prisma.invoiceTemplate.findFirst({ + where: { id, userId: user.id }, + }) + + if (!existing) { + return NextResponse.json({ error: 'Template not found' }, { status: 404 }) + } + + const shouldBeDefault = data.isDefault === true + + const template = await prisma.$transaction(async (tx) => { + if (shouldBeDefault) { + await tx.invoiceTemplate.updateMany({ + where: { userId: user.id, isDefault: true, id: { not: id } }, + data: { isDefault: false }, + }) + } + + return tx.invoiceTemplate.update({ + where: { id }, + data: { + ...(data.name !== undefined ? { name: data.name } : {}), + ...(data.logoUrl !== undefined ? { logoUrl: data.logoUrl } : {}), + ...(data.primaryColor !== undefined ? { primaryColor: data.primaryColor } : {}), + ...(data.accentColor !== undefined ? { accentColor: data.accentColor } : {}), + ...(data.showLogo !== undefined ? { showLogo: data.showLogo } : {}), + ...(data.showFooter !== undefined ? { showFooter: data.showFooter } : {}), + ...(data.footerText !== undefined ? { footerText: data.footerText } : {}), + ...(data.layout !== undefined ? { layout: data.layout } : {}), + ...(data.isDefault !== undefined ? { isDefault: shouldBeDefault } : {}), + }, + }) + }) + + return NextResponse.json({ template }) + } catch (error) { + console.error('Error updating invoice template:', error) + return NextResponse.json( + { + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 }, + ) + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const auth = await getAuthenticatedUser(request) + if ('error' in auth) return auth.error + + const { user } = auth + const { id } = await params + + const template = await prisma.invoiceTemplate.findFirst({ + where: { id, userId: user.id }, + }) + + if (!template) { + return NextResponse.json({ error: 'Template not found' }, { status: 404 }) + } + + await prisma.invoiceTemplate.delete({ where: { id } }) + + // Ensure user still has a default template if any remain + if (template.isDefault) { + const nextTemplate = await prisma.invoiceTemplate.findFirst({ + where: { userId: user.id }, + orderBy: { createdAt: 'asc' }, + }) + + if (nextTemplate) { + await prisma.invoiceTemplate.update({ + where: { id: nextTemplate.id }, + data: { isDefault: true }, + }) + } + } + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Error deleting invoice template:', error) + return NextResponse.json( + { + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 }, + ) + } +} + diff --git a/app/api/routes-d/branding/templates/route.ts b/app/api/routes-d/branding/templates/route.ts index a3c5382..c70be91 100644 --- a/app/api/routes-d/branding/templates/route.ts +++ b/app/api/routes-d/branding/templates/route.ts @@ -3,92 +3,140 @@ import { prisma } from '@/lib/db' import { verifyAuthToken } from '@/lib/auth' import { z } from 'zod' -const brandingSchema = z.object({ +const hexColorSchema = z + .string() + .regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, 'Invalid hex color') + +const layoutSchema = z.enum(['modern', 'classic', 'minimal']) + +const createTemplateSchema = z.object({ + name: z.string().min(1, 'Name is required').max(100, 'Name is too long'), logoUrl: z.string().url().optional().nullable(), - primaryColor: z.string().regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, 'Invalid hex color').optional(), + primaryColor: hexColorSchema.optional(), + accentColor: hexColorSchema.optional(), + showLogo: z.boolean().optional(), + showFooter: z.boolean().optional(), footerText: z.string().max(500).optional().nullable(), - signatureUrl: z.string().url().optional().nullable(), + layout: layoutSchema.optional(), + isDefault: z.boolean().optional(), }) +const updateTemplateSchema = createTemplateSchema.partial() + +async function getAuthenticatedUser(request: NextRequest) { + const authToken = request.headers.get('authorization')?.replace('Bearer ', '') + if (!authToken) { + return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) as const } + } + + const claims = await verifyAuthToken(authToken) + if (!claims) { + return { error: NextResponse.json({ error: 'Invalid token' }, { status: 401 }) as const } + } + + const user = await prisma.user.findUnique({ where: { privyId: claims.userId } }) + if (!user) { + return { error: NextResponse.json({ error: 'User not found' }, { status: 404 }) as const } + } + + return { user } +} + export async function GET(request: NextRequest) { try { - const authToken = request.headers.get('authorization')?.replace('Bearer ', '') - const claims = await verifyAuthToken(authToken || '') - if (!claims) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const auth = await getAuthenticatedUser(request) + if ('error' in auth) return auth.error - const user = await prisma.user.findUnique({ where: { privyId: claims.userId } }) - if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 }) + const { user } = auth - const branding = await prisma.brandingSettings.findUnique({ - where: { userId: user.id } + const templates = await prisma.invoiceTemplate.findMany({ + where: { userId: user.id }, + orderBy: { createdAt: 'asc' }, }) - return NextResponse.json({ - success: true, - branding: branding || { - logoUrl: null, - primaryColor: '#000000', - footerText: null, - signatureUrl: null, - } - }) + return NextResponse.json({ templates }) } catch (error) { - console.error('Error fetching branding settings:', error) - return NextResponse.json({ - error: 'Internal server error', - message: error instanceof Error ? error.message : 'Unknown error' - }, { status: 500 }) + console.error('Error fetching invoice templates:', error) + return NextResponse.json( + { + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 }, + ) } } -export async function PATCH(request: NextRequest) { +export async function POST(request: NextRequest) { try { - const authToken = request.headers.get('authorization')?.replace('Bearer ', '') - const claims = await verifyAuthToken(authToken || '') - if (!claims) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const auth = await getAuthenticatedUser(request) + if ('error' in auth) return auth.error + + const { user } = auth - const user = await prisma.user.findUnique({ where: { privyId: claims.userId } }) - if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 }) + const existingCount = await prisma.invoiceTemplate.count({ + where: { userId: user.id }, + }) + + if (existingCount >= 5) { + return NextResponse.json( + { error: 'You can only have up to 5 invoice templates' }, + { status: 400 }, + ) + } const body = await request.json() - const result = brandingSchema.safeParse(body) + const parsed = createTemplateSchema.safeParse(body) - if (!result.success) { - return NextResponse.json({ - error: 'Validation failed', - details: result.error.flatten().fieldErrors - }, { status: 400 }) + if (!parsed.success) { + const firstIssue = parsed.error.issues[0] + return NextResponse.json( + { + error: 'Validation failed', + message: firstIssue?.message ?? 'Invalid payload', + details: parsed.error.flatten().fieldErrors, + }, + { status: 400 }, + ) } - const { logoUrl, primaryColor, footerText, signatureUrl } = result.data + const data = parsed.data - const branding = await prisma.brandingSettings.upsert({ - where: { userId: user.id }, - update: { - logoUrl, - primaryColor, - footerText, - signatureUrl, - }, - create: { - userId: user.id, - logoUrl, - primaryColor: primaryColor || '#000000', - footerText, - signatureUrl, - }, - }) + const shouldBeDefault = data.isDefault === true || existingCount === 0 - return NextResponse.json({ - success: true, - message: 'Branding settings updated successfully', - branding + const template = await prisma.$transaction(async (tx) => { + if (shouldBeDefault) { + await tx.invoiceTemplate.updateMany({ + where: { userId: user.id, isDefault: true }, + data: { isDefault: false }, + }) + } + + return tx.invoiceTemplate.create({ + data: { + userId: user.id, + name: data.name, + logoUrl: data.logoUrl ?? null, + primaryColor: data.primaryColor ?? '#000000', + accentColor: data.accentColor ?? '#059669', + showLogo: data.showLogo ?? true, + showFooter: data.showFooter ?? true, + footerText: data.footerText ?? null, + layout: data.layout ?? 'modern', + isDefault: shouldBeDefault, + }, + }) }) + + return NextResponse.json({ template }, { status: 201 }) } catch (error) { - console.error('Error updating branding settings:', error) - return NextResponse.json({ - error: 'Internal server error', - message: error instanceof Error ? error.message : 'Unknown error' - }, { status: 500 }) + console.error('Error creating invoice template:', error) + return NextResponse.json( + { + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 }, + ) } } diff --git a/app/api/routes-d/bulk-invoices/_shared.ts b/app/api/routes-d/bulk-invoices/_shared.ts index 79b9f34..ec41759 100644 --- a/app/api/routes-d/bulk-invoices/_shared.ts +++ b/app/api/routes-d/bulk-invoices/_shared.ts @@ -193,6 +193,30 @@ export async function executeBulkInvoiceJob(params: { let successCount = 0 let failedCount = preResults.filter((r) => !r.success).length + const [brandingSettings, defaultTemplate] = await Promise.all([ + prisma.brandingSettings.findUnique({ where: { userId } }), + prisma.invoiceTemplate.findFirst({ + where: { userId, isDefault: true }, + orderBy: { createdAt: 'asc' }, + }), + ]) + + const emailBranding = defaultTemplate + ? { + logoUrl: defaultTemplate.logoUrl ?? brandingSettings?.logoUrl ?? null, + primaryColor: defaultTemplate.primaryColor, + accentColor: defaultTemplate.accentColor, + footerText: defaultTemplate.footerText ?? brandingSettings?.footerText ?? null, + } + : brandingSettings + ? { + logoUrl: brandingSettings.logoUrl ?? null, + primaryColor: brandingSettings.primaryColor, + accentColor: '#059669', + footerText: brandingSettings.footerText ?? null, + } + : undefined + for (const { index, invoice: inv } of items) { try { const invoiceNumber = await generateUniqueInvoiceNumber() @@ -236,6 +260,7 @@ export async function executeBulkInvoiceJob(params: { currency: invoice.currency, paymentLink: invoice.paymentLink, dueDate: invoice.dueDate, + branding: emailBranding, }) if (!emailRes.success) warning = 'Invoice created but email failed to send' } diff --git a/app/api/routes-d/invoices/[id]/pdf/route.ts b/app/api/routes-d/invoices/[id]/pdf/route.ts index bcc2097..1649888 100644 --- a/app/api/routes-d/invoices/[id]/pdf/route.ts +++ b/app/api/routes-d/invoices/[id]/pdf/route.ts @@ -1,7 +1,8 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/db' import { renderToBuffer } from '@react-pdf/renderer' -import { InvoicePDF } from '@/lib/pdf' +import { InvoicePDF, type InvoiceTemplateConfig } from '@/lib/invoice-renderer' +import { getBrandingLogoAbsolutePath } from '@/lib/file-storage' export async function GET( request: NextRequest, @@ -10,7 +11,7 @@ export async function GET( try { const { id } = await params - // Include brandingSettings in the user relation + // Include branding settings and invoice templates in the user relation const invoice = await prisma.invoice.findFirst({ where: { OR: [ @@ -21,7 +22,12 @@ export async function GET( include: { user: { include: { - brandingSettings: true + brandingSettings: true, + invoiceTemplates: { + where: { isDefault: true }, + orderBy: { createdAt: 'asc' }, + take: 1, + }, } } } @@ -52,12 +58,57 @@ export async function GET( const branding = user?.brandingSettings + let template: InvoiceTemplateConfig | undefined + + const defaultTemplate = user?.invoiceTemplates?.[0] + + if (defaultTemplate) { + const rawLogoUrl = defaultTemplate.logoUrl ?? branding?.logoUrl ?? null + const logoUrlForPdf = + rawLogoUrl && rawLogoUrl.startsWith('/branding-logos/') + ? getBrandingLogoAbsolutePath(rawLogoUrl) ?? rawLogoUrl + : rawLogoUrl + + template = { + id: defaultTemplate.id, + name: defaultTemplate.name, + logoUrl: logoUrlForPdf, + primaryColor: defaultTemplate.primaryColor, + accentColor: defaultTemplate.accentColor, + showLogo: defaultTemplate.showLogo, + showFooter: defaultTemplate.showFooter, + footerText: defaultTemplate.footerText ?? branding?.footerText ?? null, + layout: + (defaultTemplate.layout as 'modern' | 'classic' | 'minimal') ?? 'modern', + signatureUrl: branding?.signatureUrl ?? null, + } + } else if (branding) { + const rawLogoUrl = branding.logoUrl ?? null + const logoUrlForPdf = + rawLogoUrl && rawLogoUrl.startsWith('/branding-logos/') + ? getBrandingLogoAbsolutePath(rawLogoUrl) ?? rawLogoUrl + : rawLogoUrl + + // Backwards-compatible "implicit" template from branding settings + template = { + name: 'Default', + logoUrl: logoUrlForPdf, + primaryColor: branding.primaryColor ?? '#000000', + accentColor: '#059669', + showLogo: !!branding.logoUrl, + showFooter: true, + footerText: branding.footerText ?? null, + layout: 'modern', + signatureUrl: branding.signatureUrl ?? null, + } + } + // Generate PDF buffer const pdfBuffer = await renderToBuffer( - InvoicePDF({ + InvoicePDF({ invoice: invoiceData, - branding: branding || undefined - }) + template, + }), ) // Return PDF response (convert Buffer to Uint8Array for NextResponse) diff --git a/components/settings/TemplateEditor.tsx b/components/settings/TemplateEditor.tsx new file mode 100644 index 0000000..ea8895d --- /dev/null +++ b/components/settings/TemplateEditor.tsx @@ -0,0 +1,739 @@ +'use client' + +import { useEffect, useState, useRef } from 'react' +import { usePrivy } from '@privy-io/react-auth' + +type LayoutType = 'modern' | 'classic' | 'minimal' + +export interface InvoiceTemplate { + id: string + userId: string + name: string + isDefault: boolean + logoUrl: string | null + primaryColor: string + accentColor: string + showLogo: boolean + showFooter: boolean + footerText: string | null + layout: LayoutType + createdAt: string + updatedAt: string +} + +interface TemplateFormState { + name: string + logoUrl: string + primaryColor: string + accentColor: string + showLogo: boolean + showFooter: boolean + footerText: string + layout: LayoutType + isDefault: boolean +} + +const EMPTY_FORM: TemplateFormState = { + name: '', + logoUrl: '', + primaryColor: '#000000', + accentColor: '#059669', + showLogo: true, + showFooter: true, + footerText: '', + layout: 'modern', + isDefault: false, +} + +export function TemplateEditor() { + const { getAccessToken } = usePrivy() + + const [templates, setTemplates] = useState([]) + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [uploadingLogo, setUploadingLogo] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + + const [selectedTemplateId, setSelectedTemplateId] = useState(null) + const [form, setForm] = useState(EMPTY_FORM) + const [logoPreviewUrl, setLogoPreviewUrl] = useState(null) + const logoPreviewUrlRef = useRef(null) + + const selectedTemplate = templates.find((t) => t.id === selectedTemplateId) || null + + useEffect(() => { + void loadTemplates() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Resolve logo URL for preview: external URLs work as-is; /branding-logos/... must be fetched with auth + useEffect(() => { + const url = form.logoUrl?.trim() || null + if (!url) { + if (logoPreviewUrlRef.current) { + URL.revokeObjectURL(logoPreviewUrlRef.current) + logoPreviewUrlRef.current = null + } + setLogoPreviewUrl(null) + return + } + if (url.startsWith('http://') || url.startsWith('https://')) { + if (logoPreviewUrlRef.current) { + URL.revokeObjectURL(logoPreviewUrlRef.current) + logoPreviewUrlRef.current = null + } + setLogoPreviewUrl(url) + return + } + if (url.startsWith('/branding-logos/')) { + const pathPart = url.replace(/^\/branding-logos\//, '') + getAccessToken().then((token) => { + if (!token) { + setLogoPreviewUrl(null) + return + } + fetch(`/api/routes-d/branding/logo?path=${encodeURIComponent(pathPart)}`, { + headers: { Authorization: `Bearer ${token}` }, + }) + .then((r) => (r.ok ? r.blob() : null)) + .then((blob) => { + if (logoPreviewUrlRef.current) { + URL.revokeObjectURL(logoPreviewUrlRef.current) + logoPreviewUrlRef.current = null + } + if (blob) { + const objUrl = URL.createObjectURL(blob) + logoPreviewUrlRef.current = objUrl + setLogoPreviewUrl(objUrl) + } else { + setLogoPreviewUrl(null) + } + }) + .catch(() => setLogoPreviewUrl(null)) + }) + return + } + setLogoPreviewUrl(null) + }, [form.logoUrl, getAccessToken]) + + useEffect(() => { + return () => { + if (logoPreviewUrlRef.current) { + URL.revokeObjectURL(logoPreviewUrlRef.current) + } + } + }, []) + + async function authorizedFetch(input: RequestInfo, init?: RequestInit) { + const token = await getAccessToken() + if (!token) throw new Error('Not authenticated') + + const headers = new Headers(init?.headers) + headers.set('Authorization', `Bearer ${token}`) + + return fetch(input, { + ...init, + headers, + }) + } + + async function loadTemplates() { + try { + setLoading(true) + setError(null) + + const res = await authorizedFetch('/api/routes-d/branding/templates') + const data = await res.json() + + if (!res.ok) { + throw new Error(data.error || 'Failed to load templates') + } + + const loaded: InvoiceTemplate[] = data.templates || [] + setTemplates(loaded) + + const defaultTemplate = loaded.find((t) => t.isDefault) || loaded[0] + if (defaultTemplate) { + setSelectedTemplateId(defaultTemplate.id) + setForm(fromTemplate(defaultTemplate)) + } else { + setSelectedTemplateId(null) + setForm(EMPTY_FORM) + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load templates') + } finally { + setLoading(false) + } + } + + function fromTemplate(template: InvoiceTemplate): TemplateFormState { + return { + name: template.name, + logoUrl: template.logoUrl || '', + primaryColor: template.primaryColor || '#000000', + accentColor: template.accentColor || '#059669', + showLogo: template.showLogo, + showFooter: template.showFooter, + footerText: template.footerText || '', + layout: (template.layout as LayoutType) || 'modern', + isDefault: template.isDefault, + } + } + + function handleFieldChange( + key: K, + value: TemplateFormState[K], + ) { + setForm((prev) => ({ ...prev, [key]: value })) + setSuccess(null) + } + + function handleNewTemplate() { + setSelectedTemplateId(null) + setForm({ + ...EMPTY_FORM, + name: `Template ${templates.length + 1}`, + }) + setSuccess(null) + } + + async function handleSave(e: React.FormEvent) { + e.preventDefault() + setSaving(true) + setError(null) + setSuccess(null) + + try { + const payload = { + name: form.name.trim(), + logoUrl: form.logoUrl || null, + primaryColor: form.primaryColor, + accentColor: form.accentColor, + showLogo: form.showLogo, + showFooter: form.showFooter, + footerText: form.footerText.trim() || null, + layout: form.layout, + isDefault: form.isDefault, + } + + let res: Response + if (selectedTemplateId) { + res = await authorizedFetch( + `/api/routes-d/branding/templates/${selectedTemplateId}`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }, + ) + } else { + res = await authorizedFetch('/api/routes-d/branding/templates', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }) + } + + const data = await res.json() + if (!res.ok) { + throw new Error(data.message || data.error || 'Failed to save template') + } + + await loadTemplates() + setSuccess('Template saved') + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save template') + } finally { + setSaving(false) + } + } + + async function handleSetDefault(id: string) { + try { + setSaving(true) + setError(null) + setSuccess(null) + + const res = await authorizedFetch(`/api/routes-d/branding/templates/${id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ isDefault: true }), + }) + const data = await res.json() + if (!res.ok) { + throw new Error(data.message || data.error || 'Failed to set default template') + } + + await loadTemplates() + setSuccess('Default template updated') + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to set default template') + } finally { + setSaving(false) + } + } + + async function handleDelete(id: string) { + if (!confirm('Delete this template? This cannot be undone.')) return + + try { + setSaving(true) + setError(null) + setSuccess(null) + + const res = await authorizedFetch(`/api/routes-d/branding/templates/${id}`, { + method: 'DELETE', + }) + const data = await res.json() + if (!res.ok) { + throw new Error(data.message || data.error || 'Failed to delete template') + } + + await loadTemplates() + setSuccess('Template deleted') + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete template') + } finally { + setSaving(false) + } + } + + async function handleLogoUpload(file: File | null) { + if (!file) return + + try { + setUploadingLogo(true) + setError(null) + setSuccess(null) + + if (file.size > 2 * 1024 * 1024) { + throw new Error('Logo too large (max 2MB)') + } + + const formData = new FormData() + formData.append('logo', file) + + const res = await authorizedFetch('/api/routes-d/branding/logo-upload', { + method: 'POST', + body: formData, + }) + const data = await res.json() + + if (!res.ok) { + throw new Error(data.message || data.error || 'Failed to upload logo') + } + + if (typeof data.logoUrl === 'string') { + setForm((prev) => ({ ...prev, logoUrl: data.logoUrl })) + setSuccess('Logo uploaded') + } else { + throw new Error('Upload response did not include logoUrl') + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to upload logo') + } finally { + setUploadingLogo(false) + } + } + + const maxTemplatesReached = templates.length >= 5 + + const previewPrimary = form.primaryColor || '#111827' + const previewAccent = form.accentColor || '#059669' + + return ( +
+
+
+

+ Invoice Templates +

+

+ Create reusable invoice layouts with your logo, colors, and footer copy. +

+
+ +
+ + {error && ( +
+ {error} +
+ )} + + {success && ( +
+ {success} +
+ )} + +
+ {/* Templates list */} +
+
+ + {templates.length} / 5 templates used + +
+ + {loading && ( +
+ Loading templates… +
+ )} + + {!loading && templates.length === 0 && ( +
+ No templates yet. Create your first branded invoice template. +
+ )} + +
+ {templates.map((t) => ( + + )} + +
+
+ + ))} +
+
+ + {/* Editor + live preview */} +
+
+
+
+ + handleFieldChange('name', e.target.value)} + className="w-full rounded-lg border border-brand-border px-3 py-2 text-sm outline-none focus:border-brand-black" + placeholder="Client-ready invoice" + /> +
+ +
+ + +
+
+ +
+
+ +
+ handleFieldChange('primaryColor', e.target.value)} + className="h-9 w-9 cursor-pointer rounded border border-brand-border bg-transparent" + /> + handleFieldChange('primaryColor', e.target.value)} + className="flex-1 rounded-lg border border-brand-border px-3 py-2 text-sm font-mono outline-none focus:border-brand-black" + /> +
+
+ +
+ +
+ handleFieldChange('accentColor', e.target.value)} + className="h-9 w-9 cursor-pointer rounded border border-brand-border bg-transparent" + /> + handleFieldChange('accentColor', e.target.value)} + className="flex-1 rounded-lg border border-brand-border px-3 py-2 text-sm font-mono outline-none focus:border-brand-black" + /> +
+
+
+ +
+ +
+ void handleLogoUpload(e.target.files?.[0] || null)} + className="text-xs" + /> + + Max 2MB • JPG, PNG, WEBP, HEIC + + {uploadingLogo && ( + Uploading… + )} +
+ {form.logoUrl && ( +
+
+ {logoPreviewUrl && ( + /* eslint-disable-next-line @next/next/no-img-element */ + Logo preview + )} +
+ + {form.logoUrl} + +
+ )} +
+ +
+ +