perfect but the email template has to have an attached pdf where the es

This commit is contained in:
Leon Serfaty G
2025-07-18 03:28:15 +00:00
parent 0b87cca169
commit a8eb3376fc
8 changed files with 468 additions and 33 deletions
+87 -25
View File
@@ -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<Record<string, string>> {
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<string, string>): 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<HTMLInputElement>) => {
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() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2">
<Label htmlFor="smtp-server">SMTP Server</Label>
<Input id="smtp-server" placeholder="smtp.example.com" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="smtp-port">Port</Label>
<Input id="smtp-port" placeholder="587" />
{isLoading ? (
<div className="space-y-4">
<Skeleton className="h-10 w-full" />
<div className="grid grid-cols-2 gap-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
<Skeleton className="h-10 w-full" />
</div>
<div className="grid gap-2">
<Label htmlFor="smtp-username">Username</Label>
<Input id="smtp-username" placeholder="your_username" />
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="smtp-password">Password</Label>
<Input id="smtp-password" type="password" placeholder="••••••••" />
</div>
) : (
<>
<div className="grid gap-2">
<Label htmlFor="smtp_server">SMTP Server</Label>
<Input id="smtp_server" placeholder="smtp.example.com" value={settings.smtp_server} onChange={handleChange} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="smtp_port">Port</Label>
<Input id="smtp_port" placeholder="587" value={settings.smtp_port} onChange={handleChange} />
</div>
<div className="grid gap-2">
<Label htmlFor="smtp_username">Username</Label>
<Input id="smtp_username" placeholder="your_username" value={settings.smtp_username} onChange={handleChange} />
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="smtp_password">Password</Label>
<Input id="smtp_password" type="password" placeholder="••••••••" value={settings.smtp_password} onChange={handleChange} />
</div>
</>
)}
</CardContent>
</Card>
<Button onClick={handleSaveChanges}>Save Changes</Button>
<Button onClick={handleSaveChanges} disabled={isLoading}>Save Changes</Button>
</div>
);
}
+45
View File
@@ -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<string, string>);
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 });
}
}
@@ -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 (
<Card className={`mt-8 w-full max-w-4xl shadow-2xl overflow-hidden ${isResultsStep ? 'bg-transparent border-none' : ''}`}>
<Card className={`mt-8 w-full max-w-4xl shadow-2xl overflow-hidden ${isResultsStep ? 'bg-transparent border-none shadow-none' : ''}`}>
<CardContent className={`p-4 sm:p-8 ${isResultsStep ? 'p-0 sm:p-0' : ''}`}>
<AnimatePresence mode="wait">
<motion.div
@@ -4,14 +4,28 @@
import type { FormData } from './cost-estimator-form';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Info, Mail, RefreshCw } from 'lucide-react';
import React from 'react';
import { Info, Mail } from 'lucide-react';
import React, { useState, useTransition } from 'react';
import { Tooltip, TooltipProvider, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useToast } from '@/hooks/use-toast';
import { sendEstimateEmail } from '@/lib/actions/send-estimate';
type Step11Props = {
onReset: () => 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<HTMLFormElement>) => {
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 (
<div className="flex flex-col items-center text-center">
@@ -151,10 +208,43 @@ export function Step11Results({ onReset, customHours, readyMadeHours }: Step11Pr
<p className="mt-6 text-sm text-muted-foreground">If you need a detailed estimate for your project, we can send it to your email.</p>
<div className="mt-8 flex items-center gap-4">
<Button size="lg" variant="outline">
<Mail className="mr-2 h-4 w-4" />
Send Estimate on Email
</Button>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button size="lg" variant="outline">
<Mail className="mr-2 h-4 w-4" />
Send Estimate on Email
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Send Estimate</DialogTitle>
<DialogDescription>
Enter your name and email to receive the estimate PDF.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name
</Label>
<Input id="name" name="name" className="col-span-3" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="email" className="text-right">
Email
</Label>
<Input id="email" name="email" type="email" className="col-span-3" required />
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isPending}>
{isPending ? "Sending..." : "Send to Email"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Button onClick={onReset} size="lg" variant="link" className="text-muted-foreground">
Start over
</Button>
+165
View File
@@ -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<typeof inputSchema>;
// 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<string, string>);
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<Buffer> {
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]', `
<p style="font-size: 16px; line-height: 1.6;">
Please find your detailed project estimate attached to this email as a PDF.
</p>
`),
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.' };
}
}
+28
View File
@@ -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;
};