Migrate frontend contact forms from Formspree to SMTP backend
All checks were successful
Build and Push Docker Images / docker (push) Successful in 3m16s
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:
parent
7bdd462be9
commit
37d861f9eb
145
src/api/emailClient.ts
Normal file
145
src/api/emailClient.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Mail, Phone, Loader2, AlertCircle } from 'lucide-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';
|
import { analytics } from '../utils/analytics';
|
||||||
|
|
||||||
export function Contact() {
|
export function Contact() {
|
||||||
@ -26,8 +26,8 @@ export function Contact() {
|
|||||||
throw new Error('Please enter a valid email address');
|
throw new Error('Please enter a valid email address');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send via Formspree
|
// Send via SMTP backend
|
||||||
await sendFormspreeEmail(formData, 'contact');
|
await sendContactFormEmail(formData, 'contact');
|
||||||
|
|
||||||
setSubmitted(true);
|
setSubmitted(true);
|
||||||
analytics.event('contact', 'form_submitted');
|
analytics.event('contact', 'form_submitted');
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { createOffsetOrder, getPortfolios } from '../api/wrenClient';
|
|||||||
import type { OffsetOrder as OffsetOrderType, Portfolio, OffsetProject } from '../types';
|
import type { OffsetOrder as OffsetOrderType, Portfolio, OffsetProject } from '../types';
|
||||||
import { currencies, formatCurrency } from '../utils/currencies';
|
import { currencies, formatCurrency } from '../utils/currencies';
|
||||||
import { config } from '../utils/config';
|
import { config } from '../utils/config';
|
||||||
import { sendFormspreeEmail } from '../utils/email';
|
import { sendContactFormEmail } from '../utils/email';
|
||||||
import { RadialProgress } from './RadialProgress';
|
import { RadialProgress } from './RadialProgress';
|
||||||
import { PortfolioDonutChart } from './PortfolioDonutChart';
|
import { PortfolioDonutChart } from './PortfolioDonutChart';
|
||||||
import { getProjectColor } from '../utils/portfolioColors';
|
import { getProjectColor } from '../utils/portfolioColors';
|
||||||
@ -248,7 +248,7 @@ export function MobileOffsetOrder({ tons, monetaryAmount, onBack }: Props) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await sendFormspreeEmail(formData, 'offset');
|
await sendContactFormEmail(formData, 'contact');
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
setCurrentStep('confirmation');
|
setCurrentStep('confirmation');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { createCheckoutSession } from '../api/checkoutClient';
|
|||||||
import type { OffsetOrder as OffsetOrderType, Portfolio, OffsetProject } from '../types';
|
import type { OffsetOrder as OffsetOrderType, Portfolio, OffsetProject } from '../types';
|
||||||
import { currencies, formatCurrency } from '../utils/currencies';
|
import { currencies, formatCurrency } from '../utils/currencies';
|
||||||
import { config } from '../utils/config';
|
import { config } from '../utils/config';
|
||||||
import { sendFormspreeEmail } from '../utils/email';
|
import { sendContactFormEmail } from '../utils/email';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { FormInput } from './forms/FormInput';
|
import { FormInput } from './forms/FormInput';
|
||||||
import { FormTextarea } from './forms/FormTextarea';
|
import { FormTextarea } from './forms/FormTextarea';
|
||||||
@ -247,7 +247,7 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await sendFormspreeEmail(formData, 'offset');
|
await sendContactFormEmail(formData, 'contact');
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to send request. Please try again.');
|
setError('Failed to send request. Please try again.');
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
interface Config {
|
interface Config {
|
||||||
wrenApiKey: string;
|
wrenApiKey: string;
|
||||||
formspreeContactId: string;
|
|
||||||
formspreeOffsetId: string;
|
|
||||||
isProduction: boolean;
|
isProduction: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,14 +29,10 @@ const getEnv = (key: string): string => {
|
|||||||
|
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
const wrenApiKey = getEnv('VITE_WREN_API_TOKEN');
|
const wrenApiKey = getEnv('VITE_WREN_API_TOKEN');
|
||||||
const formspreeContactId = getEnv('VITE_FORMSPREE_CONTACT_ID');
|
|
||||||
const formspreeOffsetId = getEnv('VITE_FORMSPREE_OFFSET_ID');
|
|
||||||
|
|
||||||
// Initialize config
|
// Initialize config
|
||||||
export const config: Config = {
|
export const config: Config = {
|
||||||
wrenApiKey: wrenApiKey || '',
|
wrenApiKey: wrenApiKey || '',
|
||||||
formspreeContactId: formspreeContactId || 'xkgovnby',
|
|
||||||
formspreeOffsetId: formspreeOffsetId || 'xvgzbory',
|
|
||||||
isProduction: import.meta.env.PROD === true
|
isProduction: import.meta.env.PROD === true
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { analytics } from './analytics';
|
import { analytics } from './analytics';
|
||||||
|
import { sendContactEmail, type ContactEmailData } from '../api/emailClient';
|
||||||
|
|
||||||
interface EmailData {
|
interface EmailData {
|
||||||
name: string;
|
name: string;
|
||||||
@ -14,7 +15,7 @@ export function validateEmail(email: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatEmailContent(data: EmailData, type: 'contact' | 'offset'): { subject: string, body: string } {
|
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`
|
? `Contact from ${data.name} - Puffin Offset`
|
||||||
: `Offset Request - ${data.name}`;
|
: `Offset Request - ${data.name}`;
|
||||||
|
|
||||||
@ -36,32 +37,22 @@ export function sendEmail(to: string, subject: string, body: string): void {
|
|||||||
window.location.href = mailtoUrl;
|
window.location.href = mailtoUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendFormspreeEmail(data: EmailData, type: 'contact' | 'offset'): Promise<void> {
|
/**
|
||||||
const FORMSPREE_CONTACT_ID = 'xkgovnby'; // Contact form
|
* Send contact form email via SMTP backend
|
||||||
const FORMSPREE_OFFSET_ID = 'xvgzbory'; // Offset request form
|
* @param data Contact form data
|
||||||
|
* @param type Email type (contact or offset) - currently only 'contact' is supported
|
||||||
const formId = type === 'contact' ? FORMSPREE_CONTACT_ID : FORMSPREE_OFFSET_ID;
|
*/
|
||||||
|
export async function sendContactFormEmail(data: EmailData, type: 'contact' | 'offset' = 'contact'): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`https://formspree.io/f/${formId}`, {
|
const contactData: ContactEmailData = {
|
||||||
method: 'POST',
|
name: data.name,
|
||||||
headers: {
|
email: data.email,
|
||||||
'Content-Type': 'application/json',
|
phone: data.phone,
|
||||||
'Accept': 'application/json'
|
company: data.company,
|
||||||
},
|
message: data.message,
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
await sendContactEmail(contactData);
|
||||||
analytics.event('email', 'sent', type);
|
analytics.event('email', 'sent', type);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
analytics.error(error as Error, 'Email sending failed');
|
analytics.error(error as Error, 'Email sending failed');
|
||||||
|
|||||||
4
src/vite-env.d.ts
vendored
4
src/vite-env.d.ts
vendored
@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_WREN_API_TOKEN: string;
|
readonly VITE_WREN_API_TOKEN: string;
|
||||||
readonly VITE_FORMSPREE_CONTACT_ID: string;
|
|
||||||
readonly VITE_FORMSPREE_OFFSET_ID: string;
|
|
||||||
readonly PROD: boolean;
|
readonly PROD: boolean;
|
||||||
readonly DEV: boolean;
|
readonly DEV: boolean;
|
||||||
// Add index signature for dynamic access
|
// Add index signature for dynamic access
|
||||||
@ -19,7 +17,5 @@ interface Window {
|
|||||||
env?: {
|
env?: {
|
||||||
[key: string]: string | undefined;
|
[key: string]: string | undefined;
|
||||||
WREN_API_TOKEN?: string;
|
WREN_API_TOKEN?: string;
|
||||||
FORMSPREE_CONTACT_ID?: string;
|
|
||||||
FORMSPREE_OFFSET_ID?: string;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user