diff --git a/app/api/webhook/route.ts b/app/api/webhook/route.ts index 012cf43..fa44d96 100644 --- a/app/api/webhook/route.ts +++ b/app/api/webhook/route.ts @@ -1,6 +1,8 @@ import { type NextRequest, NextResponse } from "next/server" import Stripe from "stripe" import { updatePaymentStatus } from "@/lib/google-sheets" +import { sendPaymentConfirmation } from "@/lib/email" +import { addToListmonk } from "@/lib/listmonk" // Lazy initialization to avoid build-time errors let stripe: Stripe | null = null @@ -61,7 +63,30 @@ export async function POST(request: NextRequest) { console.error(`[Webhook] Failed to update Google Sheet for ${metadata.name}`) } - // TODO: Send confirmation email + // Send payment confirmation email + if (customerEmail) { + await sendPaymentConfirmation({ + name: metadata.name || "", + email: customerEmail, + amountPaid: session.amount_total + ? `€${(session.amount_total / 100).toFixed(2)}` + : "", + paymentMethod: session.payment_method_types?.[0] || "card", + contributions: metadata.contributions || "", + dietary: metadata.dietary || "", + }) + + // Add to Listmonk newsletter + addToListmonk({ + email: customerEmail, + name: metadata.name || "", + attribs: { + contact: metadata.contact, + contributions: metadata.contributions, + expectations: metadata.expectations, + }, + }).catch((err) => console.error("[Webhook] Listmonk sync failed:", err)) + } break } diff --git a/docker-compose.yml b/docker-compose.yml index 95c95e2..90ecfdd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,14 +12,35 @@ services: - GOOGLE_SERVICE_ACCOUNT_KEY=${GOOGLE_SERVICE_ACCOUNT_KEY} - GOOGLE_SHEET_ID=${GOOGLE_SHEET_ID} - GOOGLE_SHEET_NAME=${GOOGLE_SHEET_NAME:-Registrations} + - SMTP_HOST=${SMTP_HOST:-mail.rmail.online} + - SMTP_PORT=${SMTP_PORT:-587} + - SMTP_USER=${SMTP_USER:-newsletter@cryptocommonsgather.ing} + - SMTP_PASS=${SMTP_PASS} + - EMAIL_FROM=${EMAIL_FROM:-Crypto Commons Gathering } + - LISTMONK_DB_HOST=${LISTMONK_DB_HOST:-listmonk-db} + - LISTMONK_DB_PORT=${LISTMONK_DB_PORT:-5432} + - LISTMONK_DB_NAME=${LISTMONK_DB_NAME:-listmonk} + - LISTMONK_DB_USER=${LISTMONK_DB_USER:-listmonk} + - LISTMONK_DB_PASS=${LISTMONK_DB_PASS} + - LISTMONK_LIST_ID=${LISTMONK_LIST_ID:-22} labels: - "traefik.enable=true" - "traefik.http.routers.ccg.rule=Host(`cryptocommonsgather.ing`) || Host(`www.cryptocommonsgather.ing`)" - "traefik.http.routers.ccg.entrypoints=web,websecure" - "traefik.http.services.ccg.loadbalancer.server.port=3000" + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s networks: - traefik-public + - listmonk-internal networks: traefik-public: external: true + listmonk-internal: + external: + name: listmonk_listmonk-internal diff --git a/lib/email.ts b/lib/email.ts new file mode 100644 index 0000000..944f34a --- /dev/null +++ b/lib/email.ts @@ -0,0 +1,110 @@ +import nodemailer from "nodemailer" + +// Lazy-initialized SMTP transport (Mailcow) +let transporter: nodemailer.Transporter | null = null + +function getTransporter() { + if (!transporter && process.env.SMTP_PASS) { + transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST || "mail.rmail.online", + port: parseInt(process.env.SMTP_PORT || "587"), + secure: false, + auth: { + user: process.env.SMTP_USER || "newsletter@cryptocommonsgather.ing", + pass: process.env.SMTP_PASS, + }, + tls: { rejectUnauthorized: false }, + }) + } + return transporter +} + +const EMAIL_FROM = + process.env.EMAIL_FROM || + "Crypto Commons Gathering " + +interface PaymentConfirmationData { + name: string + email: string + amountPaid: string + paymentMethod: string + contributions: string + dietary: string +} + +export async function sendPaymentConfirmation( + data: PaymentConfirmationData +): Promise { + const transport = getTransporter() + if (!transport) { + console.log("[Email] SMTP not configured, skipping confirmation email") + return false + } + + const html = ` +
+

You're In!

+

Crypto Commons Gathering 2026

+ +

Dear ${data.name},

+ +

Your payment of ${data.amountPaid} has been confirmed. You are now registered for Crypto Commons Gathering 2026 in Austria's Höllental Valley, August 16–22, 2026.

+ +
+

Registration Details

