Add consented social milestone posting

This commit is contained in:
2026-08-14 09:03:10 +02:00
parent 14c429ff30
commit f7d82aa5bd
14 changed files with 613 additions and 3 deletions

View File

@@ -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}
@@ -114,6 +117,25 @@ services:
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)
adminer:

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,37 @@ 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?
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,86 @@
"""Always-on QRMaster X milestone worker. The web app never receives X keys."""
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 render_card(card):
image = Image.new("RGB", (1200, 675), "#061b31")
draw = ImageDraw.Draw(image)
fonts = Path("/usr/share/fonts/truetype/dejavu")
bold = ImageFont.truetype(str(fonts / "DejaVuSans-Bold.ttf"), 112)
regular = ImageFont.truetype(str(fonts / "DejaVuSans.ttf"), 34)
image_draw = draw
image_draw.rounded_rectangle((55, 55, 1145, 620), radius=28, outline="#304866", width=2)
image_draw.text((100, 105), "QR MASTER", font=regular, fill="#dce8f7")
image_draw.text((100, 210), f"{card['threshold']:,}", font=bold, fill="#ffffff")
scans = "eindeutige Scans" if card.get("language") == "de" else "unique scans"
image_draw.text((105, 350), scans, font=regular, fill="#b8c7da")
image_draw.line((100, 500, 1100, 500), fill="#304866", width=2)
image_draw.text((100, 535), f"{card['label']} · {card['title']}", font=regular, fill="#dce8f7")
path = Path(tempfile.mkstemp(suffix=".png")[1])
image.save(path, "PNG", optimize=True)
return path
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"))
api("PATCH", base, {"id": milestone["id"], "result": "posted"})
print(json.dumps({"posted": milestone["id"], "x": result}), flush=True)
except Exception as error:
api("PATCH", base, {"id": milestone["id"], "result": "failed"})
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,30 @@
-- 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");

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,53 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSocialMilestoneThresholds, milestoneKind } 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}`;
}
function excludedEmails() {
return (process.env.SOCIAL_MILESTONE_EXCLUDED_EMAILS || '')
.split(',').map(email => email.trim().toLowerCase()).filter(Boolean);
}
// 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 excluded = excludedEmails();
const thresholds = getSocialMilestoneThresholds();
const candidates = await db.qRScan.groupBy({
by: ['qrId'],
where: {
isUnique: true,
qr: { user: excluded.length ? { email: { notIn: excluded, mode: 'insensitive' } } : undefined },
},
_count: { _all: true },
});
const records = candidates.flatMap(({ qrId, _count }) =>
thresholds
.filter(threshold => _count._all >= threshold)
.map(threshold => ({ qrId, kind: milestoneKind(threshold) }))
);
if (records.length) {
const qrs = await db.qRCode.findMany({
where: { id: { in: Array.from(new Set(records.map(record => record.qrId))) } },
select: { id: true, userId: true },
});
const userIdByQr = new Map(qrs.map(qr => [qr.id, qr.userId]));
await db.socialMilestone.createMany({
data: records
.filter(record => userIdByQr.has(record.qrId))
.map(record => ({ ...record, userId: userIdByQr.get(record.qrId)! })),
skipDuplicates: true,
});
}
return NextResponse.json({ ok: true, detected: records.length, thresholds });
}

View File

@@ -0,0 +1,65 @@
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 dayAgo = new Date(now - 24 * 60 * 60 * 1000);
const approvalNotBefore = new Date(now - approvalDelayHours() * 60 * 60 * 1000);
const postedToday = await db.socialMilestone.count({ where: { OR: [{ status: 'posted', postedAt: { gte: dayAgo } }, { status: 'processing', claimedAt: { gte: dayAgo } }] } });
if (postedToday > 0) return NextResponse.json({ milestone: null, reason: 'daily_limit' });
const milestone = await db.socialMilestone.findFirst({
where: { status: 'approved', respondedAt: { lte: approvalNotBefore } },
orderBy: { respondedAt: '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 });
// A claimed item also occupies the daily slot. This prevents two workers
// from each claiming a different milestone before either one posts.
const claimed = await db.$transaction(async (tx) => {
await tx.$queryRawUnsafe('SELECT pg_advisory_xact_lock(920241)');
const occupied = await tx.socialMilestone.count({ where: { OR: [{ status: 'posted', postedAt: { gte: dayAgo } }, { status: 'processing', claimedAt: { gte: dayAgo } }] } });
if (occupied) return 0;
const result = await tx.socialMilestone.updateMany({
where: { id: milestone.id, status: 'approved' }, data: { status: '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' } | 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, status: 'processing' },
data: { status: body.result!, postedAt: body.result === 'posted' ? new Date() : 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,48 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { csrfProtection } from '@/lib/csrf';
import { getSessionUserId } from '@/lib/session';
import { buildMilestoneCard, buildMilestonePost, milestoneThreshold, normalizeXHandle, socialLocale } from '@/lib/social-milestones';
type Action = 'approve_brand' | 'self_share' | 'decline' | 'opt_out' | 'revoke';
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 db.socialMilestone.findFirst({
where: { id: params.id, userId }, include: { user: { select: { primaryUseCase: true } } },
});
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 });
if (body.action === 'revoke') {
if (milestone.status !== 'approved') return NextResponse.json({ error: 'Only queued approvals can be revoked' }, { status: 409 });
await db.socialMilestone.update({ where: { id: milestone.id }, data: { status: 'revoked', respondedAt: new Date() } });
return NextResponse.json({ ok: true });
}
if (!['detected', 'shown'].includes(milestone.status)) return NextResponse.json({ error: 'This milestone has already been answered' }, { status: 409 });
const withName = body.action === 'approve_brand' && body.withName === true;
const language = socialLocale(body.language);
const xHandle = withName ? normalizeXHandle(body.xHandle || '') : null;
if (withName && !xHandle) return NextResponse.json({ error: 'Enter a valid X handle' }, { status: 400 });
const status = body.action === 'approve_brand' ? 'approved' : body.action === 'self_share' ? 'self_shared' : 'declined';
const consentText = body.action === 'approve_brand'
? buildMilestonePost(milestone.user.primaryUseCase, threshold, xHandle, language)
: null;
const now = new Date();
await db.$transaction([
db.socialMilestone.update({ where: { id: milestone.id }, data: { status, withName, consentText, language, cardData: body.action === 'approve_brand' ? buildMilestoneCard(milestone.user.primaryUseCase, threshold, language) : undefined, respondedAt: now } }),
...(body.action === 'opt_out' ? [db.user.update({ where: { id: userId }, data: { socialPromptOptOut: true } })] : []),
...(withName ? [db.user.update({ where: { id: userId }, data: { xHandle } })] : []),
]);
return NextResponse.json({ ok: true, consentText });
}

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSessionUserId } from '@/lib/session';
import { buildMilestoneCard, buildMilestonePost, 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: { 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'));
return NextResponse.json({
milestone: {
id: milestone.id, qrTitle: milestone.qr.title, threshold,
defaultXHandle: user.xHandle,
language: locale,
preview: buildMilestonePost(user.primaryUseCase, threshold, null, locale),
card: buildMilestoneCard(user.primaryUseCase, threshold, locale),
},
});
}

View File

@@ -0,0 +1,104 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { QrCode, Sparkles } 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 Milestone = {
id: string;
qrTitle: string;
threshold: number;
defaultXHandle: string | null;
preview: string;
language: 'en' | 'de';
card: { title: string; label: string };
};
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(false);
useEffect(() => {
fetch(`/api/social-milestones?locale=${locale}`)
.then(async (response) => response.ok && setMilestone((await response.json()).milestone))
.catch(() => undefined);
}, [locale]);
useEffect(() => setXHandle(milestone?.defaultXHandle || ''), [milestone]);
const copy = milestone?.language === 'de'
? { heading: 'Ein echter Erfolg', subtitle: 'hat gerade einen Scan-Meilenstein erreicht.', consent: 'Darf QR Master diesen Erfolg mit der unten stehenden Karte auf dem eigenen X-Account teilen?', name: 'Meinen X-Handle nennen', decline: 'Nein, danke', optOut: 'Nicht mehr anzeigen', self: 'Selbst teilen', approve: 'Auf QR Master posten', published: 'Der Beitrag wird jetzt auf dem QR Master X-Account veroeffentlicht.' }
: { heading: 'A real milestone', subtitle: 'just reached a scan milestone.', consent: 'May QR Master share this success, including the card below, from our X account?', name: 'Mention my X handle', decline: 'No thanks', optOut: 'Do not show again', self: 'Share myself', approve: 'Post from QR Master', published: 'This will now be published from the QR Master X account.' };
const preview = useMemo(() => {
if (!milestone || !withName || !xHandle.trim()) return milestone?.preview || '';
return `${milestone.preview} By @${xHandle.trim().replace(/^@/, '')}.`;
}, [milestone, withName, xHandle]);
const respond = async (action: 'approve_brand' | 'self_share' | 'decline' | 'opt_out') => {
if (!milestone) return;
setSaving(true);
try {
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');
if (action === 'self_share') {
await navigator.clipboard?.writeText(preview);
window.open(`https://x.com/intent/post?text=${encodeURIComponent(preview)}`, '_blank', 'noopener,noreferrer');
window.open('https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fwww.qrmaster.net', '_blank', 'noopener,noreferrer');
showToast('Post text copied for LinkedIn.', 'success');
} else if (action === 'approve_brand') {
showToast(copy.published, 'success');
}
setMilestone(null);
} catch (error) {
showToast(error instanceof Error ? error.message : 'Could not save your choice', 'error');
} finally {
setSaving(false);
}
};
if (!milestone) return null;
return <Dialog open onOpenChange={(open) => !open && setMilestone(null)}>
<DialogContent className="max-w-lg overflow-hidden border-slate-200 p-0 shadow-[0_30px_45px_-30px_rgba(50,50,93,0.4)]">
<div className="border-b border-slate-100 px-6 pb-5 pt-6">
<DialogHeader>
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-violet-50 text-violet-700"><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-slate-600"><strong className="font-medium text-slate-900">{milestone.qrTitle}</strong> {copy.subtitle}</DialogDescription>
</DialogHeader>
</div>
<div className="space-y-5 px-6 py-5">
<div className="rounded-xl bg-[#061b31] p-5 text-white shadow-[0_18px_36px_-20px_rgba(50,50,93,0.65)]">
<div className="flex items-center justify-between text-xs text-slate-300"><span className="flex items-center gap-2 font-medium tracking-wide"><QrCode className="h-4 w-4" />QR MASTER</span><span>Verified</span></div>
<div className="mt-7 text-5xl font-semibold tracking-[-0.04em] tabular-nums">{milestone.threshold.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}</div>
<div className="mt-1 text-sm text-slate-300">{milestone.language === 'de' ? 'eindeutige Scans' : 'unique scans'}</div>
<div className="mt-7 border-t border-white/15 pt-3 text-xs text-slate-300">{milestone.card.label} · {milestone.card.title}</div>
</div>
<p className="text-sm leading-6 text-slate-600">{copy.consent}</p>
<blockquote className="border-l-2 border-violet-500 pl-3 text-sm leading-6 text-slate-700">{preview}</blockquote>
<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)} className="h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500" />{copy.name}</label>
{withName && <input aria-label="X handle" value={xHandle} onChange={(event) => setXHandle(event.target.value)} placeholder="@yourhandle" className="w-full rounded-md border border-slate-200 px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-2 focus:ring-violet-100" />}
</div>
<DialogFooter className="border-t border-slate-100 bg-slate-50 px-6 py-4">
<div className="flex w-full flex-wrap items-center justify-end gap-2">
<Button variant="outline" onClick={() => respond('decline')} disabled={saving}>{copy.decline}</Button>
<Button variant="outline" onClick={() => respond('self_share')} disabled={saving}>{copy.self}</Button>
<Button variant="primary" onClick={() => respond('approve_brand')} disabled={saving}>{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={() => respond('opt_out')} disabled={saving}>{copy.optOut}</button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>;
}

View File

@@ -0,0 +1,69 @@
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 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 normalizeXHandle(value: string): string | null {
const handle = value.trim().replace(/^@/, '');
return /^[A-Za-z0-9_]{1,15}$/.test(handle) ? handle : null;
}