12 Commits

22 changed files with 1046 additions and 52 deletions

View File

@@ -49,6 +49,8 @@ ARG NEXT_PUBLIC_UMAMI_SRC=""
ARG NEXT_PUBLIC_UMAMI_ID=""
ENV NEXT_PUBLIC_UMAMI_SRC=$NEXT_PUBLIC_UMAMI_SRC
ENV NEXT_PUBLIC_UMAMI_ID=$NEXT_PUBLIC_UMAMI_ID
ARG SMTP_USER=""
ENV SMTP_USER=$SMTP_USER
# Shared session cookie across www.* and app.*. Needed at build time too: process.env is
# inlined into the Edge middleware bundle, so a runtime-only value would leave the
# middleware and the route handlers disagreeing about the cookie scope.

View File

@@ -38,6 +38,17 @@ services:
# April 2026 and the schema has moved on through manual SQL since, so running them
# against a fresh database would build a stale schema the app cannot work with.
# Bring the schema in with `pg_dump --schema-only` from production instead.
environment:
# Docker sets HOSTNAME=<container-id>, and the Next.js standalone server binds to
# that single interface. With two networks Caddy then cannot reach the container.
HOSTNAME: "0.0.0.0"
# `db` and `redis` are taken in BOTH networks - by this stack in test-internal and
# by production in qrmaster-network. Production wins the lookup every time, so the
# base file's hostnames point the staging app at the production instances. Container
# names are unique per daemon and cannot be shadowed.
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@qrmaster-test-db:5432/${POSTGRES_DB}?schema=public
DIRECT_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@qrmaster-test-db:5432/${POSTGRES_DB}?schema=public
REDIS_URL: redis://qrmaster-test-redis:6379
entrypoint: ["node", "server.js"]
build:
args:

View File

@@ -39,7 +39,7 @@ services:
- qrmaster-network
# Next.js Application
web:
web:
build:
context: .
dockerfile: Dockerfile
@@ -62,6 +62,9 @@ services:
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-}
INTERNAL_API_SECRET: ${INTERNAL_API_SECRET}
CRON_SECRET: ${CRON_SECRET:-}
SOCIAL_MILESTONE_THRESHOLDS: ${SOCIAL_MILESTONE_THRESHOLDS:-}
SOCIAL_MILESTONE_POST_DELAY_HOURS: ${SOCIAL_MILESTONE_POST_DELAY_HOURS:-}
TIKTOK_CLIENT_KEY: ${TIKTOK_CLIENT_KEY:-}
TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-}
TIKTOK_REDIRECT_URI: ${TIKTOK_REDIRECT_URI:-https://qrmaster.net/api/tiktok/callback}
@@ -85,7 +88,7 @@ services:
RESEND_API_KEY: ${RESEND_API_KEY:-}
SMTP_HOST: ${SMTP_HOST:-smtp.qrmaster.net}
SMTP_PORT: ${SMTP_PORT:-465}
SMTP_USER: ${SMTP_USER:-timo@qrmaster.net}
SMTP_USER: ${SMTP_USER:-info@qrmaster.net}
SMTP_PASS: ${SMTP_PASS:-}
NEWSLETTER_ADMIN_EMAIL: ${NEWSLETTER_ADMIN_EMAIL:-}
NEWSLETTER_ADMIN_PASSWORD: ${NEWSLETTER_ADMIN_PASSWORD:-}
@@ -111,8 +114,27 @@ services:
interval: 10s
timeout: 3s
retries: 10
networks:
- qrmaster-network
networks:
- qrmaster-network
social-worker:
build:
context: ./scripts/social-worker
restart: unless-stopped
environment:
QRMASTER_API_BASE: http://web:3000
INTERNAL_API_SECRET: ${INTERNAL_API_SECRET}
SOCIAL_MILESTONE_POSTING_ENABLED: ${SOCIAL_MILESTONE_POSTING_ENABLED:-false}
SOCIAL_WORKER_INTERVAL_SECONDS: ${SOCIAL_WORKER_INTERVAL_SECONDS:-10}
X_API_KEY: ${X_API_KEY:-}
X_API_SECRET: ${X_API_SECRET:-}
X_ACCESS_TOKEN: ${X_ACCESS_TOKEN:-}
X_ACCESS_TOKEN_SECRET: ${X_ACCESS_TOKEN_SECRET:-}
depends_on:
web:
condition: service_started
networks:
- qrmaster-network
# Adminer - Database Management UI (Optional)

54
docker/init-db.sh Normal file → Executable file
View File

@@ -1,26 +1,28 @@
#!/bin/bash
set -e
# This script runs when the PostgreSQL container is first created
# It ensures the database is properly initialized
echo "🚀 Initializing QR Master database..."
# Create the database if it doesn't exist (already created by POSTGRES_DB)
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
-- Enable required extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
-- Grant privileges
GRANT ALL PRIVILEGES ON DATABASE qrmaster TO postgres;
-- Set timezone
ALTER DATABASE qrmaster SET timezone TO 'UTC';
EOSQL
echo "✅ Database initialization complete!"
echo "📊 Database: $POSTGRES_DB"
echo "👤 User: $POSTGRES_USER"
echo "🌐 Ready to accept connections on port 5432"
#!/bin/bash
set -e
# This script runs when the PostgreSQL container is first created
# It ensures the database is properly initialized
#
# Keep this database-name agnostic: the staging stack (docker-compose.test.yml)
# runs the same script with POSTGRES_DB=qrmaster_test. A hardcoded name aborts
# the init, and the container never becomes healthy.
# Must stay LF-only and executable - Postgres sources non-executable init
# scripts, and CRLF breaks them on the first line.
echo "🚀 Initializing QR Master database..."
# The database itself is already created by POSTGRES_DB
psql -v ON_ERROR_STOP=1 -v dbname="$POSTGRES_DB" --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
-- Enable required extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
-- Set timezone
ALTER DATABASE :"dbname" SET timezone TO 'UTC';
EOSQL
echo "✅ Database initialization complete!"
echo "📊 Database: $POSTGRES_DB"
echo "👤 User: $POSTGRES_USER"
echo "🌐 Ready to accept connections on port 5432"

View File

@@ -0,0 +1,49 @@
# Social milestone worker
The app detects QR-code scan milestones and stores customer consent. It does not
hold X or LinkedIn credentials. An external X worker can use the internal queue
after the test rollout is approved.
## Test setup (manual SQL only)
1. Apply [`sql/2026-08-13_social_milestones.sql`](../../sql/2026-08-13_social_milestones.sql)
to `qrmaster_test`.
2. Set distinct `CRON_SECRET` and `INTERNAL_API_SECRET` values in `.env.test`.
For an end-to-end test without 1,000 scans, also set
`SOCIAL_MILESTONE_THRESHOLDS=1` (or `1,2`). Do not set this on production.
Publishing is immediate after consent by default. Set
`SOCIAL_MILESTONE_POST_DELAY_HOURS=24` only if a revocation window is desired.
3. Deploy using the documented test compose command. `CRON_SECRET` is forwarded
to the web service by `docker-compose.yml`.
4. Trigger detection manually:
```bash
curl -H "Authorization: Bearer $CRON_SECRET" \
https://testmodul.qrmaster.net/api/cron/social-milestones
```
The detector creates records at 1,000 and 10,000 unique scans only. It is safe
to call repeatedly because `(qrId, kind)` is unique.
## X worker contract
After an explicit rollout approval, the existing QRMaster X worker may poll:
```bash
curl -H "Authorization: Bearer $INTERNAL_API_SECRET" \
https://qrmaster.net/api/internal/social-milestones
```
It receives at most one approved item per 24 hours. The worker must post `milestone.text`
without altering it, then report its result:
```bash
curl -X PATCH -H "Authorization: Bearer $INTERNAL_API_SECRET" \
-H "Content-Type: application/json" \
-d '{"id":"<milestone-id>","result":"posted"}' \
https://qrmaster.net/api/internal/social-milestones
```
Do not configure this worker against `testmodul`. LinkedIn has no approved
brand-posting integration in this project. Customers can self-share: X opens a
prefilled intent; the same text is copied for a LinkedIn post.

View File

@@ -89,6 +89,12 @@ model User {
accounts Account[]
sessions Session[]
lifecycleLogs UserLifecycleLog[]
socialMilestones SocialMilestone[]
// Social-success sharing preferences. A post is still never published
// without a per-milestone approval stored below.
xHandle String?
socialPromptOptOut Boolean @default(false)
}
enum Plan {
@@ -148,11 +154,45 @@ model QRCode {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
scans QRScan[]
socialMilestones SocialMilestone[]
@@index([userId, createdAt])
@@index([userId, type, status])
}
model SocialMilestone {
id String @id @default(cuid())
qrId String
userId String
kind String
status String @default("detected")
detectedAt DateTime @default(now())
shownAt DateTime?
respondedAt DateTime?
claimedAt DateTime?
postedAt DateTime?
withName Boolean @default(false)
consentText String?
language String @default("en")
cardData Json?
brandStatus String @default("pending")
brandApprovedAt DateTime?
brandPostedAt DateTime?
brandPostUrl String?
brandPostError String?
selfSharedAt DateTime?
shareToken String? @unique
publicShareApprovedAt DateTime?
qr QRCode @relation(fields: [qrId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([qrId, kind])
@@index([status, respondedAt])
@@index([status, claimedAt])
@@index([userId, status])
}
enum QRType {
STATIC
DYNAMIC

View File

@@ -0,0 +1,6 @@
FROM python:3.12-slim
WORKDIR /worker
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY worker.py .
CMD ["python", "worker.py"]

View File

@@ -0,0 +1,3 @@
Pillow>=10
requests>=2.31
requests-oauthlib>=2.0

View File

@@ -0,0 +1,151 @@
"""Always-on QR Master X milestone worker. The web app never receives X keys."""
import io
import json
import os
import tempfile
import time
from pathlib import Path
import requests
from PIL import Image, ImageDraw, ImageFont
from requests_oauthlib import OAuth1Session
def required(name):
value = os.getenv(name, "").strip()
if not value:
raise RuntimeError(f"Missing {name}")
return value
def api(method, url, payload=None):
response = requests.request(method, url, json=payload, headers={"Authorization": f"Bearer {required('INTERNAL_API_SECRET')}"}, timeout=30)
response.raise_for_status()
return response.json()
def font(name, size):
for candidate in (f"/usr/share/fonts/truetype/dejavu/{name}", f"C:/Windows/Fonts/{'arialbd.ttf' if 'Bold' in name else 'arial.ttf'}"):
if Path(candidate).exists():
return ImageFont.truetype(candidate, size)
return ImageFont.load_default()
def logo():
"""Use the actual deployed QR Master favicon, not an invented icon."""
try:
base = required("QRMASTER_API_BASE").rstrip("/")
response = requests.get(f"{base}/favicon.ico", timeout=10)
response.raise_for_status()
mark = Image.open(io.BytesIO(response.content)).convert("RGBA")
mark.thumbnail((56, 56))
return mark
except Exception:
return None
def render_card(card):
"""Render an immutable cumulative scan timeline from the stored snapshot."""
image = Image.new("RGB", (1200, 630), "#f8f7f4")
draw = ImageDraw.Draw(image)
navy, blue, slate, mint = "#061b31", "#0256ff", "#64748b", "#059669"
regular, medium, display = font("DejaVuSans.ttf", 27), font("DejaVuSans-Bold.ttf", 27), font("DejaVuSans.ttf", 142)
mark = logo()
if mark:
image.paste(mark, (68, 58), mark)
logo_x = 140
else:
logo_x = 68
draw.text((logo_x, 70), "QR MASTER", font=medium, fill=navy)
total = int(card.get("totalUniqueScans") or card.get("threshold") or 0)
total_scans = int(card.get("totalScans") or total)
draw.text((66, 212), f"{total:,}", font=display, fill=navy)
draw.text((73, 374), "UNIQUE SCANS", font=medium, fill=navy)
draw.text((74, 410), f"{total_scans:,} total scans", font=font("DejaVuSans.ttf", 20), fill=slate)
draw.line((68, 548, 108, 548), fill=blue, width=4)
draw.text((126, 530), "QR code milestone", font=regular, fill=slate)
trend = card.get("trend") or {}
raw_points = trend.get("points") or []
left, top, width, height = 590, 140, 540, 330
if raw_points:
start_ms = min(_timestamp(point.get("at")) for point in raw_points)
end_ms = max(_timestamp(point.get("at")) for point in raw_points)
span = max(1, end_ms - start_ms)
ceiling = float(trend.get("ceiling") or max(total * 1.25, 1.25))
points = [(left + round((_timestamp(point.get("at")) - start_ms) / span * width), top + height - round(float(point.get("total", 0)) / ceiling * height)) for point in raw_points]
target_y = top + height - round(float(trend.get("target") or total) / ceiling * height)
# Exactly five levels: for 20 scans, 5 / 10 / 15 / 20 / 25.
for index in range(1, 6):
value = total * index / 4
y = top + height - round(value / ceiling * height)
is_target = index == 4
draw.line((left, y, left + width, y), fill="#bfdbfe" if is_target else "#dde5ef", width=2 if is_target else 1)
draw.text((left - 15, y - 12), _format_axis(value), font=font("DejaVuSans.ttf", 18), fill=blue if is_target else slate, anchor="ra")
draw.line(points, fill=blue, width=5, joint="curve")
x, y = points[-1]
draw.ellipse((x - 8, y - 8, x + 8, y + 8), fill="#ffffff", outline=blue, width=5)
draw.text((left, top + height + 27), trend.get("startLabel") or "Created", font=font("DejaVuSans.ttf", 19), fill=slate)
draw.text((left + width, top + height + 27), trend.get("endLabel") or "Reached", font=font("DejaVuSans.ttf", 19), fill=slate, anchor="ra")
draw.text((870, 530), "VERIFIED SCAN DATA", font=font("DejaVuSans-Bold.ttf", 18), fill=mint)
path = Path(tempfile.mkstemp(suffix=".png")[1])
image.save(path, "PNG", optimize=True)
return path
def _timestamp(value):
try:
return int(__import__("datetime").datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() * 1000)
except Exception:
return 0
def _format_axis(value):
return f"{value:g}" if value < 1000 else f"{value:,.0f}"
def post_x(text, card):
oauth = OAuth1Session(required("X_API_KEY"), client_secret=required("X_API_SECRET"), resource_owner_key=required("X_ACCESS_TOKEN"), resource_owner_secret=required("X_ACCESS_TOKEN_SECRET"))
path = render_card(card) if card else None
try:
media_id = None
if path:
with path.open("rb") as image:
upload = oauth.post("https://upload.x.com/1.1/media/upload.json", files={"media": image}, timeout=60)
upload.raise_for_status()
media_id = upload.json()["media_id_string"]
payload = {"text": text}
if media_id:
payload["media"] = {"media_ids": [media_id]}
result = oauth.post("https://api.x.com/2/tweets", json=payload, timeout=30)
result.raise_for_status()
return result.json()
finally:
if path:
path.unlink(missing_ok=True)
def run_once():
base = required("QRMASTER_API_BASE").rstrip("/") + "/api/internal/social-milestones"
milestone = api("GET", base).get("milestone")
if not milestone:
return
try:
result = post_x(milestone["text"], milestone.get("card"))
tweet_id = result.get("data", {}).get("id")
post_url = f"https://x.com/i/web/status/{tweet_id}" if tweet_id else None
api("PATCH", base, {"id": milestone["id"], "result": "posted", "postUrl": post_url})
print(json.dumps({"posted": milestone["id"], "x": result}), flush=True)
except Exception as error:
api("PATCH", base, {"id": milestone["id"], "result": "failed", "error": str(error)[:500]})
print(f"Milestone post failed: {error}", flush=True)
if __name__ == "__main__":
interval = max(5, int(os.getenv("SOCIAL_WORKER_INTERVAL_SECONDS", "10")))
if os.getenv("SOCIAL_MILESTONE_POSTING_ENABLED", "").lower() not in {"true", "1", "yes"}:
raise RuntimeError("Set SOCIAL_MILESTONE_POSTING_ENABLED=true to run this worker")
while True:
run_once()
time.sleep(interval)

View File

@@ -0,0 +1,43 @@
-- Success-sharing milestones. Run once against the target database before
-- deploying the application version that uses this feature.
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "xHandle" TEXT;
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "socialPromptOptOut" BOOLEAN NOT NULL DEFAULT false;
CREATE TABLE IF NOT EXISTS "SocialMilestone" (
"id" TEXT PRIMARY KEY,
"qrId" TEXT NOT NULL REFERENCES "QRCode"("id") ON DELETE CASCADE,
"userId" TEXT NOT NULL REFERENCES "User"("id") ON DELETE CASCADE,
"kind" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'detected',
"detectedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"shownAt" TIMESTAMP(3),
"respondedAt" TIMESTAMP(3),
"postedAt" TIMESTAMP(3),
"withName" BOOLEAN NOT NULL DEFAULT false,
"consentText" TEXT,
CONSTRAINT "SocialMilestone_qr_kind_key" UNIQUE ("qrId", "kind")
);
CREATE INDEX IF NOT EXISTS "SocialMilestone_status_respondedAt_idx"
ON "SocialMilestone" ("status", "respondedAt");
CREATE INDEX IF NOT EXISTS "SocialMilestone_userId_status_idx"
ON "SocialMilestone" ("userId", "status");
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "claimedAt" TIMESTAMP(3);
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "language" TEXT NOT NULL DEFAULT 'en';
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "cardData" JSONB;
CREATE INDEX IF NOT EXISTS "SocialMilestone_status_claimedAt_idx"
ON "SocialMilestone" ("status", "claimedAt");
-- Version 2: independent brand and self-share state plus a consent-gated
-- unguessable URL for Open Graph previews. Execute manually once.
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandStatus" TEXT NOT NULL DEFAULT 'pending';
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandApprovedAt" TIMESTAMP(3);
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandPostedAt" TIMESTAMP(3);
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandPostUrl" TEXT;
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandPostError" TEXT;
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "selfSharedAt" TIMESTAMP(3);
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "shareToken" TEXT;
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "publicShareApprovedAt" TIMESTAMP(3);
CREATE UNIQUE INDEX IF NOT EXISTS "SocialMilestone_shareToken_key"
ON "SocialMilestone" ("shareToken") WHERE "shareToken" IS NOT NULL;

View File

@@ -16,6 +16,7 @@ import { QrCode } from 'lucide-react';
import { trackEvent, identifyUser } from '@/components/PostHogProvider';
import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans';
import { OnboardingChecklist } from '@/components/dashboard/OnboardingChecklist';
import { SocialMilestoneDialog } from '@/components/dashboard/SocialMilestoneDialog';
interface QRCodeData {
id: string;
@@ -322,6 +323,7 @@ export default function DashboardPage() {
return (
<div className="space-y-6">
<SocialMilestoneDialog />
{/* Header with Plan Badge */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">

View File

@@ -0,0 +1,15 @@
import { ImageResponse } from 'next/og';
import { db } from '@/lib/db';
export const runtime = 'nodejs';
export async function GET(_request: Request, { params }: { params: { token: string } }) {
const share = await db.socialMilestone.findFirst({
where: { shareToken: params.token, publicShareApprovedAt: { not: null } },
select: { cardData: true, language: true },
});
if (!share) return new Response('Not found', { status: 404, headers: { 'Cache-Control': 'no-store' } });
const card = share.cardData as { qrTitle?: string; totalUniqueScans?: number; trend?: { periodDays: number; recentTotal: number } | null } | null;
const german = share.language === 'de';
return new ImageResponse(<div style={{ height: '100%', width: '100%', display: 'flex', background: '#f8fafc', padding: 46, color: '#061b31' }}><div style={{ display: 'flex', flexDirection: 'column', width: '100%', border: '2px solid #e2e8f0', borderRadius: 24, background: 'white', padding: 44 }}><div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 23, fontWeight: 700 }}><span>QR MASTER</span><span style={{ color: '#059669', background: '#ecfdf5', padding: '8px 14px', borderRadius: 8 }}>Verified milestone</span></div><div style={{ display: 'flex', flexDirection: 'column', marginTop: 58 }}><span style={{ fontSize: 20, color: '#94a3b8', fontWeight: 700 }}>TOTAL UNIQUE SCANS</span><span style={{ fontSize: 116, fontWeight: 700, letterSpacing: -5 }}>{(card?.totalUniqueScans || 0).toLocaleString(german ? 'de-DE' : 'en-US')}</span><span style={{ fontSize: 29, color: '#64748b' }}>{german ? 'eindeutige Scans' : 'unique scans'}</span></div><div style={{ display: 'flex', marginTop: 'auto', paddingTop: 28, borderTop: '2px solid #e2e8f0', justifyContent: 'space-between', fontSize: 25 }}><span>{card?.qrTitle || 'QR code'}</span><span style={{ color: '#0256ff' }}>{card?.trend ? `${card.trend.recentTotal} in ${card.trend.periodDays} days` : (german ? 'Erste Dynamik' : 'Early momentum')}</span></div></div></div>, { width: 1200, height: 630, headers: { 'Cache-Control': 'no-store' } });
}

View File

@@ -0,0 +1,38 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { db } from '@/lib/db';
import { getWwwOrigin } from '@/lib/hosts';
type Props = { params: { token: string } };
async function getShare(token: string) {
return db.socialMilestone.findFirst({
where: { shareToken: token, publicShareApprovedAt: { not: null } },
select: { cardData: true, language: true },
});
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const share = await getShare(params.token);
if (!share) return { robots: { index: false, follow: false } };
const card = share.cardData as { qrTitle?: string; totalUniqueScans?: number } | null;
const count = card?.totalUniqueScans || 0;
const title = share.language === 'de'
? `${count.toLocaleString('de-DE')} eindeutige QR-Scans erreicht`
: `${count.toLocaleString('en-US')} unique QR scans reached`;
const url = `${getWwwOrigin()}/s/m/${params.token}`;
return {
title,
description: card?.qrTitle || 'A verified QR Master scan milestone.',
robots: { index: false, follow: false },
openGraph: { type: 'website', title, description: card?.qrTitle, url, images: [`${url}/og`] },
twitter: { card: 'summary_large_image', title, description: card?.qrTitle, images: [`${url}/og`] },
};
}
export default async function SocialMilestoneSharePage({ params }: Props) {
const share = await getShare(params.token);
if (!share) notFound();
const card = share.cardData as { qrTitle?: string; totalUniqueScans?: number } | null;
return <main className="min-h-screen bg-slate-50 px-6 py-20 text-center text-[#061b31]"><div className="mx-auto max-w-xl rounded-xl border border-slate-200 bg-white p-10 shadow-[0_30px_45px_-30px_rgba(50,50,93,0.35)]"><p className="text-xs font-semibold tracking-[0.14em] text-[#0256ff]">QR MASTER · VERIFIED MILESTONE</p><h1 className="mt-5 text-5xl font-semibold tracking-[-0.05em] tabular-nums">{(card?.totalUniqueScans || 0).toLocaleString(share.language === 'de' ? 'de-DE' : 'en-US')}</h1><p className="mt-2 text-slate-500">{share.language === 'de' ? 'eindeutige Scans' : 'unique scans'}</p><p className="mt-8 text-lg font-medium">{card?.qrTitle}</p></div></main>;
}

View File

@@ -0,0 +1,17 @@
import { NextRequest, NextResponse } from 'next/server';
import { detectSocialMilestones } from '@/lib/social-milestones-server';
import { getSocialMilestoneThresholds } from '@/lib/social-milestones';
export const dynamic = 'force-dynamic';
function isAuthorized(request: NextRequest) {
const secret = process.env.CRON_SECRET;
return Boolean(secret) && request.headers.get('authorization') === `Bearer ${secret}`;
}
// Detection only: this route never contacts customers or an external network.
export async function GET(request: NextRequest) {
if (!isAuthorized(request)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const detected = await detectSocialMilestones();
return NextResponse.json({ ok: true, detected, thresholds: getSocialMilestoneThresholds() });
}

View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
export const dynamic = 'force-dynamic';
function isAuthorized(request: NextRequest) {
const secret = process.env.INTERNAL_API_SECRET;
return Boolean(secret) && request.headers.get('authorization') === `Bearer ${secret}`;
}
function approvalDelayHours() {
const configured = Number(process.env.SOCIAL_MILESTONE_POST_DELAY_HOURS);
return Number.isFinite(configured) && configured >= 0 && configured <= 168 ? configured : 0;
}
// This endpoint is intentionally a queue, not a social-media client. The
// external X worker fetches an approved payload and marks it complete only
// after its own post succeeded. The app never receives X credentials.
export async function GET(request: NextRequest) {
if (!isAuthorized(request)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const dryRun = request.nextUrl.searchParams.get('dryRun') === 'true';
const now = Date.now();
const approvalNotBefore = new Date(now - approvalDelayHours() * 60 * 60 * 1000);
const milestone = await db.socialMilestone.findFirst({
where: { brandStatus: 'approved', brandApprovedAt: { lte: approvalNotBefore } },
orderBy: { brandApprovedAt: 'asc' },
include: { user: { select: { id: true } }, qr: { select: { id: true, status: true } } },
});
// Relations are required by the schema. This guard makes the intended
// revalidation explicit if retention policies are changed later.
if (!milestone || !milestone.user || !milestone.qr || milestone.qr.status !== 'ACTIVE') {
return NextResponse.json({ milestone: null });
}
if (dryRun) return NextResponse.json({ milestone: { id: milestone.id, text: milestone.consentText }, dryRun: true });
const claimed = await db.$transaction(async (tx) => {
await tx.$queryRawUnsafe('SELECT pg_advisory_xact_lock(920241)');
const result = await tx.socialMilestone.updateMany({
where: { id: milestone.id, brandStatus: 'approved' }, data: { brandStatus: 'processing', claimedAt: new Date() },
});
return result.count;
});
if (!claimed) return NextResponse.json({ milestone: null, reason: 'claimed' });
return NextResponse.json({ milestone: { id: milestone.id, text: milestone.consentText, card: milestone.cardData } });
}
export async function PATCH(request: NextRequest) {
if (!isAuthorized(request)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await request.json().catch(() => null) as { id?: string; result?: 'posted' | 'failed'; postUrl?: string; error?: string } | null;
if (!body?.id || !['posted', 'failed'].includes(body.result || '')) return NextResponse.json({ error: 'Invalid payload' }, { status: 400 });
const updated = await db.socialMilestone.updateMany({
where: { id: body.id, brandStatus: 'processing' },
data: {
brandStatus: body.result!,
brandPostedAt: body.result === 'posted' ? new Date() : null,
brandPostUrl: body.result === 'posted' ? body.postUrl || null : null,
brandPostError: body.result === 'failed' ? (body.error || 'The post could not be published.') : null,
},
});
if (!updated.count) return NextResponse.json({ error: 'Milestone is no longer available' }, { status: 409 });
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,113 @@
import { randomBytes } from 'crypto';
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { csrfProtection } from '@/lib/csrf';
import { getSessionUserId } from '@/lib/session';
import { buildMilestoneCardSnapshot, buildMilestonePostForQr, milestoneThreshold, normalizeXHandle, socialLocale } from '@/lib/social-milestones';
type Action = 'approve_brand' | 'self_share' | 'decline' | 'opt_out' | 'revoke';
async function ownedMilestone(id: string, userId: string) {
return db.socialMilestone.findFirst({
where: { id, userId },
include: { user: { select: { primaryUseCase: true } }, qr: { select: { title: true } } },
});
}
function clientState(milestone: { brandStatus: string; brandPostUrl: string | null; brandPostError: string | null; selfSharedAt: Date | null }) {
return {
brandStatus: milestone.brandStatus,
brandPostUrl: milestone.brandPostUrl,
brandPostError: milestone.brandPostError,
selfSharedAt: milestone.selfSharedAt?.toISOString() || null,
};
}
export async function GET(_request: NextRequest, { params }: { params: { id: string } }) {
const userId = getSessionUserId();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const milestone = await ownedMilestone(params.id, userId);
if (!milestone) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json({ milestone: clientState(milestone) });
}
export async function PATCH(request: NextRequest, { params }: { params: { id: string } }) {
const csrf = csrfProtection(request);
if (!csrf.valid) return NextResponse.json({ error: csrf.error }, { status: 403 });
const userId = getSessionUserId();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await request.json().catch(() => null) as { action?: Action; withName?: boolean; xHandle?: string; language?: string } | null;
if (!body || !['approve_brand', 'self_share', 'decline', 'opt_out', 'revoke'].includes(body.action || '')) {
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
}
const milestone = await ownedMilestone(params.id, userId);
if (!milestone) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const threshold = milestoneThreshold(milestone.kind);
if (!threshold) return NextResponse.json({ error: 'Invalid milestone' }, { status: 400 });
const isDismissible = ['detected', 'shown'].includes(milestone.status);
if (body.action === 'revoke') {
if (!['approved', 'failed'].includes(milestone.brandStatus)) return NextResponse.json({ error: 'Only queued approvals can be revoked' }, { status: 409 });
const updated = await db.socialMilestone.update({ where: { id: milestone.id }, data: { brandStatus: 'revoked', brandPostError: null } });
return NextResponse.json({ ok: true, milestone: clientState(updated) });
}
if (body.action === 'decline' || body.action === 'opt_out') {
if (!isDismissible) return NextResponse.json({ error: 'This milestone has already been dismissed' }, { status: 409 });
const now = new Date();
await db.$transaction([
db.socialMilestone.update({ where: { id: milestone.id }, data: { status: 'declined', respondedAt: now } }),
...(body.action === 'opt_out' ? [db.user.update({ where: { id: userId }, data: { socialPromptOptOut: true } })] : []),
]);
return NextResponse.json({ ok: true });
}
const language = socialLocale(body.language);
const withName = body.action === 'approve_brand' && body.withName === true;
const xHandle = withName ? normalizeXHandle(body.xHandle || '') : null;
if (withName && !xHandle) return NextResponse.json({ error: 'Enter a valid X handle' }, { status: 400 });
const card = milestone.cardData || buildMilestoneCardSnapshot({
primaryUseCase: milestone.user.primaryUseCase,
qrTitle: milestone.qr.title,
totalScans: threshold,
totalUniqueScans: threshold,
milestoneThreshold: threshold,
reachedAt: milestone.detectedAt,
trend: null,
locale: language,
});
const now = new Date();
if (body.action === 'self_share') {
// 72 random bits keep public URLs unguessable while making the share URL
// much less disruptive in an X compose window than a full UUID.
const token = milestone.shareToken || randomBytes(9).toString('base64url');
const updated = await db.socialMilestone.update({
where: { id: milestone.id },
data: { selfSharedAt: now, publicShareApprovedAt: now, shareToken: token, cardData: card, language },
});
return NextResponse.json({ ok: true, shareToken: token, milestone: clientState(updated) });
}
if (!['pending', 'failed', 'revoked'].includes(milestone.brandStatus)) {
return NextResponse.json({ error: 'This brand post is already being processed' }, { status: 409 });
}
const consentText = buildMilestonePostForQr(
milestone.user.primaryUseCase,
(card as { totalUniqueScans?: number }).totalUniqueScans || threshold,
xHandle,
language,
milestone.qr.title,
);
const updated = await db.$transaction(async tx => {
if (withName) await tx.user.update({ where: { id: userId }, data: { xHandle } });
return tx.socialMilestone.update({
where: { id: milestone.id },
data: {
brandStatus: 'approved', brandApprovedAt: now, brandPostError: null,
withName, consentText, language, cardData: card, respondedAt: now,
},
});
});
return NextResponse.json({ ok: true, consentText, milestone: clientState(updated) });
}

View File

@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSessionUserId } from '@/lib/session';
import { buildMilestoneCardSnapshot, buildMilestonePostForQr, milestoneThreshold, socialLocale } from '@/lib/social-milestones';
export const dynamic = 'force-dynamic';
// Returns at most one item. A missing response is treated as no consent, never as approval.
export async function GET(request: NextRequest) {
const userId = getSessionUserId();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const user = await db.user.findUnique({
where: { id: userId }, select: { socialPromptOptOut: true, xHandle: true, primaryUseCase: true },
});
if (!user || user.socialPromptOptOut) return NextResponse.json({ milestone: null });
const milestone = await db.socialMilestone.findFirst({
where: { userId, status: { in: ['detected', 'shown'] } },
orderBy: { detectedAt: 'asc' },
include: { qr: { select: { id: true, title: true } } },
});
if (!milestone) return NextResponse.json({ milestone: null });
if (milestone.status === 'detected') {
await db.socialMilestone.update({ where: { id: milestone.id }, data: { status: 'shown', shownAt: new Date() } });
}
const threshold = milestoneThreshold(milestone.kind);
if (!threshold) return NextResponse.json({ milestone: null });
const locale = socialLocale(request.nextUrl.searchParams.get('locale'));
const allScanCount = await db.qRScan.count({ where: { qrId: milestone.qr.id } });
const storedCard = milestone.cardData as Record<string, unknown> | null;
const card = storedCard
? { ...storedCard, totalScans: typeof storedCard.totalScans === 'number' ? storedCard.totalScans : allScanCount }
: buildMilestoneCardSnapshot({
primaryUseCase: user.primaryUseCase,
qrTitle: milestone.qr.title,
totalScans: allScanCount,
totalUniqueScans: threshold,
milestoneThreshold: threshold,
reachedAt: milestone.detectedAt,
trend: null,
locale,
});
return NextResponse.json({
milestone: {
id: milestone.id, qrTitle: milestone.qr.title, threshold,
defaultXHandle: user.xHandle,
brandStatus: milestone.brandStatus,
brandPostUrl: milestone.brandPostUrl,
brandPostError: milestone.brandPostError,
language: locale,
preview: buildMilestonePostForQr(
user.primaryUseCase,
((milestone.cardData as { totalUniqueScans?: number } | null)?.totalUniqueScans || threshold),
null,
locale,
milestone.qr.title,
),
card,
},
});
}

View File

@@ -5,6 +5,7 @@ import { getWwwOrigin } from '@/lib/hosts';
import { db } from '@/lib/db';
import { hashIP } from '@/lib/hash';
import { triggerLifecycleScoring } from '@/lib/revops-server';
import { detectSocialMilestones } from '@/lib/social-milestones-server';
export async function GET(
request: NextRequest,
@@ -260,6 +261,12 @@ async function trackScan(qrId: string, userId: string, request: NextRequest) {
},
});
// The customer sees a newly crossed milestone on their next dashboard
// visit; no separate cron invocation is required after a real scan.
if (isUnique) {
await detectSocialMilestones(qrId);
}
const activatedUsers = await db.user.updateMany({
where: {
id: userId,

View File

@@ -0,0 +1,128 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Check, ExternalLink, LineChart, Linkedin, Send, Sparkles, X } from 'lucide-react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
import { Button } from '@/components/ui/Button';
import { useCsrf } from '@/hooks/useCsrf';
import { useTranslation } from '@/hooks/useTranslation';
import { showToast } from '@/components/ui/Toast';
type Card = { language: 'en' | 'de'; qrTitle: string; label: string; title: string; totalScans?: number; totalUniqueScans: number; milestoneThreshold: number; trend: { points: Array<{ at: string; total: number }>; startLabel: string; endLabel: string; target: number; ceiling: number } | null };
type Milestone = { id: string; qrTitle: string; threshold: number; defaultXHandle: string | null; preview: string; language: 'en' | 'de'; card: Card; brandStatus: string; brandPostUrl: string | null; brandPostError: string | null };
type BrandState = { brandStatus: string; brandPostUrl: string | null; brandPostError: string | null; selfSharedAt: string | null };
function Trend({ trend }: { trend: NonNullable<Card['trend']> }) {
const first = new Date(trend.points[0].at).getTime();
const last = Math.max(new Date(trend.points[trend.points.length - 1].at).getTime(), first + 1);
const points = trend.points.map(point => {
const x = 5 + ((new Date(point.at).getTime() - first) / (last - first)) * 90;
const y = 88 - (point.total / trend.ceiling) * 72;
return `${x},${y}`;
}).join(' ');
const targetY = 88 - (trend.target / trend.ceiling) * 72;
const ticks = Array.from({ length: 5 }, (_, index) => trend.target * (index + 1) / 4);
return <div className="grid grid-cols-[2.5rem_1fr] gap-2"><div className="flex h-24 flex-col justify-between py-1 text-right text-[10px] font-medium tabular-nums text-[#45617f]">{ticks.slice().reverse().map(tick => <span key={tick} className={tick === trend.target ? 'text-[#0256ff]' : undefined}>{tick.toLocaleString()}</span>)}</div><div><svg viewBox="0 0 100 100" preserveAspectRatio="none" className="h-24 w-full overflow-visible" aria-label="Cumulative unique scan trend">{ticks.map(tick => { const y = 88 - (tick / trend.ceiling) * 72; return <line key={tick} x1="5" x2="95" y1={y} y2={y} stroke={tick === trend.target ? '#bfdbfe' : '#e2e8f0'} strokeWidth={tick === trend.target ? '1.4' : '0.8'} vectorEffect="non-scaling-stroke" />; })}<polyline points={points} fill="none" stroke="currentColor" strokeWidth="3" vectorEffect="non-scaling-stroke" /><circle cx="95" cy={targetY} r="2.7" fill="#0256ff" /></svg><div className="mt-1 flex justify-between text-[11px] font-medium tracking-wide text-[#45617f]"><span>{trend.startLabel}</span><span>{trend.endLabel}</span></div></div></div>;
}
export function SocialMilestoneDialog() {
const { fetchWithCsrf } = useCsrf();
const { locale } = useTranslation();
const [milestone, setMilestone] = useState<Milestone | null>(null);
const [withName, setWithName] = useState(false);
const [xHandle, setXHandle] = useState('');
const [saving, setSaving] = useState<'brand' | 'self' | null>(null);
const [brand, setBrand] = useState<BrandState | null>(null);
useEffect(() => {
fetch(`/api/social-milestones?locale=${locale}`).then(async response => {
if (response.ok) {
const next = (await response.json()).milestone as Milestone | null;
setMilestone(next);
if (next) setBrand({ brandStatus: next.brandStatus, brandPostUrl: next.brandPostUrl, brandPostError: next.brandPostError, selfSharedAt: null });
}
}).catch(() => undefined);
}, [locale]);
useEffect(() => setXHandle(milestone?.defaultXHandle || ''), [milestone]);
useEffect(() => {
if (!milestone || !['approved', 'processing'].includes(brand?.brandStatus || '')) return;
const poll = async () => {
const response = await fetch(`/api/social-milestones/${milestone.id}`);
if (response.ok) setBrand((await response.json()).milestone);
};
const timer = window.setInterval(poll, 3000);
void poll();
return () => window.clearInterval(timer);
}, [milestone, brand?.brandStatus]);
const copy = milestone?.language === 'de'
? { heading: 'Ein echter Erfolg', subtitle: 'hat einen Scan-Meilenstein erreicht.', consent: 'Darf QR Master diesen Erfolg auf dem eigenen X-Account veröffentlichen?', name: 'Meinen X-Handle nennen', decline: 'Nicht jetzt', self: 'Selbst teilen', approve: 'Auf QR Master posten', queued: 'Wird auf X veröffentlicht …', posted: 'Auf X veröffentlicht', failed: 'Veröffentlichung fehlgeschlagen' }
: { heading: 'A real milestone', subtitle: 'just reached a scan milestone.', consent: 'May QR Master publish this success from our X account?', name: 'Mention my X handle', decline: 'Not now', self: 'Share myself', approve: 'Post from QR Master', queued: 'Publishing to X …', posted: 'Published on X', failed: 'Publishing failed' };
const preview = useMemo(() => {
if (!milestone || !withName || !xHandle.trim()) return milestone?.preview || '';
return `${milestone.preview} By @${xHandle.trim().replace(/^@/, '')}.`;
}, [milestone, withName, xHandle]);
const update = async (action: 'approve_brand' | 'self_share' | 'decline' | 'opt_out') => {
if (!milestone) return null;
const response = await fetchWithCsrf(`/api/social-milestones/${milestone.id}`, { method: 'PATCH', body: JSON.stringify({ action, withName, xHandle, language: milestone.language }) });
const result = await response.json();
if (!response.ok) throw new Error(result.error || 'Could not save your choice');
return result;
};
const shareSelf = async (network: 'x' | 'linkedin') => {
if (!milestone) return;
// Open synchronously from the user gesture. Awaiting the API first can make
// LinkedIn treat the new window as a blocked popup.
const shareWindow = window.open('', '_blank', 'noopener,noreferrer');
setSaving('self');
try {
const result = await update('self_share');
const shareUrl = `${window.location.origin}/s/m/${result.shareToken}`;
const text = `${preview} ${shareUrl}`;
if (network === 'x') shareWindow?.location.replace(`https://x.com/intent/post?text=${encodeURIComponent(text)}`);
else {
await navigator.clipboard?.writeText(text);
shareWindow?.location.replace(`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`);
}
setBrand(result.milestone);
showToast(network === 'linkedin' ? 'Share text copied and LinkedIn opened.' : 'X share composer opened.', 'success');
} catch (error) { shareWindow?.close(); showToast(error instanceof Error ? error.message : 'Could not prepare this share', 'error'); }
finally { setSaving(null); }
};
const approveBrand = async () => {
setSaving('brand');
try {
const result = await update('approve_brand');
setBrand(result.milestone);
showToast(copy.queued, 'success');
} catch (error) { showToast(error instanceof Error ? error.message : 'Could not save your choice', 'error'); }
finally { setSaving(null); }
};
const dismiss = async (action: 'decline' | 'opt_out') => {
try { await update(action); setMilestone(null); } catch (error) { showToast(error instanceof Error ? error.message : 'Could not save your choice', 'error'); }
};
if (!milestone) return null;
const card = milestone.card;
const count = card.totalUniqueScans || milestone.threshold;
const status = brand?.brandStatus || milestone.brandStatus || 'pending';
return <Dialog open onOpenChange={open => !open && setMilestone(null)}>
<DialogContent className="max-w-xl overflow-hidden border-slate-200 p-0 shadow-[0_30px_45px_-30px_rgba(50,50,93,0.45)]">
<div className="px-7 pb-5 pt-7"><DialogHeader><div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-[#eaf1ff] text-[#0256ff]"><Sparkles className="h-5 w-5" /></div><DialogTitle className="text-2xl font-semibold tracking-[-0.03em] text-[#061b31]">{copy.heading}</DialogTitle><DialogDescription className="pt-1 text-sm leading-6 text-[#4b5e76]"><strong className="font-medium text-[#061b31]">{milestone.qrTitle}</strong> {copy.subtitle}</DialogDescription></DialogHeader></div>
<div className="space-y-5 border-y border-slate-100 px-7 py-6">
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-[0_14px_28px_-22px_rgba(50,50,93,0.4)]">
<div className="flex items-center justify-between border-b border-slate-100 pb-4 text-xs"><span className="flex items-center gap-2 font-semibold tracking-wide text-[#061b31]"><img src="/favicon.ico" alt="" className="h-5 w-5" />QR MASTER</span><span className="rounded bg-emerald-50 px-2 py-1 font-medium text-emerald-700"><Check className="mr-1 inline h-3 w-3" />Verified scan milestone</span></div>
<div className="mt-5 text-[11px] font-semibold tracking-[0.12em] text-slate-400">TOTAL UNIQUE SCANS</div><div className="mt-1 text-5xl font-semibold tracking-[-0.05em] tabular-nums text-[#061b31]">{count.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}</div><div className="mt-1 text-sm text-slate-500">{milestone.language === 'de' ? 'eindeutige Scans' : 'unique scans'}</div><div className="mt-3 border-l border-slate-200 pl-3 text-xs text-[#4b5e76]"><span className="font-semibold tabular-nums text-[#061b31]">{(card.totalScans || count).toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}</span> {milestone.language === 'de' ? 'Scans insgesamt' : 'total scans'}</div>
{card.trend ? <div className="mt-5 text-[#0256ff]"><Trend trend={card.trend} /><div className="mt-2 flex items-center gap-2 text-xs text-[#45617f]"><LineChart className="h-3.5 w-3.5 text-[#0256ff]" />{milestone.language === 'de' ? 'Kumulierte eindeutige Scans seit Erstellung' : 'Cumulative unique scans since creation'}</div></div> : <div className="mt-5 inline-flex items-center gap-2 rounded-md bg-blue-50 px-3 py-2 text-xs font-medium text-blue-700"><LineChart className="h-3.5 w-3.5" />{milestone.language === 'de' ? 'Erste Dynamik' : 'Early momentum'}</div>}
<div className="mt-5 border-t border-slate-100 pt-3 text-sm font-medium text-[#061b31]">{card.qrTitle}</div>
</section>
<div><p className="text-sm leading-6 text-[#4b5e76]">{copy.consent}</p><blockquote className="mt-3 border-l-2 border-[#0256ff] pl-3 text-sm leading-6 text-[#273951]">{preview}</blockquote></div>
<label className="flex cursor-pointer items-center gap-3 text-sm font-medium text-slate-700"><input type="checkbox" checked={withName} onChange={event => setWithName(event.target.checked)} disabled={status !== 'pending'} className="h-4 w-4 rounded border-slate-300 text-[#0256ff] focus:ring-[#0256ff]" />{copy.name}</label>
{withName && <input aria-label="X handle" value={xHandle} onChange={event => setXHandle(event.target.value)} disabled={status !== 'pending'} placeholder="@yourhandle" className="w-full rounded-md border border-slate-200 px-3 py-2 text-sm outline-none focus:border-[#0256ff] focus:ring-2 focus:ring-blue-100" />}
<div className="flex flex-wrap items-center gap-2"><span className="mr-1 text-xs font-medium text-slate-500">{copy.self}</span><Button variant="outline" size="sm" onClick={() => shareSelf('x')} disabled={saving !== null}><X className="mr-1.5 h-3.5 w-3.5" />X</Button><Button variant="outline" size="sm" onClick={() => shareSelf('linkedin')} disabled={saving !== null}><Linkedin className="mr-1.5 h-3.5 w-3.5" />LinkedIn</Button></div>
{status !== 'pending' && <div className={`flex items-center justify-between rounded-md px-3 py-2 text-sm ${status === 'posted' ? 'bg-emerald-50 text-emerald-800' : status === 'failed' ? 'bg-rose-50 text-rose-800' : 'bg-blue-50 text-blue-800'}`}><span>{status === 'posted' ? copy.posted : status === 'failed' ? copy.failed : copy.queued}</span>{brand?.brandPostUrl && <a href={brand.brandPostUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 font-medium underline"><ExternalLink className="h-3.5 w-3.5" />View</a>}</div>}
</div>
<DialogFooter className="bg-slate-50 px-7 py-4"><div className="flex w-full flex-wrap items-center justify-end gap-2"><Button variant="outline" onClick={() => dismiss('decline')} disabled={saving !== null}>{copy.decline}</Button><Button variant="primary" onClick={approveBrand} disabled={saving !== null || status !== 'pending'}><Send className="mr-1.5 h-4 w-4" />{copy.approve}</Button><button type="button" className="w-full pt-1 text-xs text-slate-500 underline underline-offset-2 hover:text-slate-700" onClick={() => dismiss('opt_out')} disabled={saving !== null}>Do not show again</button></div></DialogFooter>
</DialogContent>
</Dialog>;
}

View File

@@ -48,6 +48,20 @@ async function waitForRateLimit() {
lastEmailSent = Date.now();
}
function getEmailFrom(name = 'Timo from QR Master'): string {
const address = process.env.SMTP_USER || 'timo@qrmaster.net';
return `${name} <${address}>`;
}
function getEmailFromSecurity(): string {
const address = process.env.SMTP_USER || 'noreply@qrmaster.net';
return `QR Master Security <${address}>`;
}
function getEmailReplyTo(): string {
return process.env.SMTP_USER || 'support@qrmaster.net';
}
/**
* Password Reset Email - Security focused with clear urgency
*/
@@ -58,8 +72,8 @@ export async function sendPasswordResetEmail(email: string, resetToken: string)
try {
await resend.emails.send({
from: 'QR Master Security <noreply@qrmaster.net>',
replyTo: 'support@qrmaster.net',
from: getEmailFromSecurity(),
replyTo: getEmailReplyTo(),
to: email,
subject: '🔐 Reset Your QR Master Password (Expires in 1 Hour)',
html: `
@@ -190,8 +204,8 @@ export async function sendNewsletterWelcomeEmail(email: string) {
try {
await resend.emails.send({
from: 'Timo from QR Master <timo@qrmaster.net>',
replyTo: 'support@qrmaster.net',
from: getEmailFrom(),
replyTo: getEmailReplyTo(),
to: email,
subject: '🎉 You\'re In! Here\'s What Happens Next (AI QR Features)',
html: `
@@ -362,8 +376,8 @@ export async function sendAIFeatureLaunchEmail(email: string) {
try {
await resend.emails.send({
from: 'Timo from QR Master <timo@qrmaster.net>',
replyTo: 'support@qrmaster.net',
from: getEmailFrom(),
replyTo: getEmailReplyTo(),
to: email,
subject: '🚀 They\'re Live! Your AI QR Features Are Ready',
html: `
@@ -568,8 +582,8 @@ export async function sendEmailVerificationEmail(email: string, name: string, ve
const firstName = name.trim().split(/\s+/)[0] || 'there';
await transport.sendMail({
from: 'Timo from QR Master <timo@qrmaster.net>',
replyTo: 'support@qrmaster.net',
from: getEmailFrom(),
replyTo: getEmailReplyTo(),
to: email,
subject: 'Confirm your QR Master email address',
html: `<!doctype html><html><body style="margin:0;background:#f5f4ef;color:#1b1c19;font-family:Arial,sans-serif;"><table role="presentation" width="100%" cellspacing="0" cellpadding="0"><tr><td align="center" style="padding:32px 12px;"><table role="presentation" width="600" cellspacing="0" cellpadding="0" style="width:100%;max-width:600px;background:#fff;"><tr><td style="padding:20px 32px;border-bottom:1px solid #e3e3de;font-size:11px;font-weight:bold;letter-spacing:2px;">QR MASTER</td></tr><tr><td style="padding:36px 32px;"><h1 style="margin:0 0 18px;font-family:Georgia,serif;font-size:30px;font-weight:normal;line-height:1.2;">Confirm your email address</h1><p style="margin:0;font-size:16px;line-height:1.65;">Hi ${escapeHtml(firstName)},</p><p style="font-size:16px;line-height:1.65;">Click the button below to finish creating your QR Master account.</p><a href="${verificationUrl}" style="display:inline-block;margin:10px 0 22px;background:#0047ff;color:#fff;padding:14px 22px;text-decoration:none;font-size:14px;font-weight:bold;">CONFIRM EMAIL</a><p style="margin:0;color:#747878;font-size:13px;line-height:1.6;">This link expires in 24 hours. If you did not create an account, you can ignore this email.</p></td></tr></table></td></tr></table></body></html>`,
@@ -586,8 +600,8 @@ export async function sendDesignerAnnouncementEmail(email: string, unsubscribeUr
const transport = createSmtpTransport();
await transport.sendMail({
from: 'Timo from QR Master <timo@qrmaster.net>',
replyTo: 'support@qrmaster.net',
from: getEmailFrom(),
replyTo: getEmailReplyTo(),
to: email,
subject: 'Your QR codes can now look like your brand',
html: `
@@ -652,8 +666,8 @@ export async function sendNewsletterEmail({
const transport = createSmtpTransport();
await transport.sendMail({
from: 'Timo from QR Master <timo@qrmaster.net>',
replyTo: 'support@qrmaster.net',
from: getEmailFrom(),
replyTo: getEmailReplyTo(),
to: email,
subject,
html: `<!doctype html><html><body style="margin:0;background:#f5f4ef;color:#1b1c19;font-family:Arial,sans-serif;"><table role="presentation" width="100%" cellspacing="0" cellpadding="0"><tr><td align="center" style="padding:32px 12px;"><table role="presentation" width="600" cellspacing="0" cellpadding="0" style="width:100%;max-width:600px;background:#fff;"><tr><td style="padding:20px 32px;border-bottom:1px solid #e3e3de;font-size:11px;font-weight:bold;letter-spacing:2px;">QR MASTER</td></tr><tr><td style="padding:36px 32px;">${body}</td></tr><tr><td style="padding:20px 32px;border-top:1px solid #e3e3de;color:#747878;font-size:11px;line-height:1.6;">You are receiving this email from QR Master.<br><a href="${unsubscribeUrl}" style="color:#747878;">Unsubscribe from product updates</a></td></tr></table></td></tr></table></body></html>`,
@@ -929,8 +943,8 @@ export async function sendWelcomeEmail(email: string, name: string) {
`);
await transport.sendMail({
from: 'Timo from QR Master <timo@qrmaster.net>',
replyTo: 'support@qrmaster.net',
from: getEmailFrom(),
replyTo: getEmailReplyTo(),
to: email,
subject: 'Your QR Master account is ready',
html,
@@ -1066,8 +1080,8 @@ export async function sendActivationNudgeEmail(email: string, name: string) {
`);
await transport.sendMail({
from: 'Timo from QR Master <timo@qrmaster.net>',
replyTo: 'support@qrmaster.net',
from: getEmailFrom(),
replyTo: getEmailReplyTo(),
to: email,
subject: "Your 3 free codes are still sitting there",
html,
@@ -1237,8 +1251,8 @@ export async function sendUpgradeNudgeEmail(email: string, name: string, qrCount
`);
await transport.sendMail({
from: 'Timo from QR Master <timo@qrmaster.net>',
replyTo: 'support@qrmaster.net',
from: getEmailFrom(),
replyTo: getEmailReplyTo(),
to: email,
subject: 'You just hit the free limit',
html,
@@ -1413,8 +1427,8 @@ export async function sendThirtyDayNudgeEmail(
`);
await transport.sendMail({
from: 'Timo from QR Master <timo@qrmaster.net>',
replyTo: 'support@qrmaster.net',
from: getEmailFrom(),
replyTo: getEmailReplyTo(),
to: email,
subject: `${firstName}, your codes were scanned ${scanCount} time${scanCount !== 1 ? 's' : ''} this month`,
html,
@@ -1528,8 +1542,8 @@ export async function sendFirstScanEmail(
`);
await transport.sendMail({
from: 'Timo from QR Master <timo@qrmaster.net>',
replyTo: 'support@qrmaster.net',
from: getEmailFrom(),
replyTo: getEmailReplyTo(),
to: email,
subject: 'Your QR code was just scanned for the first time',
html,

View File

@@ -0,0 +1,84 @@
import { db } from '@/lib/db';
import { buildMilestoneCardSnapshot, getSocialMilestoneThresholds, milestoneKind, SocialLocale } from '@/lib/social-milestones';
function excludedEmails() {
return (process.env.SOCIAL_MILESTONE_EXCLUDED_EMAILS || '')
.split(',').map(email => email.trim().toLowerCase()).filter(Boolean);
}
/** Creates any newly crossed milestones. Safe to call repeatedly. */
export async function detectSocialMilestones(qrId?: string) {
const excluded = excludedEmails();
const candidates = await db.qRScan.groupBy({
by: ['qrId'],
where: {
isUnique: true,
...(qrId ? { qrId } : {}),
qr: { user: excluded.length ? { email: { notIn: excluded, mode: 'insensitive' } } : undefined },
},
_count: { _all: true },
});
const records = candidates.flatMap(({ qrId: candidateQrId, _count }) =>
getSocialMilestoneThresholds()
.filter(threshold => _count._all >= threshold)
.map(threshold => ({ qrId: candidateQrId, kind: milestoneKind(threshold) }))
);
if (!records.length) return 0;
const qrs = await db.qRCode.findMany({
where: { id: { in: Array.from(new Set(records.map(record => record.qrId))) } },
select: { id: true, userId: true, title: true, createdAt: true, user: { select: { primaryUseCase: true } } },
});
const cardByQr = new Map<string, Awaited<ReturnType<typeof createCardSnapshot>>>();
await Promise.all(qrs.map(async qr => cardByQr.set(qr.id, await createCardSnapshot(qr))));
const userIdByQr = new Map(qrs.map(qr => [qr.id, qr.userId]));
const created = await db.socialMilestone.createMany({
data: records.filter(record => userIdByQr.has(record.qrId)).map(record => ({
...record,
userId: userIdByQr.get(record.qrId)!,
cardData: cardByQr.get(record.qrId),
})),
skipDuplicates: true,
});
return created.count;
}
async function createCardSnapshot(qr: { id: string; title: string; createdAt: Date; user: { primaryUseCase: string | null } }) {
const now = new Date();
const scans = await db.qRScan.findMany({
where: { qrId: qr.id },
select: { ts: true, isUnique: true },
orderBy: { ts: 'asc' },
});
const uniqueScans = scans.filter(scan => scan.isUnique);
// Keep a representative, cumulative history in the immutable snapshot.
// It starts at QR creation and ends at the moment the milestone is detected.
const stride = Math.max(1, Math.ceil(uniqueScans.length / 24));
const points = [{ at: qr.createdAt.toISOString(), total: 0 }];
uniqueScans.forEach((scan, index) => {
const total = index + 1;
if (total % stride === 0 || total === uniqueScans.length) points.push({ at: scan.ts.toISOString(), total });
});
const month = new Intl.DateTimeFormat('en', { month: 'short', year: '2-digit', timeZone: 'UTC' });
const trend = {
points,
startLabel: month.format(qr.createdAt),
endLabel: month.format(now),
target: uniqueScans.length,
// Five labelled grid lines: 1/4, 1/2, 3/4, target, then one level above.
// For 20 scans this is precisely 5, 10, 15, 20, 25.
ceiling: uniqueScans.length < 5 ? 5 : Math.max(1.25, uniqueScans.length * 1.25),
};
// The snapshot is made at detection time and never silently changes after consent.
return buildMilestoneCardSnapshot({
primaryUseCase: qr.user.primaryUseCase,
qrTitle: qr.title,
totalScans: scans.length,
totalUniqueScans: uniqueScans.length,
milestoneThreshold: uniqueScans.length,
reachedAt: now,
trend,
locale: 'en' as SocialLocale,
});
}

View File

@@ -0,0 +1,121 @@
export const DEFAULT_SOCIAL_MILESTONE_THRESHOLDS = [1000, 10000] as const;
export type SocialMilestoneKind = `unique_scans_${number}`;
/**
* Staging can set SOCIAL_MILESTONE_THRESHOLDS=1 (or e.g. 1,2) so the complete
* flow is testable without fabricating thousands of scans. Production keeps
* the conservative defaults unless its environment explicitly changes them.
*/
export function getSocialMilestoneThresholds(): number[] {
const configured = process.env.SOCIAL_MILESTONE_THRESHOLDS;
if (!configured) return [...DEFAULT_SOCIAL_MILESTONE_THRESHOLDS];
const thresholds = Array.from(new Set(
configured.split(',')
.map(value => Number(value.trim()))
.filter(value => Number.isInteger(value) && value > 0 && value <= 1_000_000)
)).sort((a, b) => a - b);
return thresholds.length ? thresholds : [...DEFAULT_SOCIAL_MILESTONE_THRESHOLDS];
}
const useCaseLabels: Record<string, string> = {
menu_pdf: 'menu QR code',
marketing_campaign: 'campaign QR code',
vcard: 'digital business-card QR code',
event: 'event QR code',
feedback: 'feedback QR code',
};
export function milestoneKind(threshold: number): SocialMilestoneKind {
return `unique_scans_${threshold}` as SocialMilestoneKind;
}
export function milestoneThreshold(kind: string): number | null {
const result = /^unique_scans_(\d+)$/.exec(kind);
return result ? Number(result[1]) : null;
}
export type SocialLocale = 'en' | 'de';
export type SocialMilestoneCard = {
version: 'milestone-card-v2';
language: SocialLocale;
qrTitle: string;
label: string;
title: string;
totalScans: number;
totalUniqueScans: number;
milestoneThreshold: number;
reachedAt: string;
trend: {
points: Array<{ at: string; total: number }>;
startLabel: string;
endLabel: string;
target: number;
ceiling: number;
} | null;
};
export function socialLocale(value?: string | null): SocialLocale {
return value === 'de' ? 'de' : 'en';
}
export function usageLabel(primaryUseCase: string | null, locale: SocialLocale = 'en'): string {
if (locale === 'de') {
const german: Record<string, string> = { menu_pdf: 'Speisekarten-QR-Code', marketing_campaign: 'Kampagnen-QR-Code', vcard: 'Visitenkarten-QR-Code', event: 'Event-QR-Code', feedback: 'Feedback-QR-Code' };
return (primaryUseCase && german[primaryUseCase]) || 'QR-Code';
}
return (primaryUseCase && useCaseLabels[primaryUseCase]) || 'QR code';
}
export function buildMilestonePost(primaryUseCase: string | null, threshold: number, xHandle?: string | null, locale: SocialLocale = 'en'): string {
const count = threshold.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US');
const base = locale === 'de'
? `Ein ${usageLabel(primaryUseCase, locale)} hat gerade ${count} eindeutige Scans erreicht. 🎉`
: `A ${usageLabel(primaryUseCase, locale)} just reached ${count} unique scans. 🎉`;
return xHandle ? `${base} By @${xHandle.replace(/^@/, '')}.` : base;
}
export function buildMilestoneCard(primaryUseCase: string | null, threshold: number, locale: SocialLocale) {
return { version: 'milestone-card-v1', language: locale, threshold, label: usageLabel(primaryUseCase, locale), title: locale === 'de' ? 'Erfolgsmeilenstein' : 'Success milestone' };
}
export function buildMilestonePostForQr(primaryUseCase: string | null, totalUniqueScans: number, xHandle: string | null | undefined, locale: SocialLocale, qrTitle: string): string {
const count = totalUniqueScans.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US');
const subject = qrTitle.trim() || usageLabel(primaryUseCase, locale);
const base = locale === 'de'
? `${subject}“ hat ${count} eindeutige Scans erreicht.`
: `${subject}” reached ${count} unique scans.`;
return xHandle ? `${base} By @${xHandle.replace(/^@/, '')}.` : base;
}
export function buildMilestoneCardSnapshot(input: {
primaryUseCase: string | null;
qrTitle: string;
totalScans: number;
totalUniqueScans: number;
milestoneThreshold: number;
reachedAt: Date;
trend: SocialMilestoneCard['trend'];
locale: SocialLocale;
}): SocialMilestoneCard {
return {
version: 'milestone-card-v2',
language: input.locale,
qrTitle: input.qrTitle,
label: usageLabel(input.primaryUseCase, input.locale),
title: input.locale === 'de' ? 'Erfolgsmeilenstein' : 'Success milestone',
totalScans: input.totalScans,
totalUniqueScans: input.totalUniqueScans,
milestoneThreshold: input.milestoneThreshold,
reachedAt: input.reachedAt.toISOString(),
trend: input.trend,
};
}
export function normalizeXHandle(value: string): string | null {
const handle = value.trim().replace(/^@/, '');
return /^[A-Za-z0-9_]{1,15}$/.test(handle) ? handle : null;
}