Migrate frontend contact forms from Formspree to SMTP backend
All checks were successful
Build and Push Docker Images / docker (push) Successful in 3m16s

- Replace Formspree integration with new backend email API
- Update Contact, OffsetOrder, and MobileOffsetOrder components
- Remove Formspree config from environment variables
- Add emailClient API for backend communication
- Centralize email sending through SMTP service

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Matt 2025-10-31 20:17:37 +01:00
parent 7bdd462be9
commit 37d861f9eb
7 changed files with 168 additions and 42 deletions

145
src/api/emailClient.ts Normal file
View File

@ -0,0 +1,145 @@
import axios from 'axios';
import { logger } from '../utils/logger';
// Get API base URL from runtime config (window.env) or build-time config
// IMPORTANT: Call this function at REQUEST TIME, not at module load time,
// to ensure window.env is populated by env-config.js
const getApiBaseUrl = (): string => {
// Check window.env first (runtime config from env.sh)
if (typeof window !== 'undefined' && window.env?.API_BASE_URL) {
return window.env.API_BASE_URL;
}
// Fall back to build-time env or production default
return import.meta.env.VITE_API_BASE_URL || 'https://puffinoffset.com/api';
};
export interface ContactEmailData {
name: string;
email: string;
phone?: string;
company?: string;
message: string;
}
export interface ReceiptEmailData {
customerEmail: string;
orderDetails: {
tons: number;
portfolioId: number;
baseAmount: number;
processingFee: number;
totalAmount: number;
orderId: string;
stripeSessionId: string;
};
}
export interface AdminNotificationData {
orderDetails: {
tons: number;
portfolioId: number;
totalAmount: number;
orderId: string;
};
customerEmail: string;
}
export interface EmailResponse {
success: boolean;
messageId: string;
message: string;
skipped?: boolean;
}
/**
* Send contact form email to admin
* @param data Contact form data
* @returns Email response with message ID
*/
export async function sendContactEmail(data: ContactEmailData): Promise<EmailResponse> {
try {
const apiBaseUrl = getApiBaseUrl();
logger.info('Sending contact form email:', { name: data.name, email: data.email });
logger.info('Using API base URL:', apiBaseUrl);
const response = await axios.post<EmailResponse>(
`${apiBaseUrl}/api/email/contact`,
data
);
logger.info('Contact email sent successfully:', response.data.messageId);
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
logger.error('Contact email failed:', error.response?.data || error.message);
throw new Error(error.response?.data?.error || 'Failed to send contact form');
}
throw error;
}
}
/**
* Send receipt email to customer
* @param data Receipt email data with order details
* @returns Email response with message ID
*/
export async function sendReceiptEmail(data: ReceiptEmailData): Promise<EmailResponse> {
try {
const apiBaseUrl = getApiBaseUrl();
logger.info('Sending receipt email to:', data.customerEmail);
logger.info('Order ID:', data.orderDetails.orderId);
const response = await axios.post<EmailResponse>(
`${apiBaseUrl}/api/email/receipt`,
data
);
logger.info('Receipt email sent successfully:', response.data.messageId);
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
logger.error('Receipt email failed:', error.response?.data || error.message);
throw new Error(error.response?.data?.error || 'Failed to send receipt email');
}
throw error;
}
}
/**
* Send admin notification for new order
* @param data Admin notification data
* @returns Email response with message ID
*/
export async function sendAdminNotification(
data: AdminNotificationData
): Promise<EmailResponse> {
try {
const apiBaseUrl = getApiBaseUrl();
logger.info('Sending admin notification for order:', data.orderDetails.orderId);
const response = await axios.post<EmailResponse>(
`${apiBaseUrl}/api/email/admin-notify`,
data
);
if (response.data.skipped) {
logger.info('Admin notification skipped (disabled in config)');
} else {
logger.info('Admin notification sent successfully:', response.data.messageId);
}
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
logger.error('Admin notification failed:', error.response?.data || error.message);
// Don't throw - admin notifications should not block user experience
logger.warn('Admin notification failure is non-fatal, continuing...');
return {
success: false,
messageId: '',
message: 'Admin notification failed (non-fatal)',
};
}
throw error;
}
}

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { Mail, Phone, Loader2, AlertCircle } from 'lucide-react';
import { validateEmail, sendFormspreeEmail } from '../utils/email';
import { validateEmail, sendContactFormEmail } from '../utils/email';
import { analytics } from '../utils/analytics';
export function Contact() {
@ -26,8 +26,8 @@ export function Contact() {
throw new Error('Please enter a valid email address');
}
// Send via Formspree
await sendFormspreeEmail(formData, 'contact');
// Send via SMTP backend
await sendContactFormEmail(formData, 'contact');
setSubmitted(true);
analytics.event('contact', 'form_submitted');

View File

@ -5,7 +5,7 @@ import { createOffsetOrder, getPortfolios } from '../api/wrenClient';
import type { OffsetOrder as OffsetOrderType, Portfolio, OffsetProject } from '../types';
import { currencies, formatCurrency } from '../utils/currencies';
import { config } from '../utils/config';
import { sendFormspreeEmail } from '../utils/email';
import { sendContactFormEmail } from '../utils/email';
import { RadialProgress } from './RadialProgress';
import { PortfolioDonutChart } from './PortfolioDonutChart';
import { getProjectColor } from '../utils/portfolioColors';
@ -248,7 +248,7 @@ export function MobileOffsetOrder({ tons, monetaryAmount, onBack }: Props) {
e.preventDefault();
setLoading(true);
try {
await sendFormspreeEmail(formData, 'offset');
await sendContactFormEmail(formData, 'contact');
setSuccess(true);
setCurrentStep('confirmation');
} catch (err) {

View File

@ -6,7 +6,7 @@ import { createCheckoutSession } from '../api/checkoutClient';
import type { OffsetOrder as OffsetOrderType, Portfolio, OffsetProject } from '../types';
import { currencies, formatCurrency } from '../utils/currencies';
import { config } from '../utils/config';
import { sendFormspreeEmail } from '../utils/email';
import { sendContactFormEmail } from '../utils/email';
import { logger } from '../utils/logger';
import { FormInput } from './forms/FormInput';
import { FormTextarea } from './forms/FormTextarea';
@ -247,7 +247,7 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
e.preventDefault();
setLoading(true);
try {
await sendFormspreeEmail(formData, 'offset');
await sendContactFormEmail(formData, 'contact');
setSuccess(true);
} catch (err) {
setError('Failed to send request. Please try again.');

View File

@ -1,7 +1,5 @@
interface Config {
wrenApiKey: string;
formspreeContactId: string;
formspreeOffsetId: string;
isProduction: boolean;
}
@ -31,14 +29,10 @@ const getEnv = (key: string): string => {
// Load environment variables
const wrenApiKey = getEnv('VITE_WREN_API_TOKEN');
const formspreeContactId = getEnv('VITE_FORMSPREE_CONTACT_ID');
const formspreeOffsetId = getEnv('VITE_FORMSPREE_OFFSET_ID');
// Initialize config
export const config: Config = {
wrenApiKey: wrenApiKey || '',
formspreeContactId: formspreeContactId || 'xkgovnby',
formspreeOffsetId: formspreeOffsetId || 'xvgzbory',
isProduction: import.meta.env.PROD === true
};

View File

@ -1,4 +1,5 @@
import { analytics } from './analytics';
import { sendContactEmail, type ContactEmailData } from '../api/emailClient';
interface EmailData {
name: string;
@ -14,7 +15,7 @@ export function validateEmail(email: string): boolean {
}
export function formatEmailContent(data: EmailData, type: 'contact' | 'offset'): { subject: string, body: string } {
const subject = type === 'contact'
const subject = type === 'contact'
? `Contact from ${data.name} - Puffin Offset`
: `Offset Request - ${data.name}`;
@ -36,32 +37,22 @@ export function sendEmail(to: string, subject: string, body: string): void {
window.location.href = mailtoUrl;
}
export async function sendFormspreeEmail(data: EmailData, type: 'contact' | 'offset'): Promise<void> {
const FORMSPREE_CONTACT_ID = 'xkgovnby'; // Contact form
const FORMSPREE_OFFSET_ID = 'xvgzbory'; // Offset request form
const formId = type === 'contact' ? FORMSPREE_CONTACT_ID : FORMSPREE_OFFSET_ID;
/**
* Send contact form email via SMTP backend
* @param data Contact form data
* @param type Email type (contact or offset) - currently only 'contact' is supported
*/
export async function sendContactFormEmail(data: EmailData, type: 'contact' | 'offset' = 'contact'): Promise<void> {
try {
const response = await fetch(`https://formspree.io/f/${formId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
...data,
_subject: type === 'contact'
? `Contact from ${data.name} - Puffin Offset`
: `Offset Request - ${data.name}`
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to send message');
}
const contactData: ContactEmailData = {
name: data.name,
email: data.email,
phone: data.phone,
company: data.company,
message: data.message,
};
await sendContactEmail(contactData);
analytics.event('email', 'sent', type);
} catch (error) {
analytics.error(error as Error, 'Email sending failed');

4
src/vite-env.d.ts vendored
View File

@ -2,8 +2,6 @@
interface ImportMetaEnv {
readonly VITE_WREN_API_TOKEN: string;
readonly VITE_FORMSPREE_CONTACT_ID: string;
readonly VITE_FORMSPREE_OFFSET_ID: string;
readonly PROD: boolean;
readonly DEV: boolean;
// Add index signature for dynamic access
@ -19,7 +17,5 @@ interface Window {
env?: {
[key: string]: string | undefined;
WREN_API_TOKEN?: string;
FORMSPREE_CONTACT_ID?: string;
FORMSPREE_OFFSET_ID?: string;
};
}