+ + + + + + + + + + ${data.dietary ? `` : ""} +
Amount:${data.amountPaid}
Payment:${data.paymentMethod}
Dietary:${data.dietary}
+
+ + ${data.contributions ? ` +
+ What you're bringing: +

${data.contributions}

+
+ ` : ""} + +

What's Next?

+
    +
  • Join the CCG26 Telegram group to connect with other participants
  • +
  • Start preparing your session proposals
  • +
  • Watch for pre-event communications with logistics details
  • +
+ +

+ See you in the valley,
+ The Crypto Commons Gathering Team +

+ +
+

+ You received this email because you registered at cryptocommonsgather.ing.
+ cryptocommonsgather.ing +

+
+ ` + + try { + const info = await transport.sendMail({ + from: EMAIL_FROM, + to: data.email, + subject: "Registration Confirmed - Crypto Commons Gathering 2026", + html, + }) + console.log( + `[Email] Payment confirmation sent to ${data.email} (${info.messageId})` + ) + return true + } catch (error) { + console.error("[Email] Failed to send payment confirmation:", error) + return false + } +} diff --git a/lib/listmonk.ts b/lib/listmonk.ts new file mode 100644 index 0000000..6528816 --- /dev/null +++ b/lib/listmonk.ts @@ -0,0 +1,92 @@ +import pg from "pg" + +const { Pool } = pg + +// Lazy-initialized Listmonk PostgreSQL connection +let pool: pg.Pool | null = null + +function getPool() { + if (!pool && process.env.LISTMONK_DB_HOST) { + pool = new Pool({ + host: process.env.LISTMONK_DB_HOST, + port: parseInt(process.env.LISTMONK_DB_PORT || "5432"), + database: process.env.LISTMONK_DB_NAME || "listmonk", + user: process.env.LISTMONK_DB_USER || "listmonk", + password: process.env.LISTMONK_DB_PASS || "", + }) + } + return pool +} + +const LISTMONK_LIST_ID = parseInt(process.env.LISTMONK_LIST_ID || "22") // Crypto Commons list + +interface SubscriberData { + email: string + name: string + attribs?: Record +} + +export async function addToListmonk(data: SubscriberData): Promise { + const db = getPool() + if (!db) { + console.log("[Listmonk] Database not configured, skipping") + return false + } + + const client = await db.connect() + try { + const attribs = { + ccg: { + ...data.attribs, + registeredAt: new Date().toISOString(), + }, + } + + // Check if subscriber exists + const existing = await client.query( + "SELECT id, attribs FROM subscribers WHERE email = $1", + [data.email] + ) + + let subscriberId: number + + if (existing.rows.length > 0) { + subscriberId = existing.rows[0].id + const mergedAttribs = { ...existing.rows[0].attribs, ...attribs } + await client.query( + "UPDATE subscribers SET name = $1, attribs = $2, updated_at = NOW() WHERE id = $3", + [data.name, JSON.stringify(mergedAttribs), subscriberId] + ) + console.log( + `[Listmonk] Updated existing subscriber: ${data.email} (ID: ${subscriberId})` + ) + } else { + const result = await client.query( + `INSERT INTO subscribers (uuid, email, name, status, attribs, created_at, updated_at) + VALUES (gen_random_uuid(), $1, $2, 'enabled', $3, NOW(), NOW()) + RETURNING id`, + [data.email, data.name, JSON.stringify(attribs)] + ) + subscriberId = result.rows[0].id + console.log( + `[Listmonk] Created new subscriber: ${data.email} (ID: ${subscriberId})` + ) + } + + // Add to Crypto Commons list + await client.query( + `INSERT INTO subscriber_lists (subscriber_id, list_id, status, created_at, updated_at) + VALUES ($1, $2, 'confirmed', NOW(), NOW()) + ON CONFLICT (subscriber_id, list_id) DO UPDATE SET status = 'confirmed', updated_at = NOW()`, + [subscriberId, LISTMONK_LIST_ID] + ) + console.log(`[Listmonk] Added to list ${LISTMONK_LIST_ID}: ${data.email}`) + + return true + } catch (error) { + console.error("[Listmonk] Error:", error) + return false + } finally { + client.release() + } +} diff --git a/package.json b/package.json index 55276b2..11d0e06 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,6 @@ "@radix-ui/react-toggle": "1.1.1", "@radix-ui/react-toggle-group": "1.1.1", "@radix-ui/react-tooltip": "1.1.6", - "@vercel/analytics": "latest", "autoprefixer": "^10.4.20", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -45,6 +44,8 @@ "date-fns": "4.1.0", "embla-carousel-react": "8.5.1", "googleapis": "^170.1.0", + "nodemailer": "^6.9.0", + "pg": "^8.13.0", "input-otp": "1.4.1", "lucide-react": "^0.454.0", "next": "16.0.10", @@ -65,6 +66,8 @@ "devDependencies": { "@tailwindcss/postcss": "^4.1.9", "@types/node": "^22", + "@types/nodemailer": "^6.4.0", + "@types/pg": "^8.11.0", "@types/react": "^19", "@types/react-dom": "^19", "postcss": "^8.5",