feat: switch email provider to SendGrid and add Docker support
- Replace Resend with SendGrid for notification emails - Add sendThankYouEmail function using SendGrid REST API - Add SENDGRID_API_KEY to env vars documentation - Fix URL handling in checkout/portal routes (use getBaseUrl helper) - Simplify donation request body in homepage - Add Dockerfile for standalone deployment - Enable standalone output in next.config.mjs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
ddb673ede6
commit
e3934509d4
|
|
@ -56,6 +56,7 @@ Add these environment variables in Cloudflare Pages dashboard:
|
||||||
- `STRIPE_PUBLISHABLE_KEY`
|
- `STRIPE_PUBLISHABLE_KEY`
|
||||||
- `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`
|
- `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`
|
||||||
- `STRIPE_WEBHOOK_SECRET`
|
- `STRIPE_WEBHOOK_SECRET`
|
||||||
|
- `SENDGRID_API_KEY` - For sending notification emails (thank you emails after purchases)
|
||||||
|
|
||||||
## Important Notes
|
## Important Notes
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
FROM node:20-alpine AS base
|
||||||
|
|
||||||
|
# Install dependencies only when needed
|
||||||
|
FROM base AS deps
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Rebuild the source code only when needed
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production image, copy all the files and run next
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
|
|
@ -3,8 +3,22 @@ import Stripe from "stripe"
|
||||||
|
|
||||||
export const runtime = "edge"
|
export const runtime = "edge"
|
||||||
|
|
||||||
|
function getBaseUrl(request: NextRequest): string {
|
||||||
|
// Use environment variable if set, otherwise construct from headers
|
||||||
|
if (process.env.NEXT_PUBLIC_BASE_URL) {
|
||||||
|
return process.env.NEXT_PUBLIC_BASE_URL
|
||||||
|
}
|
||||||
|
const host = request.headers.get("host") || request.headers.get("x-forwarded-host")
|
||||||
|
const protocol = request.headers.get("x-forwarded-proto") || "https"
|
||||||
|
if (host) {
|
||||||
|
return `${protocol}://${host}`
|
||||||
|
}
|
||||||
|
return "https://alertbaytrumpeter.com"
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const baseUrl = getBaseUrl(request)
|
||||||
const stripeSecretKey = process.env.STRIPE_SECRET_KEY
|
const stripeSecretKey = process.env.STRIPE_SECRET_KEY
|
||||||
|
|
||||||
if (!stripeSecretKey) {
|
if (!stripeSecretKey) {
|
||||||
|
|
@ -60,8 +74,8 @@ export async function POST(request: NextRequest) {
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
success_url: `${request.nextUrl.origin}/success?session_id={CHECKOUT_SESSION_ID}`,
|
success_url: `${baseUrl}/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||||
cancel_url: `${request.nextUrl.origin}/cancel`,
|
cancel_url: `${baseUrl}/cancel`,
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log("[v0] Subscription checkout session created successfully:", session.id)
|
console.log("[v0] Subscription checkout session created successfully:", session.id)
|
||||||
|
|
@ -89,8 +103,8 @@ export async function POST(request: NextRequest) {
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
success_url: `${request.nextUrl.origin}/success?session_id={CHECKOUT_SESSION_ID}`,
|
success_url: `${baseUrl}/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||||
cancel_url: `${request.nextUrl.origin}/cancel`,
|
cancel_url: `${baseUrl}/cancel`,
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log("[v0] Monthly subscription checkout session created successfully:", session.id)
|
console.log("[v0] Monthly subscription checkout session created successfully:", session.id)
|
||||||
|
|
@ -140,8 +154,8 @@ export async function POST(request: NextRequest) {
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
success_url: `${request.nextUrl.origin}/success?session_id={CHECKOUT_SESSION_ID}`,
|
success_url: `${baseUrl}/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||||
cancel_url: `${request.nextUrl.origin}/cancel`,
|
cancel_url: `${baseUrl}/cancel`,
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log("[v0] Checkout session created successfully:", session.id)
|
console.log("[v0] Checkout session created successfully:", session.id)
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,21 @@ import Stripe from "stripe"
|
||||||
|
|
||||||
export const runtime = "edge"
|
export const runtime = "edge"
|
||||||
|
|
||||||
|
function getBaseUrl(request: NextRequest): string {
|
||||||
|
if (process.env.NEXT_PUBLIC_BASE_URL) {
|
||||||
|
return process.env.NEXT_PUBLIC_BASE_URL
|
||||||
|
}
|
||||||
|
const host = request.headers.get("host") || request.headers.get("x-forwarded-host")
|
||||||
|
const protocol = request.headers.get("x-forwarded-proto") || "https"
|
||||||
|
if (host) {
|
||||||
|
return `${protocol}://${host}`
|
||||||
|
}
|
||||||
|
return "https://alertbaytrumpeter.com"
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const baseUrl = getBaseUrl(request)
|
||||||
const stripeSecretKey = process.env.STRIPE_SECRET_KEY?.trim()
|
const stripeSecretKey = process.env.STRIPE_SECRET_KEY?.trim()
|
||||||
|
|
||||||
if (!stripeSecretKey) {
|
if (!stripeSecretKey) {
|
||||||
|
|
@ -25,7 +38,7 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
const portalSession = await stripe.billingPortal.sessions.create({
|
const portalSession = await stripe.billingPortal.sessions.create({
|
||||||
customer: checkoutSession.customer as string,
|
customer: checkoutSession.customer as string,
|
||||||
return_url: `${request.nextUrl.origin}`,
|
return_url: baseUrl,
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({ url: portalSession.url })
|
return NextResponse.json({ url: portalSession.url })
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,86 @@ import Stripe from "stripe"
|
||||||
|
|
||||||
export const runtime = "edge"
|
export const runtime = "edge"
|
||||||
|
|
||||||
|
async function sendThankYouEmail(
|
||||||
|
customerEmail: string,
|
||||||
|
customerName: string | null,
|
||||||
|
amount: number,
|
||||||
|
currency: string,
|
||||||
|
productName: string
|
||||||
|
) {
|
||||||
|
const sendgridApiKey = process.env.SENDGRID_API_KEY?.trim()
|
||||||
|
|
||||||
|
if (!sendgridApiKey) {
|
||||||
|
console.log("⚠️ SENDGRID_API_KEY not configured, skipping email")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedAmount = new Intl.NumberFormat("en-CA", {
|
||||||
|
style: "currency",
|
||||||
|
currency: currency.toUpperCase(),
|
||||||
|
}).format(amount / 100)
|
||||||
|
|
||||||
|
const name = customerName || "Valued Supporter"
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("https://api.sendgrid.com/v3/mail/send", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${sendgridApiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
personalizations: [
|
||||||
|
{
|
||||||
|
to: [{ email: customerEmail, name: name }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
from: {
|
||||||
|
email: "noreply@alertbaytrumpeter.com",
|
||||||
|
name: "Alert Bay Trumpeter",
|
||||||
|
},
|
||||||
|
subject: "Thank You for Supporting the Alert Bay Trumpeter!",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text/html",
|
||||||
|
value: `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h1 style="color: #1e40af;">Thank You, ${name}!</h1>
|
||||||
|
<p>Your generous support means the world to Jerry Higginson, the Alert Bay Trumpeter.</p>
|
||||||
|
<div style="background-color: #f0f9ff; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<h2 style="color: #1e40af; margin-top: 0;">Subscription Details</h2>
|
||||||
|
<p><strong>Amount:</strong> ${formattedAmount}/month</p>
|
||||||
|
<p><strong>Plan:</strong> ${productName}</p>
|
||||||
|
</div>
|
||||||
|
<p>Your monthly contribution helps Jerry continue spreading smiles at sea by serenading cruise ship passengers with his trumpet.</p>
|
||||||
|
<p>With over 1,000 nautical serenades since 1996, your support keeps the music playing!</p>
|
||||||
|
<p style="margin-top: 30px;">
|
||||||
|
With gratitude,<br>
|
||||||
|
<strong>Jerry Higginson</strong><br>
|
||||||
|
The Alert Bay Trumpeter
|
||||||
|
</p>
|
||||||
|
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 30px 0;">
|
||||||
|
<p style="color: #6b7280; font-size: 12px;">
|
||||||
|
If you have any questions, contact us at alertbaytrumpeter@icloud.com
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok || response.status === 202) {
|
||||||
|
console.log(`✅ Thank you email sent to ${customerEmail}`)
|
||||||
|
} else {
|
||||||
|
const error = await response.text()
|
||||||
|
console.error(`❌ Failed to send email: ${error}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Error sending email:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const stripeSecretKey = process.env.STRIPE_SECRET_KEY?.trim()
|
const stripeSecretKey = process.env.STRIPE_SECRET_KEY?.trim()
|
||||||
|
|
@ -33,12 +113,45 @@ export async function POST(request: NextRequest) {
|
||||||
case "checkout.session.completed":
|
case "checkout.session.completed":
|
||||||
const session = event.data.object as Stripe.Checkout.Session
|
const session = event.data.object as Stripe.Checkout.Session
|
||||||
console.log(`💰 Payment successful for session: ${session.id}`)
|
console.log(`💰 Payment successful for session: ${session.id}`)
|
||||||
// Handle successful payment here
|
|
||||||
|
// Send thank you email for subscriptions
|
||||||
|
if (session.mode === "subscription" && session.customer_details?.email) {
|
||||||
|
// Get subscription details
|
||||||
|
const subscriptionId = session.subscription as string
|
||||||
|
if (subscriptionId) {
|
||||||
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId)
|
||||||
|
const item = subscription.items.data[0]
|
||||||
|
const amount = item.price.unit_amount || 0
|
||||||
|
const currency = item.price.currency
|
||||||
|
const productId = item.price.product as string
|
||||||
|
const product = await stripe.products.retrieve(productId)
|
||||||
|
|
||||||
|
await sendThankYouEmail(
|
||||||
|
session.customer_details.email,
|
||||||
|
session.customer_details.name,
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
product.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
break
|
break
|
||||||
case "payment_intent.succeeded":
|
|
||||||
const paymentIntent = event.data.object as Stripe.PaymentIntent
|
case "customer.subscription.created":
|
||||||
console.log(`💰 Payment succeeded: ${paymentIntent.id}`)
|
const newSubscription = event.data.object as Stripe.Subscription
|
||||||
|
console.log(`🎉 New subscription created: ${newSubscription.id}`)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case "invoice.paid":
|
||||||
|
const invoice = event.data.object as Stripe.Invoice
|
||||||
|
console.log(`💵 Invoice paid: ${invoice.id}`)
|
||||||
|
break
|
||||||
|
|
||||||
|
case "customer.subscription.deleted":
|
||||||
|
const cancelledSubscription = event.data.object as Stripe.Subscription
|
||||||
|
console.log(`😢 Subscription cancelled: ${cancelledSubscription.id}`)
|
||||||
|
break
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.log(`Unhandled event type ${event.type}`)
|
console.log(`Unhandled event type ${event.type}`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
23
app/page.tsx
23
app/page.tsx
|
|
@ -50,21 +50,14 @@ export default function HomePage() {
|
||||||
try {
|
try {
|
||||||
console.log("[v0] Starting donation process for product:", product.name)
|
console.log("[v0] Starting donation process for product:", product.name)
|
||||||
|
|
||||||
let requestBody: any
|
// Always use subscription mode for products fetched from Stripe
|
||||||
|
const requestBody = {
|
||||||
if (product.lookup_key) {
|
productId: product.id,
|
||||||
// Use lookup_key for one-time donations (legacy sponsor tiers)
|
productName: product.name,
|
||||||
requestBody = { lookup_key: product.lookup_key }
|
price: product.price,
|
||||||
} else {
|
currency: product.currency,
|
||||||
// Use product info for subscription checkouts
|
description: product.description,
|
||||||
requestBody = {
|
mode: "subscription",
|
||||||
productId: product.id,
|
|
||||||
productName: product.name,
|
|
||||||
price: product.price,
|
|
||||||
currency: product.currency,
|
|
||||||
description: product.description,
|
|
||||||
mode: "subscription",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch("/api/create-checkout-session", {
|
const response = await fetch("/api/create-checkout-session", {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
eslint: {
|
eslint: {
|
||||||
ignoreDuringBuilds: true,
|
ignoreDuringBuilds: true,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue