post-completion flow: survey, referral program, review ask
- Migration 081: referral_codes, referral_uses, exit_surveys tables - API: POST /api/v1/survey, POST /api/v1/referral/check, GET /api/v1/referral/:email - Worker: completion_emails.py — sends completion + 24h follow-up (survey + referral) - Survey page: /survey/?order=X&rating=N — star rating, feedback, Google review ask - Referral: REF-FIRSTNAME codes, $25 credit per referred order, no limit - Low ratings (1-3 stars) trigger Telegram alert for admin follow-up - Cron: every 15 minutes
This commit is contained in:
parent
6b20ba7f08
commit
ad3d189b2b
6 changed files with 655 additions and 0 deletions
47
api/migrations/081_referral_and_survey.sql
Normal file
47
api/migrations/081_referral_and_survey.sql
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
-- Referral codes and survey responses for post-completion flow
|
||||
|
||||
-- Referral codes (one per customer)
|
||||
CREATE TABLE IF NOT EXISTS referral_codes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE, -- e.g. REF-MARKADAMS
|
||||
customer_email TEXT NOT NULL,
|
||||
customer_name TEXT,
|
||||
credit_cents INTEGER DEFAULT 2500, -- $25 credit
|
||||
times_used INTEGER DEFAULT 0,
|
||||
total_earned_cents INTEGER DEFAULT 0,
|
||||
balance_cents INTEGER DEFAULT 0, -- unused credit
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_referral_codes_email ON referral_codes(customer_email);
|
||||
CREATE INDEX IF NOT EXISTS idx_referral_codes_code ON referral_codes(code);
|
||||
|
||||
-- Referral usage tracking
|
||||
CREATE TABLE IF NOT EXISTS referral_uses (
|
||||
id SERIAL PRIMARY KEY,
|
||||
referral_code TEXT NOT NULL REFERENCES referral_codes(code),
|
||||
referred_order TEXT NOT NULL, -- order_number of the new customer's order
|
||||
referred_email TEXT NOT NULL,
|
||||
credit_cents INTEGER DEFAULT 2500,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Exit survey responses
|
||||
CREATE TABLE IF NOT EXISTS exit_surveys (
|
||||
id SERIAL PRIMARY KEY,
|
||||
order_number TEXT NOT NULL,
|
||||
customer_email TEXT NOT NULL,
|
||||
rating INTEGER CHECK (rating BETWEEN 1 AND 5),
|
||||
feedback TEXT,
|
||||
would_recommend BOOLEAN,
|
||||
review_link_clicked BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_exit_surveys_order ON exit_surveys(order_number);
|
||||
|
||||
-- Track which completion emails have been sent (prevent duplicates)
|
||||
ALTER TABLE compliance_orders
|
||||
ADD COLUMN IF NOT EXISTS completion_email_sent_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS followup_email_sent_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS referral_code TEXT;
|
||||
|
|
@ -47,6 +47,7 @@ import portalEsignGenericRouter from "./routes/portal-esign-generic.js";
|
|||
import pucRouter from "./routes/puc.js";
|
||||
import fccCarrierRegRouter from "./routes/fcc-carrier-registration.js";
|
||||
import dotLookupRouter from "./routes/dot-lookup.js";
|
||||
import surveyRouter from "./routes/survey.js";
|
||||
|
||||
const app = express();
|
||||
|
||||
|
|
@ -120,6 +121,7 @@ app.use(foreignQualRouter);
|
|||
app.use(pucRouter);
|
||||
app.use(fccCarrierRegRouter);
|
||||
app.use(dotLookupRouter);
|
||||
app.use(surveyRouter);
|
||||
app.use(adminCryptoRouter);
|
||||
// Note: identityRouter mounted above express.json() for webhook route,
|
||||
// but also handles non-webhook routes (create-session, poll) which work fine with json()
|
||||
|
|
|
|||
152
api/src/routes/survey.ts
Normal file
152
api/src/routes/survey.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
/**
|
||||
* Exit survey + referral endpoints.
|
||||
*
|
||||
* POST /api/v1/survey — Submit exit survey response
|
||||
* GET /api/v1/survey/:order — Get survey form data (pre-fill)
|
||||
* POST /api/v1/referral/check — Validate a referral code
|
||||
* GET /api/v1/referral/:email — Get referral code + stats for a customer
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { pool } from "../db.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN || "";
|
||||
const TELEGRAM_CHAT_ID = process.env.TELEGRAM_CHAT_ID || "";
|
||||
|
||||
// POST /api/v1/survey — Submit exit survey
|
||||
router.post("/api/v1/survey", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { order_number, rating, feedback, would_recommend } = req.body ?? {};
|
||||
if (!order_number || !rating) {
|
||||
res.status(400).json({ error: "order_number and rating required." });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get customer email from order
|
||||
const orderResult = await pool.query(
|
||||
"SELECT customer_email, customer_name, service_name FROM compliance_orders WHERE order_number = $1",
|
||||
[order_number],
|
||||
);
|
||||
const order = orderResult.rows[0] as Record<string, unknown> | undefined;
|
||||
const email = (order?.customer_email as string) || "";
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO exit_surveys (order_number, customer_email, rating, feedback, would_recommend)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[order_number, email, rating, feedback || null, would_recommend ?? null],
|
||||
);
|
||||
|
||||
// Telegram alert for low ratings
|
||||
if (rating <= 3 && TELEGRAM_BOT_TOKEN && TELEGRAM_CHAT_ID) {
|
||||
const text = `⚠️ Low survey rating\nOrder: ${order_number}\nRating: ${"⭐".repeat(rating)}\nCustomer: ${email}\nFeedback: ${feedback || "(none)"}`;
|
||||
fetch(`https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ chat_id: TELEGRAM_CHAT_ID, text }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// If high rating, return Google review link
|
||||
const showReview = rating >= 4;
|
||||
const googleReviewUrl = process.env.GOOGLE_REVIEW_URL || "";
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
show_review: showReview,
|
||||
google_review_url: googleReviewUrl || null,
|
||||
referral_code: order ? await getOrCreateReferralCode(email, (order.customer_name as string) || "") : null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[survey] Error:", err);
|
||||
res.status(500).json({ error: "Survey submission failed." });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/v1/referral/check — Validate a referral code
|
||||
router.post("/api/v1/referral/check", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { code } = req.body ?? {};
|
||||
if (!code) {
|
||||
res.status(400).json({ valid: false, error: "code required." });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
"SELECT code, customer_name FROM referral_codes WHERE code = $1",
|
||||
[code.toUpperCase()],
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
res.json({ valid: false });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
valid: true,
|
||||
referrer_name: (result.rows[0] as Record<string, unknown>).customer_name,
|
||||
});
|
||||
} catch {
|
||||
res.status(500).json({ valid: false, error: "Check failed." });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/v1/referral/:email — Get referral info for a customer
|
||||
router.get("/api/v1/referral/:email", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const email = req.params.email;
|
||||
const result = await pool.query(
|
||||
"SELECT code, times_used, total_earned_cents, balance_cents FROM referral_codes WHERE customer_email = $1",
|
||||
[email],
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
res.json({ has_code: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const r = result.rows[0] as Record<string, unknown>;
|
||||
res.json({
|
||||
has_code: true,
|
||||
code: r.code,
|
||||
times_used: r.times_used,
|
||||
total_earned: ((r.total_earned_cents as number) / 100).toFixed(2),
|
||||
balance: ((r.balance_cents as number) / 100).toFixed(2),
|
||||
});
|
||||
} catch {
|
||||
res.status(500).json({ error: "Failed." });
|
||||
}
|
||||
});
|
||||
|
||||
async function getOrCreateReferralCode(email: string, name: string): Promise<string> {
|
||||
// Check if already exists
|
||||
const existing = await pool.query(
|
||||
"SELECT code FROM referral_codes WHERE customer_email = $1",
|
||||
[email],
|
||||
);
|
||||
if (existing.rows.length > 0) {
|
||||
return (existing.rows[0] as Record<string, unknown>).code as string;
|
||||
}
|
||||
|
||||
// Generate code from name
|
||||
const cleanName = name.replace(/[^a-zA-Z]/g, "").toUpperCase().substring(0, 12);
|
||||
let code = `REF-${cleanName || "CUSTOMER"}`;
|
||||
|
||||
// Ensure unique
|
||||
const check = await pool.query("SELECT 1 FROM referral_codes WHERE code = $1", [code]);
|
||||
if (check.rows.length > 0) {
|
||||
code = `${code}${Math.floor(Math.random() * 99)}`;
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO referral_codes (code, customer_email, customer_name)
|
||||
VALUES ($1, $2, $3) ON CONFLICT (customer_email) DO NOTHING`,
|
||||
[code, email, name],
|
||||
);
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
export default router;
|
||||
|
|
@ -134,6 +134,14 @@ worker_crons:
|
|||
on_calendar: "*-*-* 08:00:00 UTC"
|
||||
persistent: true
|
||||
|
||||
# Post-completion emails — every 15 minutes.
|
||||
# Sends completion confirmations + 24h follow-up (survey + referral).
|
||||
- name: pw-completion-emails
|
||||
description: Send post-completion and follow-up emails
|
||||
module: scripts.workers.completion_emails
|
||||
on_calendar: "*-*-* *:0/15:00 UTC"
|
||||
persistent: false
|
||||
|
||||
# Florida entity downloader — daily 07:30 UTC.
|
||||
# Downloads daily diffs from FL Sunbiz SFTP (free public access).
|
||||
- name: pw-fl-entity-downloader
|
||||
|
|
|
|||
274
scripts/workers/completion_emails.py
Normal file
274
scripts/workers/completion_emails.py
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
"""Post-completion email flow.
|
||||
|
||||
Sends two emails after an order is marked completed:
|
||||
1. Immediately: "Your filing is complete!" with documents
|
||||
2. 24 hours later: Exit survey + referral program + review ask
|
||||
|
||||
Run via cron every 15 minutes:
|
||||
python -m scripts.workers.completion_emails
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import smtplib
|
||||
import sys
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
import psycopg2
|
||||
|
||||
LOG = logging.getLogger("workers.completion_emails")
|
||||
|
||||
DB_URL = os.getenv("DATABASE_URL", "")
|
||||
SMTP_FROM = "noreply@performancewest.net"
|
||||
SITE_URL = os.getenv("SITE_URL", "https://performancewest.net")
|
||||
API_URL = os.getenv("API_URL", "https://api.performancewest.net")
|
||||
GOOGLE_REVIEW_URL = os.getenv("GOOGLE_REVIEW_URL", "")
|
||||
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
|
||||
TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "")
|
||||
|
||||
|
||||
def send_email(to: str, subject: str, html: str):
|
||||
"""Send an HTML email via local Postfix."""
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = f"Performance West <{SMTP_FROM}>"
|
||||
msg["To"] = to
|
||||
msg["Subject"] = subject
|
||||
msg.attach(MIMEText(html, "html"))
|
||||
|
||||
with smtplib.SMTP("localhost", 25) as s:
|
||||
s.sendmail(SMTP_FROM, [to], msg.as_string())
|
||||
|
||||
|
||||
def get_or_create_referral_code(conn, email: str, name: str) -> str:
|
||||
"""Get or create a referral code for a customer."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT code FROM referral_codes WHERE customer_email = %s", (email,))
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
return row[0]
|
||||
|
||||
clean = "".join(c for c in name.upper() if c.isalpha())[:12]
|
||||
code = f"REF-{clean or 'CUSTOMER'}"
|
||||
|
||||
# Ensure unique
|
||||
cur.execute("SELECT 1 FROM referral_codes WHERE code = %s", (code,))
|
||||
if cur.fetchone():
|
||||
import random
|
||||
code = f"{code}{random.randint(10, 99)}"
|
||||
|
||||
try:
|
||||
cur.execute(
|
||||
"INSERT INTO referral_codes (code, customer_email, customer_name) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
|
||||
(code, email, name),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
|
||||
return code
|
||||
|
||||
|
||||
def completion_email_html(order_number: str, service_name: str, customer_name: str) -> str:
|
||||
"""Generate the completion email HTML."""
|
||||
first = customer_name.split()[0] if customer_name else "there"
|
||||
return f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
|
||||
<body style="font-family:-apple-system,system-ui,sans-serif;background:#f8fafc;margin:0;padding:0">
|
||||
<div style="max-width:560px;margin:0 auto;padding:20px">
|
||||
<div style="background:#1a2744;border-radius:12px 12px 0 0;padding:24px;text-align:center">
|
||||
<img src="{SITE_URL}/images/logo.png" alt="Performance West" style="height:40px">
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px;padding:32px">
|
||||
<div style="text-align:center;margin-bottom:24px">
|
||||
<div style="width:56px;height:56px;background:#f0fdf4;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;margin-bottom:12px">
|
||||
<span style="font-size:28px">✅</span>
|
||||
</div>
|
||||
<h1 style="font-size:22px;color:#1a2744;margin:0">Your {service_name} Is Complete!</h1>
|
||||
</div>
|
||||
<p style="font-size:15px;color:#374151;line-height:1.6">Hi {first},</p>
|
||||
<p style="font-size:15px;color:#374151;line-height:1.6">
|
||||
Great news — your <strong>{service_name}</strong> has been completed and filed successfully.
|
||||
Order reference: <strong>{order_number}</strong>.
|
||||
</p>
|
||||
<p style="font-size:15px;color:#374151;line-height:1.6">
|
||||
You can view your documents and track all your filings in your client portal:
|
||||
</p>
|
||||
<div style="text-align:center;margin:24px 0">
|
||||
<a href="{SITE_URL}/portal/dashboard" style="display:inline-block;padding:12px 32px;background:#f97316;color:#fff;font-weight:700;border-radius:8px;text-decoration:none;font-size:15px">View in Portal →</a>
|
||||
</div>
|
||||
<p style="font-size:14px;color:#64748b;line-height:1.6">
|
||||
If you have any questions about your filing, reply to this email or call us at
|
||||
<a href="tel:8884110383" style="color:#f97316;font-weight:600">(888) 411-0383</a>.
|
||||
</p>
|
||||
<p style="font-size:14px;color:#64748b">— The Performance West Team</p>
|
||||
</div>
|
||||
<div style="text-align:center;padding:16px;font-size:11px;color:#94a3b8">
|
||||
Performance West Inc. · (888) 411-0383 · performancewest.net
|
||||
</div>
|
||||
</div>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
def followup_email_html(
|
||||
order_number: str,
|
||||
service_name: str,
|
||||
customer_name: str,
|
||||
referral_code: str,
|
||||
) -> str:
|
||||
"""Generate the 24h follow-up email with survey + referral."""
|
||||
first = customer_name.split()[0] if customer_name else "there"
|
||||
survey_url = f"{SITE_URL}/survey?order={order_number}"
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
|
||||
<body style="font-family:-apple-system,system-ui,sans-serif;background:#f8fafc;margin:0;padding:0">
|
||||
<div style="max-width:560px;margin:0 auto;padding:20px">
|
||||
<div style="background:#1a2744;border-radius:12px 12px 0 0;padding:24px;text-align:center">
|
||||
<img src="{SITE_URL}/images/logo.png" alt="Performance West" style="height:40px">
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px;padding:32px">
|
||||
<h1 style="font-size:20px;color:#1a2744;margin:0 0 16px">How did we do, {first}?</h1>
|
||||
|
||||
<p style="font-size:15px;color:#374151;line-height:1.6">
|
||||
Your <strong>{service_name}</strong> was completed yesterday. We'd love your feedback — it takes 30 seconds.
|
||||
</p>
|
||||
|
||||
<!-- Star rating -->
|
||||
<div style="text-align:center;margin:24px 0">
|
||||
<p style="font-size:14px;color:#64748b;margin:0 0 8px">Rate your experience:</p>
|
||||
<div style="font-size:32px;letter-spacing:8px">
|
||||
<a href="{survey_url}&rating=1" style="text-decoration:none">⭐</a>
|
||||
<a href="{survey_url}&rating=2" style="text-decoration:none">⭐</a>
|
||||
<a href="{survey_url}&rating=3" style="text-decoration:none">⭐</a>
|
||||
<a href="{survey_url}&rating=4" style="text-decoration:none">⭐</a>
|
||||
<a href="{survey_url}&rating=5" style="text-decoration:none">⭐</a>
|
||||
</div>
|
||||
<p style="font-size:12px;color:#94a3b8;margin:4px 0 0">Click a star to rate</p>
|
||||
</div>
|
||||
|
||||
<div style="border-top:1px solid #e2e8f0;margin:24px 0"></div>
|
||||
|
||||
<!-- Referral -->
|
||||
<div style="background:#eff6ff;border:1px solid #bfdbfe;border-radius:10px;padding:20px;margin-bottom:20px">
|
||||
<h3 style="font-size:16px;color:#1e3a5f;margin:0 0 8px">Know another trucker?</h3>
|
||||
<p style="font-size:14px;color:#1d4ed8;margin:0 0 12px;line-height:1.5">
|
||||
Share your referral code and earn <strong>$25 credit</strong> for each order they place.
|
||||
</p>
|
||||
<div style="background:#fff;border:2px dashed #3b82f6;border-radius:8px;padding:12px;text-align:center">
|
||||
<p style="font-size:12px;color:#64748b;margin:0 0 4px">Your referral code:</p>
|
||||
<p style="font-size:22px;font-weight:700;color:#1e3a5f;margin:0;letter-spacing:1px">{referral_code}</p>
|
||||
</div>
|
||||
<p style="font-size:12px;color:#64748b;margin:8px 0 0;text-align:center">
|
||||
They can enter this code at checkout. You earn $25 credit per order — no limit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style="font-size:14px;color:#64748b;line-height:1.6">
|
||||
Thank you for choosing Performance West. We're here whenever you need us.
|
||||
</p>
|
||||
<p style="font-size:14px;color:#64748b">— The Performance West Team</p>
|
||||
</div>
|
||||
<div style="text-align:center;padding:16px;font-size:11px;color:#94a3b8">
|
||||
Performance West Inc. · (888) 411-0383 · performancewest.net
|
||||
</div>
|
||||
</div>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
def process_completions():
|
||||
"""Find completed orders and send emails."""
|
||||
conn = psycopg2.connect(DB_URL)
|
||||
cur = conn.cursor()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# 1. Send completion emails (order completed, email not sent yet)
|
||||
cur.execute("""
|
||||
SELECT order_number, customer_email, customer_name, service_name, updated_at
|
||||
FROM compliance_orders
|
||||
WHERE payment_status = 'paid'
|
||||
AND completion_email_sent_at IS NULL
|
||||
AND updated_at < NOW() - interval '5 minutes'
|
||||
AND (
|
||||
intake_data->>'status' = 'completed'
|
||||
OR intake_data->>'status' = 'delivered'
|
||||
OR intake_data->>'status' = 'filed'
|
||||
)
|
||||
ORDER BY updated_at
|
||||
LIMIT 20
|
||||
""")
|
||||
completions = cur.fetchall()
|
||||
|
||||
for row in completions:
|
||||
order_number, email, name, service_name, updated_at = row
|
||||
if not email:
|
||||
continue
|
||||
|
||||
try:
|
||||
html = completion_email_html(order_number, service_name or "compliance filing", name or "")
|
||||
send_email(email, f"✅ Your {service_name or 'filing'} is complete — {order_number}", html)
|
||||
|
||||
cur.execute(
|
||||
"UPDATE compliance_orders SET completion_email_sent_at = NOW() WHERE order_number = %s",
|
||||
(order_number,),
|
||||
)
|
||||
conn.commit()
|
||||
LOG.info("[completion] Sent completion email for %s to %s", order_number, email)
|
||||
except Exception as exc:
|
||||
LOG.error("[completion] Failed for %s: %s", order_number, exc)
|
||||
conn.rollback()
|
||||
|
||||
# 2. Send 24h follow-up emails (completion email sent 24h+ ago, follow-up not sent)
|
||||
cur.execute("""
|
||||
SELECT order_number, customer_email, customer_name, service_name
|
||||
FROM compliance_orders
|
||||
WHERE payment_status = 'paid'
|
||||
AND completion_email_sent_at IS NOT NULL
|
||||
AND completion_email_sent_at < NOW() - interval '24 hours'
|
||||
AND followup_email_sent_at IS NULL
|
||||
ORDER BY completion_email_sent_at
|
||||
LIMIT 20
|
||||
""")
|
||||
followups = cur.fetchall()
|
||||
|
||||
for row in followups:
|
||||
order_number, email, name, service_name = row
|
||||
if not email:
|
||||
continue
|
||||
|
||||
try:
|
||||
referral_code = get_or_create_referral_code(conn, email, name or "")
|
||||
html = followup_email_html(order_number, service_name or "compliance filing", name or "", referral_code)
|
||||
send_email(email, f"How was your experience? + Earn $25 referrals", html)
|
||||
|
||||
cur.execute(
|
||||
"UPDATE compliance_orders SET followup_email_sent_at = NOW(), referral_code = %s WHERE order_number = %s",
|
||||
(referral_code, order_number),
|
||||
)
|
||||
conn.commit()
|
||||
LOG.info("[followup] Sent follow-up email for %s to %s (ref: %s)", order_number, email, referral_code)
|
||||
except Exception as exc:
|
||||
LOG.error("[followup] Failed for %s: %s", order_number, exc)
|
||||
conn.rollback()
|
||||
|
||||
conn.close()
|
||||
|
||||
total = len(completions) + len(followups)
|
||||
if total:
|
||||
LOG.info("[completion_emails] Processed %d completion + %d follow-up emails", len(completions), len(followups))
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
|
||||
)
|
||||
process_completions()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
172
site/public/survey/index.html
Normal file
172
site/public/survey/index.html
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>How Did We Do? | Performance West</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<script>
|
||||
window.__PW_API = (function() {
|
||||
var h = window.location.hostname;
|
||||
if (h === "localhost" || h === "127.0.0.1") return "http://" + h + ":3001";
|
||||
if (h === "dev.performancewest.net") return "https://api.dev.performancewest.net";
|
||||
return "https://api.performancewest.net";
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, system-ui, sans-serif; background: #f8fafc; min-height: 100vh; display: flex; flex-direction: column; }
|
||||
.header { background: #1a2744; padding: 20px; text-align: center; }
|
||||
.header img { height: 40px; }
|
||||
.main { flex: 1; padding: 24px; max-width: 520px; margin: 0 auto; width: 100%; }
|
||||
.card { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 32px; margin-bottom: 16px; }
|
||||
h1 { font-size: 22px; color: #1a2744; margin-bottom: 8px; text-align: center; }
|
||||
.subtitle { font-size: 15px; color: #64748b; text-align: center; margin-bottom: 24px; }
|
||||
.stars { display: flex; justify-content: center; gap: 8px; margin-bottom: 24px; }
|
||||
.star { font-size: 40px; cursor: pointer; transition: transform 0.1s; opacity: 0.3; }
|
||||
.star.active { opacity: 1; }
|
||||
.star:hover { transform: scale(1.2); }
|
||||
textarea { width: 100%; padding: 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 14px; resize: vertical; min-height: 80px; font-family: inherit; }
|
||||
.btn { display: block; width: 100%; padding: 14px; background: #f97316; color: #fff; border: none; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; margin-top: 16px; }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.review-box { background: #f0fdf4; border: 2px solid #86efac; border-radius: 12px; padding: 24px; text-align: center; margin-top: 16px; }
|
||||
.referral-box { background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 12px; padding: 20px; margin-top: 16px; }
|
||||
.referral-code { background: #fff; border: 2px dashed #3b82f6; border-radius: 8px; padding: 12px; text-align: center; margin: 12px 0; }
|
||||
.referral-code span { font-size: 24px; font-weight: 700; color: #1e3a5f; letter-spacing: 1px; }
|
||||
.footer { padding: 16px; text-align: center; font-size: 11px; color: #94a3b8; border-top: 1px solid #e2e8f0; }
|
||||
.hidden { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<img src="/images/logo.png" alt="Performance West">
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<!-- Survey form -->
|
||||
<div id="survey-form" class="card">
|
||||
<h1>How was your experience?</h1>
|
||||
<p class="subtitle">Your feedback helps us serve truckers better.</p>
|
||||
|
||||
<div class="stars" id="star-row">
|
||||
<span class="star" data-rating="1">⭐</span>
|
||||
<span class="star" data-rating="2">⭐</span>
|
||||
<span class="star" data-rating="3">⭐</span>
|
||||
<span class="star" data-rating="4">⭐</span>
|
||||
<span class="star" data-rating="5">⭐</span>
|
||||
</div>
|
||||
|
||||
<p id="rating-label" style="text-align:center;font-size:14px;color:#64748b;margin-bottom:16px"></p>
|
||||
|
||||
<label style="font-size:14px;font-weight:600;color:#374151;display:block;margin-bottom:6px">Anything you'd like us to know? <span style="color:#94a3b8">(optional)</span></label>
|
||||
<textarea id="feedback" placeholder="What went well? What could we improve?"></textarea>
|
||||
|
||||
<button type="button" id="submit-btn" class="btn" disabled>Submit Feedback</button>
|
||||
</div>
|
||||
|
||||
<!-- Thank you + review ask (shown after submit if rating >= 4) -->
|
||||
<div id="thank-you" class="hidden">
|
||||
<div class="card" style="text-align:center">
|
||||
<div style="font-size:48px;margin-bottom:12px">🙏</div>
|
||||
<h1>Thank you for your feedback!</h1>
|
||||
<p class="subtitle">We appreciate you taking the time.</p>
|
||||
</div>
|
||||
|
||||
<div id="review-ask" class="review-box hidden">
|
||||
<h3 style="font-size:16px;color:#166534;margin-bottom:8px">Glad you had a great experience!</h3>
|
||||
<p style="font-size:14px;color:#374151;margin-bottom:16px">Would you mind leaving us a quick Google review? It helps other truckers find us and takes about 30 seconds.</p>
|
||||
<a id="google-review-link" href="#" target="_blank" style="display:inline-block;padding:12px 28px;background:#059669;color:#fff;font-weight:600;border-radius:8px;text-decoration:none;font-size:15px">Leave a Google Review →</a>
|
||||
</div>
|
||||
|
||||
<div id="referral-section" class="referral-box hidden">
|
||||
<h3 style="font-size:16px;color:#1e3a5f;margin-bottom:6px">Know another trucker who needs help?</h3>
|
||||
<p style="font-size:14px;color:#1d4ed8;margin-bottom:4px">Share your referral code and earn <strong>$25 credit</strong> for each order they place.</p>
|
||||
<div class="referral-code">
|
||||
<p style="font-size:11px;color:#64748b;margin-bottom:4px">Your referral code:</p>
|
||||
<span id="referral-code-display"></span>
|
||||
</div>
|
||||
<p style="font-size:12px;color:#64748b">They enter this code at checkout. You earn $25 per order — no limit.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
Performance West Inc. · (888) 411-0383 · performancewest.net
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var API = window.__PW_API;
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var orderNumber = params.get("order") || "";
|
||||
var preRating = parseInt(params.get("rating") || "0");
|
||||
var selectedRating = 0;
|
||||
|
||||
var stars = document.querySelectorAll(".star");
|
||||
var ratingLabel = document.getElementById("rating-label");
|
||||
var submitBtn = document.getElementById("submit-btn");
|
||||
var labels = ["", "Poor", "Fair", "Good", "Great", "Excellent"];
|
||||
|
||||
function setRating(r) {
|
||||
selectedRating = r;
|
||||
stars.forEach(function(s, i) {
|
||||
s.classList.toggle("active", i < r);
|
||||
});
|
||||
ratingLabel.textContent = labels[r] || "";
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
|
||||
stars.forEach(function(s) {
|
||||
s.addEventListener("click", function() {
|
||||
setRating(parseInt(s.dataset.rating));
|
||||
});
|
||||
});
|
||||
|
||||
// Pre-select from URL param
|
||||
if (preRating >= 1 && preRating <= 5) {
|
||||
setRating(preRating);
|
||||
}
|
||||
|
||||
submitBtn.addEventListener("click", function() {
|
||||
if (!selectedRating || !orderNumber) return;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = "Submitting...";
|
||||
|
||||
fetch(API + "/api/v1/survey", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
order_number: orderNumber,
|
||||
rating: selectedRating,
|
||||
feedback: document.getElementById("feedback").value,
|
||||
would_recommend: selectedRating >= 4,
|
||||
}),
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
document.getElementById("survey-form").classList.add("hidden");
|
||||
document.getElementById("thank-you").classList.remove("hidden");
|
||||
|
||||
// Show Google review if high rating
|
||||
if (d.show_review && d.google_review_url) {
|
||||
var reviewEl = document.getElementById("review-ask");
|
||||
reviewEl.classList.remove("hidden");
|
||||
document.getElementById("google-review-link").href = d.google_review_url;
|
||||
}
|
||||
|
||||
// Show referral code
|
||||
if (d.referral_code) {
|
||||
document.getElementById("referral-section").classList.remove("hidden");
|
||||
document.getElementById("referral-code-display").textContent = d.referral_code;
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
submitBtn.textContent = "Submit Feedback";
|
||||
submitBtn.disabled = false;
|
||||
alert("Something went wrong. Please try again.");
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue