Implement comprehensive email templates with SMTP integration
Some checks failed
Build and Push Docker Images / docker (push) Has been cancelled
Some checks failed
Build and Push Docker Images / docker (push) Has been cancelled
- Add beautiful HTML email templates for receipts, admin notifications, and contact forms - Implement SMTP email service with Nodemailer and Handlebars templating - Add carbon equivalency calculations with EPA/DEFRA/IMO 2024 conversion factors - Add portfolio color palette system for project visualization - Integrate Wren API portfolio fetching in webhook handler - Add light mode enforcement for email client compatibility - Include Puffin logo from MinIO S3 in all templates - Add test email endpoint for template validation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
d40b1a6853
commit
7bdd462be9
133
DNS_TROUBLESHOOTING.md
Normal file
133
DNS_TROUBLESHOOTING.md
Normal file
@ -0,0 +1,133 @@
|
||||
# DNS Troubleshooting Guide for Puffin Backend
|
||||
|
||||
## Current Issue
|
||||
Backend container cannot resolve `api.wren.co` despite DNS servers (8.8.8.8, 8.8.4.4) configured in docker-compose.yml.
|
||||
|
||||
## Diagnostic Steps
|
||||
|
||||
### 1. Verify DNS Config in Running Container
|
||||
```bash
|
||||
docker exec puffin-backend cat /etc/resolv.conf
|
||||
```
|
||||
**Expected**: Should show `nameserver 8.8.8.8` and `nameserver 8.8.4.4`
|
||||
**If not**: Container wasn't recreated properly
|
||||
|
||||
### 2. Test DNS Resolution from Container
|
||||
```bash
|
||||
# Test with nslookup (if available)
|
||||
docker exec puffin-backend nslookup api.wren.co
|
||||
|
||||
# Test with getent (usually available)
|
||||
docker exec puffin-backend getent hosts api.wren.co
|
||||
|
||||
# Test with wget
|
||||
docker exec puffin-backend wget -O- --timeout=5 https://api.wren.co/v1/offset_orders 2>&1 | head -20
|
||||
```
|
||||
|
||||
### 3. Check if Host Can Resolve DNS
|
||||
```bash
|
||||
# On the host machine
|
||||
nslookup api.wren.co
|
||||
ping api.wren.co
|
||||
```
|
||||
**If host can't resolve**: Host DNS issue, not Docker issue
|
||||
|
||||
### 4. Check Docker Daemon DNS Configuration
|
||||
```bash
|
||||
# Check Docker daemon config
|
||||
cat /etc/docker/daemon.json
|
||||
|
||||
# Check Docker network DNS
|
||||
docker network inspect puffin-network | grep -A 5 "IPAM"
|
||||
```
|
||||
|
||||
### 5. Test with Different DNS Servers
|
||||
Try Cloudflare DNS instead of Google:
|
||||
```yaml
|
||||
dns:
|
||||
- 1.1.1.1
|
||||
- 1.0.0.1
|
||||
```
|
||||
|
||||
### 6. Check Firewall Rules
|
||||
```bash
|
||||
# Check if firewall is blocking DNS from containers
|
||||
sudo iptables -L -n | grep -i dns
|
||||
sudo ufw status verbose
|
||||
|
||||
# Temporarily disable firewall to test (BE CAREFUL)
|
||||
sudo ufw disable
|
||||
# Test, then re-enable:
|
||||
sudo ufw enable
|
||||
```
|
||||
|
||||
### 7. Check Docker Network Isolation
|
||||
```bash
|
||||
# Check if Docker bridge has internet access
|
||||
docker run --rm busybox ping -c 3 8.8.8.8
|
||||
docker run --rm busybox nslookup api.wren.co 8.8.8.8
|
||||
```
|
||||
|
||||
## Solutions to Try
|
||||
|
||||
### Solution 1: Use Host Network Mode (Testing Only)
|
||||
**WARNING**: Less secure, only for testing
|
||||
```yaml
|
||||
backend:
|
||||
network_mode: "host"
|
||||
# Remove 'networks' and 'ports' when using host mode
|
||||
```
|
||||
|
||||
### Solution 2: Update Docker Daemon DNS
|
||||
Edit `/etc/docker/daemon.json`:
|
||||
```json
|
||||
{
|
||||
"dns": ["8.8.8.8", "8.8.4.4"]
|
||||
}
|
||||
```
|
||||
Then restart Docker:
|
||||
```bash
|
||||
sudo systemctl restart docker
|
||||
```
|
||||
|
||||
### Solution 3: Use Host's DNS Resolver
|
||||
```yaml
|
||||
backend:
|
||||
dns:
|
||||
- 8.8.8.8
|
||||
- 8.8.4.4
|
||||
extra_hosts:
|
||||
- "api.wren.co:HOST_IP_HERE"
|
||||
```
|
||||
|
||||
### Solution 4: Disable Docker's Userland Proxy
|
||||
Edit `/etc/docker/daemon.json`:
|
||||
```json
|
||||
{
|
||||
"userland-proxy": false,
|
||||
"dns": ["8.8.8.8", "8.8.4.4"]
|
||||
}
|
||||
```
|
||||
|
||||
### Solution 5: Force Recreate with Network Cleanup
|
||||
```bash
|
||||
# Stop everything
|
||||
docker-compose down
|
||||
|
||||
# Remove network
|
||||
docker network rm puffin-network
|
||||
|
||||
# Recreate with proper DNS
|
||||
docker-compose up -d --force-recreate
|
||||
```
|
||||
|
||||
## Current Status
|
||||
- ✅ DNS servers added to docker-compose.yml (8.8.8.8, 8.8.4.4)
|
||||
- ✅ Stripe webhooks working (proves network connectivity works)
|
||||
- ❌ DNS resolution failing with ENOTFOUND api.wren.co
|
||||
- ❌ Error occurs after only 26ms (DNS query not reaching nameservers)
|
||||
|
||||
## Next Actions
|
||||
1. Run diagnostic commands above to identify exact failure point
|
||||
2. Check if issue is container-specific or host-wide
|
||||
3. Apply appropriate solution based on findings
|
||||
BIN
public/puffinOffset.png
Normal file
BIN
public/puffinOffset.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 239 KiB |
@ -4,6 +4,8 @@ import cors from 'cors';
|
||||
import { initializeDatabase } from './config/database.js';
|
||||
import checkoutRoutes from './routes/checkout.js';
|
||||
import webhookRoutes from './routes/webhooks.js';
|
||||
import emailRoutes from './routes/email.js';
|
||||
import { verifyConnection, closeTransporter } from './utils/emailService.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
@ -12,6 +14,10 @@ const PORT = process.env.PORT || 3001;
|
||||
console.log('🗄️ Initializing database...');
|
||||
initializeDatabase();
|
||||
|
||||
// Verify SMTP connection
|
||||
console.log('📧 Verifying SMTP connection...');
|
||||
verifyConnection();
|
||||
|
||||
// CORS configuration
|
||||
const corsOptions = {
|
||||
origin: (origin, callback) => {
|
||||
@ -54,6 +60,7 @@ app.get('/health', (req, res) => {
|
||||
|
||||
// API Routes
|
||||
app.use('/api/checkout', checkoutRoutes);
|
||||
app.use('/api/email', emailRoutes);
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
@ -78,12 +85,16 @@ app.listen(PORT, () => {
|
||||
console.log(`🌐 Frontend URL: ${process.env.FRONTEND_URL}`);
|
||||
console.log(`🔑 Stripe configured: ${!!process.env.STRIPE_SECRET_KEY}`);
|
||||
console.log(`🌱 Wren API configured: ${!!process.env.WREN_API_TOKEN}`);
|
||||
console.log(`📧 SMTP configured: ${!!process.env.SMTP_PASSWORD}`);
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('');
|
||||
console.log('📝 Available endpoints:');
|
||||
console.log(` GET http://localhost:${PORT}/health`);
|
||||
console.log(` POST http://localhost:${PORT}/api/checkout/create-session`);
|
||||
console.log(` GET http://localhost:${PORT}/api/checkout/session/:sessionId`);
|
||||
console.log(` POST http://localhost:${PORT}/api/email/contact`);
|
||||
console.log(` POST http://localhost:${PORT}/api/email/receipt`);
|
||||
console.log(` POST http://localhost:${PORT}/api/email/admin-notify`);
|
||||
console.log(` POST http://localhost:${PORT}/api/webhooks/stripe`);
|
||||
console.log('');
|
||||
console.log('🎣 Webhook events handled:');
|
||||
@ -97,10 +108,12 @@ app.listen(PORT, () => {
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('👋 SIGTERM received, shutting down gracefully...');
|
||||
closeTransporter();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('👋 SIGINT received, shutting down gracefully...');
|
||||
closeTransporter();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
94
server/package-lock.json
generated
94
server/package-lock.json
generated
@ -14,6 +14,9 @@
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^8.2.0",
|
||||
"handlebars": "^4.7.8",
|
||||
"nodemailer": "^7.0.10",
|
||||
"stripe": "^17.5.0"
|
||||
}
|
||||
},
|
||||
@ -501,6 +504,24 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.0.tgz",
|
||||
"integrity": "sha512-zDLb8RsXoA09dui1mvm/bAqSYeUh/bj3+fcDeiNBebSbSjl9IEK5mbCSYSRk52Lrco9sj9Xjuzkot3TXuXEw0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "10.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
@ -649,6 +670,27 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/handlebars": {
|
||||
"version": "4.7.8",
|
||||
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
|
||||
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.5",
|
||||
"neo-async": "^2.6.2",
|
||||
"source-map": "^0.6.1",
|
||||
"wordwrap": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"handlebars": "bin/handlebars"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.7"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"uglify-js": "^3.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
@ -748,6 +790,15 @@
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
||||
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@ -874,6 +925,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/neo-async": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
|
||||
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.79.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.79.0.tgz",
|
||||
@ -886,6 +943,15 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "7.0.10",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.10.tgz",
|
||||
"integrity": "sha512-Us/Se1WtT0ylXgNFfyFSx4LElllVLJXQjWi2Xz17xWw7amDKO2MLtFnVp1WACy7GkVGs+oBlRopVNUzlrGSw1w==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@ -1281,6 +1347,15 @@
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
@ -1383,6 +1458,19 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/uglify-js": {
|
||||
"version": "3.19.3",
|
||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
||||
"integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"uglifyjs": "bin/uglifyjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
@ -1422,6 +1510,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/wordwrap": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
|
||||
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
|
||||
@ -17,11 +17,14 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"stripe": "^17.5.0",
|
||||
"express": "^4.21.2",
|
||||
"axios": "^1.6.7",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"axios": "^1.6.7"
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^8.2.0",
|
||||
"handlebars": "^4.7.8",
|
||||
"nodemailer": "^7.0.10",
|
||||
"stripe": "^17.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
187
server/routes/email.js
Normal file
187
server/routes/email.js
Normal file
@ -0,0 +1,187 @@
|
||||
import express from 'express';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { sendContactEmail, sendReceiptEmail, sendAdminNotification } from '../utils/emailService.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Rate limiters for different endpoints
|
||||
const contactLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 5, // 5 requests per minute
|
||||
message: { error: 'Too many contact form submissions. Please try again later.' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
const receiptLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 10, // 10 receipts per minute
|
||||
message: { error: 'Too many receipt requests. Please try again later.' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
// Validation helper
|
||||
function validateEmail(email) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
// POST /api/email/contact - Send contact form to admin
|
||||
router.post('/contact', contactLimiter, async (req, res) => {
|
||||
try {
|
||||
const { name, email, phone, company, message } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!name || !email || !message) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields: name, email, and message are required'
|
||||
});
|
||||
}
|
||||
|
||||
if (!validateEmail(email)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid email address'
|
||||
});
|
||||
}
|
||||
|
||||
if (message.length < 10) {
|
||||
return res.status(400).json({
|
||||
error: 'Message must be at least 10 characters long'
|
||||
});
|
||||
}
|
||||
|
||||
if (message.length > 5000) {
|
||||
return res.status(400).json({
|
||||
error: 'Message must be less than 5000 characters'
|
||||
});
|
||||
}
|
||||
|
||||
// Send email
|
||||
const result = await sendContactEmail({
|
||||
name: name.trim(),
|
||||
email: email.trim().toLowerCase(),
|
||||
phone: phone?.trim() || '',
|
||||
company: company?.trim() || '',
|
||||
message: message.trim()
|
||||
});
|
||||
|
||||
console.log(`✅ Contact form email sent from ${email}`);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
messageId: result.messageId,
|
||||
message: 'Contact form submitted successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Contact form email failed:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to send contact form. Please try again later.',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/email/receipt - Send receipt to customer
|
||||
router.post('/receipt', receiptLimiter, async (req, res) => {
|
||||
try {
|
||||
const { customerEmail, orderDetails } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!customerEmail || !orderDetails) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields: customerEmail and orderDetails are required'
|
||||
});
|
||||
}
|
||||
|
||||
if (!validateEmail(customerEmail)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid customer email address'
|
||||
});
|
||||
}
|
||||
|
||||
const required = ['tons', 'portfolioId', 'baseAmount', 'processingFee', 'totalAmount', 'orderId', 'stripeSessionId'];
|
||||
const missing = required.filter(field => orderDetails[field] === undefined);
|
||||
|
||||
if (missing.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: `Missing required order details: ${missing.join(', ')}`
|
||||
});
|
||||
}
|
||||
|
||||
// Send receipt email
|
||||
const receiptResult = await sendReceiptEmail(
|
||||
customerEmail.trim().toLowerCase(),
|
||||
orderDetails
|
||||
);
|
||||
|
||||
// Also send admin notification (non-blocking)
|
||||
sendAdminNotification(orderDetails, customerEmail).catch(err => {
|
||||
console.error('⚠️ Admin notification failed (non-fatal):', err.message);
|
||||
});
|
||||
|
||||
console.log(`✅ Receipt email sent to ${customerEmail}`);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
messageId: receiptResult.messageId,
|
||||
message: 'Receipt sent successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Receipt email failed:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to send receipt. Please contact support.',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/email/admin-notify - Send admin notification
|
||||
router.post('/admin-notify', receiptLimiter, async (req, res) => {
|
||||
try {
|
||||
const { orderDetails, customerEmail } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!orderDetails || !customerEmail) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields: orderDetails and customerEmail are required'
|
||||
});
|
||||
}
|
||||
|
||||
if (!validateEmail(customerEmail)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid customer email address'
|
||||
});
|
||||
}
|
||||
|
||||
// Send notification
|
||||
const result = await sendAdminNotification(orderDetails, customerEmail);
|
||||
|
||||
if (result.skipped) {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
skipped: true,
|
||||
message: 'Admin notifications are disabled'
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`✅ Admin notification sent for order ${orderDetails.orderId}`);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
messageId: result.messageId,
|
||||
message: 'Admin notification sent successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Admin notification failed:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to send admin notification',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@ -1,7 +1,10 @@
|
||||
import express from 'express';
|
||||
import { stripe, webhookSecret } from '../config/stripe.js';
|
||||
import { Order } from '../models/Order.js';
|
||||
import { createWrenOffsetOrder } from '../utils/wrenClient.js';
|
||||
import { createWrenOffsetOrder, getWrenPortfolios } from '../utils/wrenClient.js';
|
||||
import { sendReceiptEmail, sendAdminNotification } from '../utils/emailService.js';
|
||||
import { selectComparisons } from '../utils/carbonComparisons.js';
|
||||
import { formatPortfolioProjects } from '../utils/portfolioColors.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@ -241,7 +244,59 @@ async function fulfillOrder(order, session) {
|
||||
console.log(` Wren Order ID: ${wrenOrder.id}`);
|
||||
console.log(` Tons offset: ${order.tons}`);
|
||||
|
||||
// TODO: Send confirmation email to customer
|
||||
// Send receipt email to customer
|
||||
const customerEmail = session.customer_details?.email || order.customer_email;
|
||||
if (customerEmail) {
|
||||
try {
|
||||
// Fetch portfolio data from Wren API
|
||||
let portfolioProjects = [];
|
||||
try {
|
||||
const portfolios = await getWrenPortfolios();
|
||||
const portfolio = portfolios.find(p => p.id === order.portfolio_id);
|
||||
|
||||
if (portfolio && portfolio.projects) {
|
||||
// Format projects with colors and percentages
|
||||
portfolioProjects = formatPortfolioProjects(portfolio.projects);
|
||||
console.log(`✅ Portfolio data fetched: ${portfolioProjects.length} projects`);
|
||||
}
|
||||
} catch (portfolioError) {
|
||||
console.warn('⚠️ Failed to fetch portfolio data (non-fatal):', portfolioError.message);
|
||||
// Continue without portfolio data
|
||||
}
|
||||
|
||||
// Calculate carbon impact comparisons
|
||||
const carbonComparisons = selectComparisons(order.tons, 3);
|
||||
console.log(`✅ Carbon comparisons calculated: ${carbonComparisons.length} items`);
|
||||
|
||||
await sendReceiptEmail(customerEmail, {
|
||||
tons: order.tons,
|
||||
portfolioId: order.portfolio_id,
|
||||
baseAmount: (order.base_amount / 100).toFixed(2),
|
||||
processingFee: (order.processing_fee / 100).toFixed(2),
|
||||
totalAmount: (order.total_amount / 100).toFixed(2),
|
||||
orderId: order.id,
|
||||
stripeSessionId: session.id,
|
||||
projects: portfolioProjects,
|
||||
comparisons: carbonComparisons,
|
||||
});
|
||||
console.log(`📧 Receipt email sent to ${customerEmail}`);
|
||||
|
||||
// Send admin notification (non-blocking)
|
||||
sendAdminNotification({
|
||||
tons: order.tons,
|
||||
portfolioId: order.portfolio_id,
|
||||
totalAmount: (order.total_amount / 100).toFixed(2),
|
||||
orderId: order.id,
|
||||
}, customerEmail).catch(err => {
|
||||
console.error('⚠️ Admin notification failed (non-fatal):', err.message);
|
||||
});
|
||||
} catch (emailError) {
|
||||
console.error('❌ Failed to send receipt email:', emailError);
|
||||
// Don't fail the order fulfillment if email fails
|
||||
}
|
||||
} else {
|
||||
console.warn('⚠️ No customer email available, skipping receipt email');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Order fulfillment failed for order ${order.id}:`, error);
|
||||
@ -249,7 +304,16 @@ async function fulfillOrder(order, session) {
|
||||
// Mark order as paid but unfulfilled (manual intervention needed)
|
||||
Order.updateStatus(order.id, 'paid');
|
||||
|
||||
// TODO: Send alert to admin about failed fulfillment
|
||||
// Send alert to admin about failed fulfillment
|
||||
const customerEmail = session.customer_details?.email || order.customer_email || 'unknown@example.com';
|
||||
sendAdminNotification({
|
||||
tons: order.tons,
|
||||
portfolioId: order.portfolio_id,
|
||||
totalAmount: (order.total_amount / 100).toFixed(2),
|
||||
orderId: order.id,
|
||||
}, customerEmail).catch(err => {
|
||||
console.error('❌ Failed to send admin alert for failed fulfillment:', err.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
203
server/templates/admin-notification.hbs
Normal file
203
server/templates/admin-notification.hbs
Normal file
@ -0,0 +1,203 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="light only">
|
||||
<meta name="supported-color-schemes" content="light">
|
||||
<title>New Order Notification - Puffin Offset</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #1e293b;
|
||||
background-color: #f8fafc;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color-scheme: light only;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
padding: 30px 20px;
|
||||
text-align: center;
|
||||
color: #ffffff;
|
||||
}
|
||||
.logo {
|
||||
width: 100px;
|
||||
height: auto;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0 0 5px 0;
|
||||
font-size: 26px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.header p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.content {
|
||||
padding: 30px;
|
||||
}
|
||||
.alert-box {
|
||||
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
|
||||
border-left: 4px solid #10b981;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.alert-box h2 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #065f46;
|
||||
font-size: 20px;
|
||||
}
|
||||
.alert-box .amount {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: #047857;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.order-details {
|
||||
background-color: #f8fafc;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.detail-label {
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
.detail-value {
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
.customer-box {
|
||||
background-color: #ffffff;
|
||||
border: 2px solid #e0e7ff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.customer-box h3 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #4f46e5;
|
||||
font-size: 16px;
|
||||
}
|
||||
.customer-email {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.customer-email:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.footer {
|
||||
background-color: #f8fafc;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
.timestamp {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
/* Force light mode for email clients */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #f8fafc !important;
|
||||
color: #1e293b !important;
|
||||
}
|
||||
.container {
|
||||
background-color: #ffffff !important;
|
||||
}
|
||||
.alert-box {
|
||||
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%) !important;
|
||||
}
|
||||
.order-details, .customer-box {
|
||||
background-color: #f8fafc !important;
|
||||
}
|
||||
.detail-label, .detail-value {
|
||||
color: #0f172a !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<img src="https://s3.puffinoffset.com/public/puffinOffset.png" alt="Puffin Offset" class="logo">
|
||||
<h1>🎉 New Order Received!</h1>
|
||||
<p>A customer just completed a carbon offset purchase</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="alert-box">
|
||||
<h2>Order Value</h2>
|
||||
<div class="amount">${{totalAmount}}</div>
|
||||
<p style="margin: 5px 0 0 0; color: #047857; font-size: 14px;">
|
||||
{{tons}} tons CO₂ offset
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="order-details" style="background-color: #f8fafc !important;">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label" style="color: #475569 !important;">Portfolio</span>
|
||||
<span class="detail-value" style="color: #0f172a !important;">#{{portfolioId}}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label" style="color: #475569 !important;">Order ID</span>
|
||||
<span class="detail-value" style="color: #0f172a !important;">{{orderId}}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label" style="color: #475569 !important;">Carbon Offset</span>
|
||||
<span class="detail-value" style="color: #0f172a !important;">{{tons}} tons CO₂</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label" style="color: #475569 !important;">Total Amount</span>
|
||||
<span class="detail-value" style="color: #0f172a !important;">${{totalAmount}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="customer-box">
|
||||
<h3>Customer Information</h3>
|
||||
<p>
|
||||
<a href="mailto:{{customerEmail}}" class="customer-email">{{customerEmail}}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style="color: #64748b; font-size: 14px; text-align: center; margin-top: 30px;">
|
||||
The order has been processed successfully and the customer will receive their receipt email shortly.
|
||||
</p>
|
||||
|
||||
<div class="timestamp">
|
||||
Order received: {{timestamp}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>This is an automated notification from Puffin Offset.</p>
|
||||
<p>© 2025 Puffin Offset</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
180
server/templates/contact.hbs
Normal file
180
server/templates/contact.hbs
Normal file
@ -0,0 +1,180 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="light only">
|
||||
<meta name="supported-color-schemes" content="light">
|
||||
<title>Contact Form Submission - Puffin Offset</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #1e293b;
|
||||
background-color: #f8fafc;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color-scheme: light only;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
|
||||
padding: 30px 20px;
|
||||
text-align: center;
|
||||
color: #ffffff;
|
||||
}
|
||||
.logo {
|
||||
width: 100px;
|
||||
height: auto;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.content {
|
||||
padding: 30px;
|
||||
}
|
||||
.info-box {
|
||||
background-color: #f1f5f9;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.info-row {
|
||||
display: flex;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.info-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.info-label {
|
||||
min-width: 100px;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
.info-value {
|
||||
color: #1e293b;
|
||||
flex: 1;
|
||||
}
|
||||
.message-box {
|
||||
background-color: #ffffff;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.message-label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.message-content {
|
||||
color: #1e293b;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.footer {
|
||||
background-color: #f8fafc;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
.reply-button {
|
||||
display: inline-block;
|
||||
background-color: #3b82f6;
|
||||
color: #ffffff;
|
||||
padding: 12px 24px;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.timestamp {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
/* Force light mode for email clients */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #f8fafc !important;
|
||||
color: #1e293b !important;
|
||||
}
|
||||
.container {
|
||||
background-color: #ffffff !important;
|
||||
}
|
||||
.info-box, .message-box {
|
||||
background-color: #f1f5f9 !important;
|
||||
}
|
||||
.info-label, .info-value {
|
||||
color: #0f172a !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<img src="https://s3.puffinoffset.com/public/puffinOffset.png" alt="Puffin Offset" class="logo">
|
||||
<h1>📬 New Contact Form Submission</h1>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p style="color: #64748b; margin-bottom: 20px;">
|
||||
You have received a new message from the Puffin Offset contact form.
|
||||
</p>
|
||||
|
||||
<div class="info-box" style="background-color: #f1f5f9 !important;">
|
||||
<div class="info-row">
|
||||
<span class="info-label" style="color: #475569 !important;">Name:</span>
|
||||
<span class="info-value" style="color: #0f172a !important;"><strong>{{name}}</strong></span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label" style="color: #475569 !important;">Email:</span>
|
||||
<span class="info-value" style="color: #0f172a !important;"><a href="mailto:{{email}}" style="color: #3b82f6 !important; text-decoration: none;">{{email}}</a></span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label" style="color: #475569 !important;">Phone:</span>
|
||||
<span class="info-value" style="color: #0f172a !important;">{{phone}}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label" style="color: #475569 !important;">Company:</span>
|
||||
<span class="info-value" style="color: #0f172a !important;">{{company}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message-box" style="background-color: #ffffff !important; border: 2px solid #e2e8f0 !important;">
|
||||
<div class="message-label" style="color: #475569 !important;">Message:</div>
|
||||
<div class="message-content" style="color: #0f172a !important;">{{message}}</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<a href="mailto:{{email}}?subject=Re: Your inquiry to Puffin Offset" class="reply-button">
|
||||
Reply to {{name}}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="timestamp">
|
||||
Received: {{timestamp}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>This is an automated notification from the Puffin Offset contact form.</p>
|
||||
<p>© 2025 Puffin Offset</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
479
server/templates/receipt.hbs
Normal file
479
server/templates/receipt.hbs
Normal file
@ -0,0 +1,479 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="light only">
|
||||
<meta name="supported-color-schemes" content="light">
|
||||
<title>Carbon Offset Receipt - Puffin Offset</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #1e293b;
|
||||
background: linear-gradient(135deg, #f1f5f9 0%, #e0f2fe 50%, #cffafe 100%);
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
color-scheme: light only;
|
||||
}
|
||||
.container {
|
||||
max-width: 650px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
padding: 40px 30px;
|
||||
text-align: center;
|
||||
color: #ffffff;
|
||||
}
|
||||
.logo {
|
||||
width: 120px;
|
||||
height: auto;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.header p {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
opacity: 0.95;
|
||||
}
|
||||
.success-badge {
|
||||
background-color: #ffffff;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
margin: -40px auto 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
.checkmark {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: #10b981;
|
||||
position: relative;
|
||||
}
|
||||
.checkmark:after {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: white;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.content {
|
||||
padding: 30px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #1e293b;
|
||||
margin: 0 0 20px 0;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 3px solid #e2e8f0;
|
||||
}
|
||||
.carbon-highlight {
|
||||
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
|
||||
border-left: 6px solid #10b981;
|
||||
border-radius: 16px;
|
||||
padding: 30px;
|
||||
margin: 20px 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.carbon-highlight .text {
|
||||
flex: 1;
|
||||
}
|
||||
.carbon-highlight .label {
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #065f46;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.carbon-highlight .value {
|
||||
font-size: 42px;
|
||||
font-weight: bold;
|
||||
color: #047857;
|
||||
line-height: 1;
|
||||
}
|
||||
.carbon-highlight .icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.detail-label {
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
.detail-value {
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
text-align: right;
|
||||
}
|
||||
.pricing-box {
|
||||
background-color: #f8fafc;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
.pricing-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.pricing-total {
|
||||
border-top: 2px solid #cbd5e1;
|
||||
padding-top: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.pricing-total .label {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #1e293b;
|
||||
}
|
||||
.pricing-total .value {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #2563eb;
|
||||
}
|
||||
.metadata-box {
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #dbeafe 100%) !important;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
/* Force light mode for email clients */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background: linear-gradient(135deg, #f1f5f9 0%, #e0f2fe 50%, #cffafe 100%) !important;
|
||||
color: #1e293b !important;
|
||||
}
|
||||
.container {
|
||||
background-color: #ffffff !important;
|
||||
}
|
||||
.metadata-box {
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #dbeafe 100%) !important;
|
||||
}
|
||||
.metadata-label, .metadata-value, .detail-label, .detail-value {
|
||||
color: #0f172a !important;
|
||||
}
|
||||
}
|
||||
.metadata-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
.metadata-item {
|
||||
break-inside: avoid;
|
||||
}
|
||||
.metadata-label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.metadata-value {
|
||||
color: #0f172a;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
word-break: break-all;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
background-color: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
.comparisons {
|
||||
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
margin: 30px 0;
|
||||
color: white;
|
||||
}
|
||||
.comparisons-title {
|
||||
font-size: 26px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
margin: 0 0 10px 0;
|
||||
color: #ffffff;
|
||||
}
|
||||
.comparisons-subtitle {
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 14px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.comparisons-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
.comparison-card {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.comparison-icon {
|
||||
font-size: 36px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.comparison-value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.comparison-unit {
|
||||
font-size: 12px;
|
||||
color: #a7f3d0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.comparison-label {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.portfolio-section {
|
||||
margin: 30px 0;
|
||||
padding: 30px;
|
||||
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
|
||||
border-radius: 16px;
|
||||
}
|
||||
.portfolio-title {
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
color: #065f46;
|
||||
text-align: center;
|
||||
margin: 0 0 24px 0;
|
||||
}
|
||||
.project-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px;
|
||||
background-color: white;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 12px;
|
||||
border-left: 4px solid;
|
||||
}
|
||||
.project-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.project-info {
|
||||
flex: 1;
|
||||
}
|
||||
.project-name {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.project-type {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
.project-percentage {
|
||||
font-weight: bold;
|
||||
color: #047857;
|
||||
font-size: 16px;
|
||||
}
|
||||
.footer {
|
||||
background-color: #f8fafc;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
}
|
||||
.footer-note {
|
||||
margin: 16px 0 8px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.comparisons-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.metadata-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.carbon-highlight {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
.carbon-highlight .icon {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<img src="https://s3.puffinoffset.com/public/puffinOffset.png" alt="Puffin Offset" class="logo">
|
||||
<h1>Order Confirmed</h1>
|
||||
<p>Thank you for your carbon offset purchase</p>
|
||||
</div>
|
||||
|
||||
<!-- Success Badge -->
|
||||
<div class="success-badge">
|
||||
<div class="checkmark"></div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
<h2 class="section-title">Order Summary</h2>
|
||||
|
||||
<!-- Carbon Offset Highlight -->
|
||||
<div class="carbon-highlight">
|
||||
<div class="text">
|
||||
<div class="label">Carbon Offset</div>
|
||||
<div class="value">{{tons}} tons CO₂</div>
|
||||
</div>
|
||||
<div class="icon" style="font-size: 64px; opacity: 0.6;">🌱</div>
|
||||
</div>
|
||||
|
||||
<!-- Portfolio Info -->
|
||||
<div class="detail-row">
|
||||
<span class="detail-label" style="color: #475569 !important;">Portfolio</span>
|
||||
<span class="detail-value" style="color: #0f172a !important;">#{{portfolioId}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Pricing Breakdown -->
|
||||
<div class="pricing-box" style="background-color: #f8fafc !important;">
|
||||
<div class="pricing-row">
|
||||
<span class="detail-label" style="color: #475569 !important;">Offset Cost</span>
|
||||
<span class="detail-value" style="color: #0f172a !important;">\${{baseAmount}}</span>
|
||||
</div>
|
||||
<div class="pricing-row">
|
||||
<span class="detail-label" style="color: #475569 !important;">Processing Fee (3%)</span>
|
||||
<span class="detail-value" style="color: #0f172a !important;">\${{processingFee}}</span>
|
||||
</div>
|
||||
<div class="pricing-total">
|
||||
<div class="pricing-row">
|
||||
<span class="label">Total Paid</span>
|
||||
<span class="value">\${{totalAmount}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Metadata -->
|
||||
<div class="metadata-box" style="background: linear-gradient(135deg, #f8fafc 0%, #dbeafe 100%) !important;">
|
||||
<div class="metadata-grid">
|
||||
<div class="metadata-item">
|
||||
<div class="metadata-label" style="color: #0f172a !important;">PAYMENT ID</div>
|
||||
<div class="metadata-value" style="color: #0f172a !important;">{{stripeSessionId}}</div>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<div class="metadata-label" style="color: #0f172a !important;">ORDER ID</div>
|
||||
<div class="metadata-value" style="color: #0f172a !important;">{{orderId}}</div>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<div class="metadata-label" style="color: #0f172a !important;">STATUS</div>
|
||||
<div class="metadata-value" style="color: #0f172a !important;">
|
||||
<span class="status-badge">Confirmed</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<div class="metadata-label" style="color: #0f172a !important;">DATE</div>
|
||||
<div class="metadata-value" style="color: #0f172a !important;">{{date}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Portfolio Distribution (if available) -->
|
||||
{{#if projects}}
|
||||
<div class="portfolio-section">
|
||||
<h3 class="portfolio-title">Your Carbon Offset Distribution</h3>
|
||||
<p style="text-align: center; color: #065f46; margin-bottom: 20px; font-size: 14px;">
|
||||
Your {{tons}} tons of CO₂ offsets are distributed across these verified projects:
|
||||
</p>
|
||||
{{#each projects}}
|
||||
<div class="project-item" style="border-left-color: {{this.color}}">
|
||||
<div class="project-color" style="background-color: {{this.color}}"></div>
|
||||
<div class="project-info">
|
||||
<div class="project-name">{{this.name}}</div>
|
||||
<div class="project-type">{{this.type}}</div>
|
||||
</div>
|
||||
<div class="project-percentage">{{this.percentage}}%</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<!-- Carbon Impact Comparisons -->
|
||||
{{#if comparisons}}
|
||||
<div class="comparisons">
|
||||
<h3 class="comparisons-title">Your Impact</h3>
|
||||
<p class="comparisons-subtitle">Here's what your offset is equivalent to:</p>
|
||||
<div class="comparisons-grid">
|
||||
{{#each comparisons}}
|
||||
<div class="comparison-card">
|
||||
<div class="comparison-icon">{{this.icon}}</div>
|
||||
<div class="comparison-value">{{this.value}}</div>
|
||||
<div class="comparison-unit">{{this.unit}}</div>
|
||||
<div class="comparison-label">{{this.label}}</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
<p style="text-align: center; font-size: 11px; color: rgba(255,255,255,0.6); margin-top: 20px; margin-bottom: 0;">
|
||||
Equivalencies calculated using EPA 2024, DEFRA 2024, and IMO 2024 verified conversion factors.
|
||||
</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div class="footer-note">
|
||||
<strong>What happens next?</strong><br>
|
||||
Your carbon offset order has been processed successfully. The offset will be registered with our verified climate projects, and your impact will be permanently recorded. You can keep this email as your official receipt for tax purposes.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<p style="margin: 0 0 8px 0;"><strong>Thank you for taking action on climate change!</strong></p>
|
||||
<p style="margin: 0;">This is an automated receipt from Puffin Offset.</p>
|
||||
<p style="margin: 8px 0 0 0;">© 2025 Puffin Offset</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
256
server/utils/carbonComparisons.js
Normal file
256
server/utils/carbonComparisons.js
Normal file
@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Carbon Equivalencies - EPA/DEFRA/IMO 2024 Verified Conversion Factors
|
||||
* Ported from frontend src/utils/carbonEquivalencies.ts and impactSelector.ts
|
||||
*/
|
||||
|
||||
// Carbon equivalency conversion factors (units per metric ton of CO2e)
|
||||
export const CARBON_EQUIVALENCIES = {
|
||||
// TRANSPORTATION (EPA 2024)
|
||||
MILES_DRIVEN_AVG_CAR: 2560,
|
||||
GALLONS_GASOLINE: 113,
|
||||
|
||||
// AVIATION (DEFRA 2024)
|
||||
FLIGHT_KM_ECONOMY_SHORT: 6667,
|
||||
FLIGHT_KM_ECONOMY_LONG: 7692,
|
||||
|
||||
// NATURAL (EPA 2024)
|
||||
TREE_SEEDLINGS_10YR: 16.7,
|
||||
FOREST_ACRES_1YR: 1.0,
|
||||
FOREST_ACRES_10YR: 1.32,
|
||||
|
||||
// ENERGY (EPA 2024)
|
||||
ELECTRICITY_KWH: 2540,
|
||||
HOME_ENERGY_YEARS: 0.134,
|
||||
BARRELS_OIL: 2.33,
|
||||
|
||||
// YACHT-SPECIFIC (IMO 2024)
|
||||
MGO_LITERS_AVOIDED: 373,
|
||||
MGO_TONNES_AVOIDED: 0.312,
|
||||
CRUISING_HOURS_60M: 1.24,
|
||||
SUPERYACHT_NAUTICAL_MILES: 149,
|
||||
SHORE_POWER_DAYS: 25.4,
|
||||
|
||||
// LIFESTYLE
|
||||
SMARTPHONE_CHARGES: 115000,
|
||||
POUNDS_COAL_BURNED: 1000,
|
||||
};
|
||||
|
||||
// Comparison definitions with emoji icons (email-compatible)
|
||||
const COMPARISON_DEFINITIONS = {
|
||||
MILES_DRIVEN: {
|
||||
icon: '🚗',
|
||||
unit: 'miles',
|
||||
label: 'Miles driven in an average car',
|
||||
},
|
||||
GALLONS_GASOLINE: {
|
||||
icon: '⛽',
|
||||
unit: 'gallons',
|
||||
label: 'Gallons of gasoline not burned',
|
||||
},
|
||||
FLIGHT_KM_SHORT: {
|
||||
icon: '✈️',
|
||||
unit: 'passenger-km',
|
||||
label: 'Short-haul flight distance (economy)',
|
||||
},
|
||||
FLIGHT_KM_LONG: {
|
||||
icon: '🛫',
|
||||
unit: 'passenger-km',
|
||||
label: 'Long-haul flight distance (economy)',
|
||||
},
|
||||
TREE_SEEDLINGS: {
|
||||
icon: '🌲',
|
||||
unit: 'trees',
|
||||
label: 'Tree seedlings grown for 10 years',
|
||||
},
|
||||
FOREST_ACRES_1YR: {
|
||||
icon: '🌳',
|
||||
unit: 'acres',
|
||||
label: 'Acres of forest preserved for 1 year',
|
||||
},
|
||||
FOREST_ACRES_10YR: {
|
||||
icon: '🌳',
|
||||
unit: 'acres',
|
||||
label: 'Acres of forest carbon sequestration over 10 years',
|
||||
},
|
||||
ELECTRICITY_KWH: {
|
||||
icon: '⚡',
|
||||
unit: 'kWh',
|
||||
label: 'Kilowatt-hours of electricity',
|
||||
},
|
||||
HOME_ENERGY_YEARS: {
|
||||
icon: '🏠',
|
||||
unit: 'home-years',
|
||||
label: 'Years of home electricity use',
|
||||
},
|
||||
BARRELS_OIL: {
|
||||
icon: '💧',
|
||||
unit: 'barrels',
|
||||
label: 'Barrels of oil not consumed',
|
||||
},
|
||||
MGO_LITERS: {
|
||||
icon: '🌊',
|
||||
unit: 'liters',
|
||||
label: 'Liters of Marine Gas Oil avoided',
|
||||
},
|
||||
MGO_TONNES: {
|
||||
icon: '💧',
|
||||
unit: 'tonnes',
|
||||
label: 'Tonnes of Marine Gas Oil avoided',
|
||||
},
|
||||
CRUISING_HOURS: {
|
||||
icon: '⚓',
|
||||
unit: 'hours',
|
||||
label: 'Hours of 60m yacht cruising avoided',
|
||||
},
|
||||
SUPERYACHT_NAUTICAL_MILES: {
|
||||
icon: '🚢',
|
||||
unit: 'nautical miles',
|
||||
label: 'Nautical miles of superyacht voyaging',
|
||||
},
|
||||
SHORE_POWER_DAYS: {
|
||||
icon: '🔋',
|
||||
unit: 'days',
|
||||
label: 'Days of shore power for large yacht',
|
||||
},
|
||||
SMARTPHONE_CHARGES: {
|
||||
icon: '📱',
|
||||
unit: 'charges',
|
||||
label: 'Smartphone charges',
|
||||
},
|
||||
POUNDS_COAL: {
|
||||
icon: '🔥',
|
||||
unit: 'pounds',
|
||||
label: 'Pounds of coal not burned',
|
||||
},
|
||||
};
|
||||
|
||||
// Smart scaling ranges - different comparisons for different CO2 amounts
|
||||
const SCALING_RANGES = [
|
||||
{
|
||||
minTons: 0,
|
||||
maxTons: 0.1,
|
||||
comparisonKeys: ['MILES_DRIVEN', 'SMARTPHONE_CHARGES', 'TREE_SEEDLINGS'],
|
||||
},
|
||||
{
|
||||
minTons: 0.1,
|
||||
maxTons: 1,
|
||||
comparisonKeys: ['MILES_DRIVEN', 'TREE_SEEDLINGS', 'GALLONS_GASOLINE'],
|
||||
},
|
||||
{
|
||||
minTons: 1,
|
||||
maxTons: 5,
|
||||
comparisonKeys: ['FLIGHT_KM_SHORT', 'TREE_SEEDLINGS', 'ELECTRICITY_KWH'],
|
||||
},
|
||||
{
|
||||
minTons: 5,
|
||||
maxTons: 20,
|
||||
comparisonKeys: ['FLIGHT_KM_LONG', 'FOREST_ACRES_1YR', 'MGO_LITERS'],
|
||||
},
|
||||
{
|
||||
minTons: 20,
|
||||
maxTons: 100,
|
||||
comparisonKeys: ['CRUISING_HOURS', 'FOREST_ACRES_1YR', 'HOME_ENERGY_YEARS'],
|
||||
},
|
||||
{
|
||||
minTons: 100,
|
||||
maxTons: Infinity,
|
||||
comparisonKeys: ['FOREST_ACRES_10YR', 'CRUISING_HOURS', 'SUPERYACHT_NAUTICAL_MILES'],
|
||||
},
|
||||
];
|
||||
|
||||
// Factor mapping for conversion
|
||||
const FACTOR_MAP = {
|
||||
MILES_DRIVEN: CARBON_EQUIVALENCIES.MILES_DRIVEN_AVG_CAR,
|
||||
GALLONS_GASOLINE: CARBON_EQUIVALENCIES.GALLONS_GASOLINE,
|
||||
FLIGHT_KM_SHORT: CARBON_EQUIVALENCIES.FLIGHT_KM_ECONOMY_SHORT,
|
||||
FLIGHT_KM_LONG: CARBON_EQUIVALENCIES.FLIGHT_KM_ECONOMY_LONG,
|
||||
TREE_SEEDLINGS: CARBON_EQUIVALENCIES.TREE_SEEDLINGS_10YR,
|
||||
FOREST_ACRES_1YR: CARBON_EQUIVALENCIES.FOREST_ACRES_1YR,
|
||||
FOREST_ACRES_10YR: CARBON_EQUIVALENCIES.FOREST_ACRES_10YR,
|
||||
ELECTRICITY_KWH: CARBON_EQUIVALENCIES.ELECTRICITY_KWH,
|
||||
HOME_ENERGY_YEARS: CARBON_EQUIVALENCIES.HOME_ENERGY_YEARS,
|
||||
BARRELS_OIL: CARBON_EQUIVALENCIES.BARRELS_OIL,
|
||||
MGO_LITERS: CARBON_EQUIVALENCIES.MGO_LITERS_AVOIDED,
|
||||
MGO_TONNES: CARBON_EQUIVALENCIES.MGO_TONNES_AVOIDED,
|
||||
CRUISING_HOURS: CARBON_EQUIVALENCIES.CRUISING_HOURS_60M,
|
||||
SUPERYACHT_NAUTICAL_MILES: CARBON_EQUIVALENCIES.SUPERYACHT_NAUTICAL_MILES,
|
||||
SHORE_POWER_DAYS: CARBON_EQUIVALENCIES.SHORE_POWER_DAYS,
|
||||
SMARTPHONE_CHARGES: CARBON_EQUIVALENCIES.SMARTPHONE_CHARGES,
|
||||
POUNDS_COAL: CARBON_EQUIVALENCIES.POUNDS_COAL_BURNED,
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate equivalency value
|
||||
*/
|
||||
function calculateEquivalency(tons, factor) {
|
||||
return tons * factor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format large numbers with appropriate precision
|
||||
*/
|
||||
function formatEquivalencyValue(value) {
|
||||
if (value < 0.01) {
|
||||
return value.toExponential(2);
|
||||
} else if (value < 1) {
|
||||
return value.toFixed(2);
|
||||
} else if (value < 100) {
|
||||
return value.toFixed(1);
|
||||
} else if (value < 1000) {
|
||||
return Math.round(value).toLocaleString();
|
||||
} else {
|
||||
return Math.round(value).toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the most appropriate comparisons for the given CO2 amount
|
||||
* @param {number} tons - Metric tons of CO2e to offset
|
||||
* @param {number} count - Number of comparisons to return (default: 3)
|
||||
* @returns {Array} Array of carbon comparisons with calculated values
|
||||
*/
|
||||
export function selectComparisons(tons, count = 3) {
|
||||
// Find the appropriate range for this amount
|
||||
const range = SCALING_RANGES.find(
|
||||
(r) => tons >= r.minTons && tons < r.maxTons
|
||||
);
|
||||
|
||||
if (!range) {
|
||||
console.warn(`No comparison range found for ${tons} tons, using default`);
|
||||
return getDefaultComparisons(tons, count);
|
||||
}
|
||||
|
||||
// Get the comparison definitions for this range
|
||||
const comparisons = range.comparisonKeys
|
||||
.slice(0, count)
|
||||
.map((key) => {
|
||||
const definition = COMPARISON_DEFINITIONS[key];
|
||||
const factor = FACTOR_MAP[key];
|
||||
const value = calculateEquivalency(tons, factor);
|
||||
|
||||
return {
|
||||
...definition,
|
||||
value: formatEquivalencyValue(value),
|
||||
};
|
||||
});
|
||||
|
||||
return comparisons;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default comparisons when no range matches
|
||||
*/
|
||||
function getDefaultComparisons(tons, count) {
|
||||
const defaultKeys = ['MILES_DRIVEN', 'TREE_SEEDLINGS', 'ELECTRICITY_KWH'];
|
||||
|
||||
return defaultKeys.slice(0, count).map((key) => {
|
||||
const definition = COMPARISON_DEFINITIONS[key];
|
||||
const factor = FACTOR_MAP[key];
|
||||
const value = calculateEquivalency(tons, factor);
|
||||
|
||||
return {
|
||||
...definition,
|
||||
value: formatEquivalencyValue(value),
|
||||
};
|
||||
});
|
||||
}
|
||||
188
server/utils/emailService.js
Normal file
188
server/utils/emailService.js
Normal file
@ -0,0 +1,188 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import handlebars from 'handlebars';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Create transporter with connection pooling
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST || 'mail.puffinoffset.com',
|
||||
port: parseInt(process.env.SMTP_PORT) || 587,
|
||||
secure: process.env.SMTP_SECURE === 'true', // true for 465, false for other ports
|
||||
auth: {
|
||||
user: process.env.SMTP_USER || 'noreply@puffinoffset.com',
|
||||
pass: process.env.SMTP_PASSWORD
|
||||
},
|
||||
pool: true,
|
||||
maxConnections: 5,
|
||||
maxMessages: 100,
|
||||
rateDelta: 1000,
|
||||
rateLimit: 10
|
||||
});
|
||||
|
||||
// Verify connection configuration on startup
|
||||
export async function verifyConnection() {
|
||||
try {
|
||||
await transporter.verify();
|
||||
console.log('✅ SMTP server is ready to send emails');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('❌ SMTP connection failed:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Load and compile template
|
||||
async function loadTemplate(templateName) {
|
||||
try {
|
||||
const templatePath = path.join(__dirname, '..', 'templates', `${templateName}.hbs`);
|
||||
const templateSource = await fs.readFile(templatePath, 'utf-8');
|
||||
return handlebars.compile(templateSource);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load template ${templateName}:`, error.message);
|
||||
throw new Error(`Template ${templateName} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
// Send email using template
|
||||
export async function sendTemplateEmail(to, subject, templateName, data) {
|
||||
try {
|
||||
// Load and compile template
|
||||
const template = await loadTemplate(templateName);
|
||||
const html = template(data);
|
||||
|
||||
// Create plain text version (strip HTML tags)
|
||||
const text = html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Send email
|
||||
const info = await transporter.sendMail({
|
||||
from: `"${process.env.SMTP_FROM_NAME || 'Puffin Offset'}" <${process.env.SMTP_FROM_EMAIL || 'noreply@puffinoffset.com'}>`,
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html
|
||||
});
|
||||
|
||||
console.log(`✉️ Email sent: ${info.messageId} to ${to}`);
|
||||
return {
|
||||
success: true,
|
||||
messageId: info.messageId,
|
||||
accepted: info.accepted,
|
||||
rejected: info.rejected
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to send email:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Send receipt email
|
||||
export async function sendReceiptEmail(customerEmail, orderDetails) {
|
||||
const subject = `Order Confirmation - ${orderDetails.tons} tons CO₂ Offset`;
|
||||
|
||||
// Prepare template data
|
||||
const templateData = {
|
||||
tons: orderDetails.tons,
|
||||
portfolioId: orderDetails.portfolioId,
|
||||
baseAmount: typeof orderDetails.baseAmount === 'number'
|
||||
? (orderDetails.baseAmount / 100).toFixed(2)
|
||||
: orderDetails.baseAmount,
|
||||
processingFee: typeof orderDetails.processingFee === 'number'
|
||||
? (orderDetails.processingFee / 100).toFixed(2)
|
||||
: orderDetails.processingFee,
|
||||
totalAmount: typeof orderDetails.totalAmount === 'number'
|
||||
? (orderDetails.totalAmount / 100).toFixed(2)
|
||||
: orderDetails.totalAmount,
|
||||
orderId: orderDetails.orderId,
|
||||
stripeSessionId: orderDetails.stripeSessionId,
|
||||
date: new Date().toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
};
|
||||
|
||||
// Add portfolio projects if available
|
||||
if (orderDetails.projects && orderDetails.projects.length > 0) {
|
||||
templateData.projects = orderDetails.projects;
|
||||
}
|
||||
|
||||
// Add carbon comparisons if available
|
||||
if (orderDetails.comparisons && orderDetails.comparisons.length > 0) {
|
||||
templateData.comparisons = orderDetails.comparisons;
|
||||
}
|
||||
|
||||
return await sendTemplateEmail(
|
||||
customerEmail,
|
||||
subject,
|
||||
'receipt',
|
||||
templateData
|
||||
);
|
||||
}
|
||||
|
||||
// Send contact form email
|
||||
export async function sendContactEmail(contactData) {
|
||||
const subject = `Contact Form: ${contactData.name}`;
|
||||
const adminEmail = process.env.ADMIN_EMAIL || 'admin@puffinoffset.com';
|
||||
|
||||
return await sendTemplateEmail(
|
||||
adminEmail,
|
||||
subject,
|
||||
'contact',
|
||||
{
|
||||
name: contactData.name,
|
||||
email: contactData.email,
|
||||
phone: contactData.phone || 'Not provided',
|
||||
company: contactData.company || 'Not provided',
|
||||
message: contactData.message,
|
||||
timestamp: new Date().toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Send admin notification for new order
|
||||
export async function sendAdminNotification(orderDetails, customerEmail) {
|
||||
const subject = `New Order: ${orderDetails.tons} tons CO₂ - $${(orderDetails.totalAmount / 100).toFixed(2)}`;
|
||||
const adminEmail = process.env.ADMIN_EMAIL || 'admin@puffinoffset.com';
|
||||
|
||||
// Check if admin notifications are enabled
|
||||
if (process.env.ADMIN_NOTIFY_ON_ORDER === 'false') {
|
||||
console.log('ℹ️ Admin notifications disabled, skipping');
|
||||
return { success: true, skipped: true };
|
||||
}
|
||||
|
||||
return await sendTemplateEmail(
|
||||
adminEmail,
|
||||
subject,
|
||||
'admin-notification',
|
||||
{
|
||||
tons: orderDetails.tons,
|
||||
portfolioId: orderDetails.portfolioId,
|
||||
totalAmount: (orderDetails.totalAmount / 100).toFixed(2),
|
||||
orderId: orderDetails.orderId,
|
||||
customerEmail,
|
||||
timestamp: new Date().toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Close transporter (for graceful shutdown)
|
||||
export function closeTransporter() {
|
||||
transporter.close();
|
||||
console.log('📪 Email transporter closed');
|
||||
}
|
||||
41
server/utils/portfolioColors.js
Normal file
41
server/utils/portfolioColors.js
Normal file
@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Portfolio Color Palette System
|
||||
* Ported from frontend src/utils/portfolioColors.ts
|
||||
*/
|
||||
|
||||
export const portfolioColorPalette = [
|
||||
'#3B82F6', // Blue 500
|
||||
'#06B6D4', // Cyan 500
|
||||
'#10B981', // Green 500
|
||||
'#14B8A6', // Teal 500
|
||||
'#8B5CF6', // Violet 500
|
||||
'#6366F1', // Indigo 500
|
||||
'#0EA5E9', // Sky 500
|
||||
'#22C55E', // Green 400
|
||||
'#84CC16', // Lime 500
|
||||
'#F59E0B', // Amber 500
|
||||
'#EC4899', // Pink 500
|
||||
'#EF4444', // Red 500
|
||||
];
|
||||
|
||||
export function getProjectColor(index) {
|
||||
return portfolioColorPalette[index % portfolioColorPalette.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add colors and formatted percentages to portfolio projects
|
||||
* @param {Array} projects - Portfolio projects from Wren API
|
||||
* @returns {Array} Projects with color and percentage added
|
||||
*/
|
||||
export function formatPortfolioProjects(projects) {
|
||||
if (!projects || !Array.isArray(projects)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return projects.map((project, index) => ({
|
||||
name: project.name || 'Unnamed Project',
|
||||
type: project.type || 'Carbon Offset',
|
||||
percentage: project.percentage || Math.round((1 / projects.length) * 100),
|
||||
color: getProjectColor(index),
|
||||
}));
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user