From a8eb3376fc3f6434e6d56351fa683f853a22eb21 Mon Sep 17 00:00:00 2001 From: Leon Serfaty G Date: Fri, 18 Jul 2025 03:28:15 +0000 Subject: [PATCH] perfect but the email template has to have an attached pdf where the es --- package-lock.json | 43 +++++ package.json | 1 + src/app/admin/settings/smtp/page.tsx | 112 +++++++++--- src/app/api/settings/smtp/route.ts | 45 +++++ .../cost-estimator/cost-estimator-form.tsx | 3 +- .../cost-estimator/step-11-results.tsx | 104 ++++++++++- src/lib/actions/send-estimate.ts | 165 ++++++++++++++++++ src/types.ts | 28 +++ 8 files changed, 468 insertions(+), 33 deletions(-) create mode 100644 src/app/api/settings/smtp/route.ts create mode 100644 src/lib/actions/send-estimate.ts create mode 100644 src/types.ts diff --git a/package-lock.json b/package-lock.json index e565af8..01dea43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "next": "15.3.3", "nodemailer": "^6.9.14", "patch-package": "^8.0.0", + "pdf-lib": "^1.17.1", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", @@ -2671,6 +2672,24 @@ "node": ">=14" } }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -8058,6 +8077,12 @@ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -8176,6 +8201,24 @@ "through": "~2.3" } }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-lib/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", diff --git a/package.json b/package.json index 9cc3384..6d8e5b6 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "next": "15.3.3", "nodemailer": "^6.9.14", "patch-package": "^8.0.0", + "pdf-lib": "^1.17.1", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", diff --git a/src/app/admin/settings/smtp/page.tsx b/src/app/admin/settings/smtp/page.tsx index c2d556f..5ac8930 100644 --- a/src/app/admin/settings/smtp/page.tsx +++ b/src/app/admin/settings/smtp/page.tsx @@ -1,22 +1,71 @@ 'use client'; +import { useState, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { useToast } from '@/hooks/use-toast'; +import { Skeleton } from '@/components/ui/skeleton'; + +async function getSmtpSettings(): Promise> { + const response = await fetch('/api/settings/smtp'); + if (!response.ok) throw new Error('Failed to fetch SMTP settings'); + return response.json(); +} + +async function updateSmtpSettings(settings: Record): Promise<{ success: boolean, message: string }> { + const response = await fetch('/api/settings/smtp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings), + }); + const data = await response.json(); + return { success: response.ok, message: data.message }; +} export default function SmtpSettingsPage() { const { toast } = useToast(); + const [settings, setSettings] = useState({ + 'smtp_server': '', + 'smtp_port': '', + 'smtp_username': '', + 'smtp_password': '' + }); + const [isLoading, setIsLoading] = useState(true); - const handleSaveChanges = () => { - // In a real app, you would save these settings. - // For now, we'll just show a success toast. - toast({ - title: 'Success!', - description: 'SMTP settings saved (simulation).', - }); + useEffect(() => { + async function loadSettings() { + setIsLoading(true); + try { + const fetchedSettings = await getSmtpSettings(); + setSettings(s => ({...s, ...fetchedSettings})); + } catch (error) { + toast({ variant: 'destructive', title: 'Error', description: 'Could not load SMTP settings.' }); + } finally { + setIsLoading(false); + } + } + loadSettings(); + }, [toast]); + + const handleChange = (e: React.ChangeEvent) => { + const { id, value } = e.target; + setSettings(prev => ({ ...prev, [id]: value })); + }; + + const handleSaveChanges = async () => { + try { + const result = await updateSmtpSettings(settings); + if (result.success) { + toast({ title: 'Success!', description: result.message }); + } else { + throw new Error(result.message); + } + } catch (error: any) { + toast({ variant: 'destructive', title: 'Error', description: error.message || "Failed to save settings." }); + } }; return ( @@ -35,27 +84,40 @@ export default function SmtpSettingsPage() { -
- - -
-
-
- - + {isLoading ? ( +
+ +
+ + +
+
-
- - -
-
-
- - -
+ ) : ( + <> +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ + )} - +
); } diff --git a/src/app/api/settings/smtp/route.ts b/src/app/api/settings/smtp/route.ts new file mode 100644 index 0000000..ac25aa4 --- /dev/null +++ b/src/app/api/settings/smtp/route.ts @@ -0,0 +1,45 @@ + +import { NextRequest, NextResponse } from 'next/server'; +import db from '@/lib/db'; + +export async function GET(req: NextRequest) { + try { + const stmt = db.prepare("SELECT key, value FROM settings WHERE key LIKE 'smtp_%'"); + const settings = stmt.all() as { key: string, value: string }[]; + const result = settings.reduce((acc, setting) => { + acc[setting.key] = setting.value; + return acc; + }, {} as Record); + return NextResponse.json(result); + } catch (error) { + console.error('Failed to get SMTP settings:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } +} + +export async function POST(req: NextRequest) { + try { + const settings = await req.json(); + + const requiredKeys = ['smtp_server', 'smtp_port', 'smtp_username', 'smtp_password']; + for(const key of requiredKeys) { + if(typeof settings[key] === 'undefined') { + return NextResponse.json({ message: `Missing required setting: ${key}` }, { status: 400 }); + } + } + + const stmt = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)'); + const transaction = db.transaction((settingsToSave) => { + for (const key in settingsToSave) { + stmt.run(key, settingsToSave[key]); + } + }); + + transaction(settings); + + return NextResponse.json({ message: 'SMTP settings updated successfully' }); + } catch (error) { + console.error('Failed to update SMTP settings:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/src/components/cost-estimator/cost-estimator-form.tsx b/src/components/cost-estimator/cost-estimator-form.tsx index 73f0bc2..0370985 100644 --- a/src/components/cost-estimator/cost-estimator-form.tsx +++ b/src/components/cost-estimator/cost-estimator-form.tsx @@ -267,6 +267,7 @@ export function CostEstimatorForm() { onReset={handleReset} customHours={customHours} readyMadeHours={readyMadeHours} + formData={formData} /> ) case 12: @@ -291,7 +292,7 @@ export function CostEstimatorForm() { const isResultsStep = currentStep === 11; return ( - + void; customHours: number; readyMadeHours: number; + formData: FormData; }; const designHoursMap = { custom: 60, mockups: 30, existing: 0 }; @@ -99,7 +113,50 @@ export const calculateTotalHours = (formData: FormData) => { return { customHours, readyMadeHours }; }; -export function Step11Results({ onReset, customHours, readyMadeHours }: Step11Props) { +export function Step11Results({ onReset, customHours, readyMadeHours, formData }: Step11Props) { + const [isPending, startTransition] = useTransition(); + const { toast } = useToast(); + const [dialogOpen, setDialogOpen] = useState(false); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + const name = form.get('name') as string; + const email = form.get('email') as string; + + if (!name || !email) { + toast({ + variant: "destructive", + title: "Missing Information", + description: "Please enter both your name and email.", + }); + return; + } + + startTransition(async () => { + const result = await sendEstimateEmail({ + name, + email, + customHours, + readyMadeHours, + formData, + }); + + if (result.success) { + toast({ + title: "Estimate Sent!", + description: "Check your email for the detailed estimate PDF.", + }); + setDialogOpen(false); + } else { + toast({ + variant: "destructive", + title: "Error Sending Email", + description: result.message || "An unexpected error occurred.", + }); + } + }); + }; return (
@@ -151,10 +208,43 @@ export function Step11Results({ onReset, customHours, readyMadeHours }: Step11Pr

If you need a detailed estimate for your project, we can send it to your email.

- + + + + + +
+ + Send Estimate + + Enter your name and email to receive the estimate PDF. + + +
+
+ + +
+
+ + +
+
+ + + +
+
+
diff --git a/src/lib/actions/send-estimate.ts b/src/lib/actions/send-estimate.ts new file mode 100644 index 0000000..1a2beb3 --- /dev/null +++ b/src/lib/actions/send-estimate.ts @@ -0,0 +1,165 @@ + +'use server'; + +import { z } from 'zod'; +import nodemailer from 'nodemailer'; +import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'; +import db from '@/lib/db'; +import { getEmailTemplate } from './email'; +import type { FormData } from '@/components/cost-estimator/cost-estimator-form'; + +const inputSchema = z.object({ + name: z.string().min(1), + email: z.string().email(), + customHours: z.number(), + readyMadeHours: z.number(), + formData: z.any(), // Keeping this flexible for now +}); + +type InputType = z.infer; + +// Helper to get SMTP settings from the database +async function getSmtpSettings() { + const stmt = db.prepare("SELECT key, value FROM settings WHERE key LIKE 'smtp_%'"); + const settings = stmt.all() as { key: string, value: string }[]; + const config = settings.reduce((acc, setting) => { + acc[setting.key.replace('smtp_', '')] = setting.value; + return acc; + }, {} as Record); + return { + host: config.server, + port: parseInt(config.port || '587', 10), + secure: parseInt(config.port || '587', 10) === 465, // true for 465, false for other ports + auth: { + user: config.username, + pass: config.password, + }, + }; +} + + +async function createEstimatePdf(data: InputType): Promise { + const { name, customHours, readyMadeHours, formData } = data; + const pdfDoc = await PDFDocument.create(); + const page = pdfDoc.addPage(); + const { width, height } = page.getSize(); + const font = await pdfDoc.embedFont(StandardFonts.Helvetica); + const boldFont = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + const brandColor = rgb(0.16, 0.47, 0.94); // #2979FF + + let y = height - 50; + + // Header + page.drawText('EstimateFlow', { x: 50, y, font: boldFont, size: 24, color: brandColor }); + y -= 20; + page.drawLine({ + start: { x: 50, y: y }, + end: { x: width - 50, y: y }, + thickness: 2, + color: brandColor, + }); + y -= 30; + + // Subheader + page.drawText(`Project Estimate for: ${name}`, { x: 50, y, font: boldFont, size: 18 }); + y -= 20; + page.drawText(`Date: ${new Date().toLocaleDateString()}`, { x: 50, y, font, size: 12, color: rgb(0.3, 0.3, 0.3) }); + y -= 40; + + // Summary + page.drawText('Estimate Summary', { x: 50, y, font: boldFont, size: 16 }); + y -= 25; + + const drawEstimateBox = (title: string, hours: number, description: string, boxY: number) => { + page.drawRectangle({ + x: 50, + y: boxY - 80, + width: width - 100, + height: 90, + borderColor: rgb(0.9, 0.9, 0.9), + borderWidth: 1, + color: rgb(0.98, 0.98, 0.98), + borderRadius: 5 + }); + page.drawText(title, { x: 65, y: boxY, font: boldFont, size: 14 }); + page.drawText(`${hours}+ hours`, { x: 65, y: boxY - 30, font: boldFont, size: 28, color: brandColor }); + page.drawText(description, { x: 65, y: boxY - 60, font, size: 10, color: rgb(0.4, 0.4, 0.4) }); + } + + drawEstimateBox('Custom Development', customHours, 'A fully custom solution tailored to your specific needs.', y); + y -= 110; + drawEstimateBox('Ready-Made Tools', readyMadeHours, 'Leveraging pre-built components for a faster turnaround.', y); + y -= 110; + + // Disclaimer + page.drawText('Disclaimer:', { x: 50, y, font: boldFont, size: 12 }); + y -= 20; + const disclaimer = 'This is a preliminary estimate based on the provided selections. The final cost may vary after a detailed project analysis and scope definition. Please contact us for a comprehensive quote.'; + page.drawText(disclaimer, { + x: 50, + y, + font, + size: 10, + color: rgb(0.5, 0.5, 0.5), + lineHeight: 14, + maxWidth: width - 100 + }); + + const pdfBytes = await pdfDoc.save(); + return Buffer.from(pdfBytes); +} + + +export async function sendEstimateEmail(data: InputType): Promise<{ success: boolean, message?: string }> { + const validation = inputSchema.safeParse(data); + if (!validation.success) { + return { success: false, message: 'Invalid data provided.' }; + } + + try { + const smtpSettings = await getSmtpSettings(); + if (!smtpSettings.host || !smtpSettings.auth.user || !smtpSettings.auth.pass) { + return { success: false, message: 'SMTP settings are not configured. Please configure them in the admin panel.' }; + } + + const transporter = nodemailer.createTransport(smtpSettings); + + await transporter.verify(); + + const pdfBuffer = await createEstimatePdf(validation.data); + const emailTemplate = await getEmailTemplate(); + + const mailOptions = { + from: `EstimateFlow <${smtpSettings.auth.user}>`, + to: validation.data.email, + subject: emailTemplate.subject.replace('[User Name]', validation.data.name), + html: emailTemplate.body.replace('[User Name]', validation.data.name).replace('[EstimateDetails]', ` +

+ Please find your detailed project estimate attached to this email as a PDF. +

+ `), + attachments: [ + { + filename: 'EstimateFlow_Project_Estimate.pdf', + content: pdfBuffer, + contentType: 'application/pdf', + }, + ], + }; + + await transporter.sendMail(mailOptions); + return { success: true }; + + } catch (error: any) { + console.error('Failed to send estimate email:', error); + + if (error.code === 'ECONNREFUSED') { + return { success: false, message: 'Could not connect to SMTP server. Check server address and port.' }; + } + if (error.code === 'EAUTH') { + return { success: false, message: 'SMTP authentication failed. Check username and password.' }; + } + + return { success: false, message: error.message || 'An unexpected error occurred while sending the email.' }; + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..e92996e --- /dev/null +++ b/src/types.ts @@ -0,0 +1,28 @@ + +export type IllustrationSelection = { + has2d: boolean; + is2dAnimated: 'static' | 'animated' | null; + has3d: boolean; + is3dAnimated: 'static' | 'animated' | null; +}; + +export type BrandingSelection = 'full-cycle' | 'brush-up' | 'logo-only' | 'none' | null; + +export type ShoppingCartSelection = { + hasCart: boolean | null; + multiplePurchases: boolean | null; +} + +export type FormData = { + projectType: 'website' | 'mobile-app' | 'platform' | null; + serviceType: 'entire-project' | 'development' | 'ui-ux-design' | 'identity-branding' | null; + projectStage: number; + designProcess: 'custom' | 'mockups' | 'existing' | null; + pageCount: number; + animatedElements: boolean | null; + illustrations: IllustrationSelection; + branding: BrandingSelection; + additionalFeatures: string[]; + shoppingCart: ShoppingCartSelection; + uiUxDesignProcess: 'custom' | 'concepts' | null; +};