diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index cb6cbbe..463df9f 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -175,6 +175,14 @@ model SocialMilestone {
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)
diff --git a/scripts/social-worker/worker.py b/scripts/social-worker/worker.py
index 7561ddc..01f61a7 100644
--- a/scripts/social-worker/worker.py
+++ b/scripts/social-worker/worker.py
@@ -1,4 +1,4 @@
-"""Always-on QRMaster X milestone worker. The web app never receives X keys."""
+"""Always-on QR Master X milestone worker. The web app never receives X keys."""
import json
import os
import tempfile
@@ -23,20 +23,45 @@ def api(method, url, payload=None):
return response.json()
+def font(name, size):
+ return ImageFont.truetype(f"/usr/share/fonts/truetype/dejavu/{name}", size)
+
+
def render_card(card):
- image = Image.new("RGB", (1200, 675), "#061b31")
+ """Render the consented immutable scan snapshot; never invent trend data."""
+ image = Image.new("RGB", (1200, 630), "#f8fafc")
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")
+ navy, blue, slate, border, green = "#061b31", "#0256ff", "#64748b", "#e2e8f0", "#059669"
+ regular, medium, bold, display = font("DejaVuSans.ttf", 28), font("DejaVuSans-Bold.ttf", 28), font("DejaVuSans-Bold.ttf", 42), font("DejaVuSans-Bold.ttf", 116)
+ draw.rounded_rectangle((45, 42, 1155, 588), radius=24, fill="#ffffff", outline=border, width=2)
+ draw.rounded_rectangle((82, 79, 114, 111), radius=7, fill=blue)
+ draw.text((130, 81), "QR MASTER", font=medium, fill=navy)
+ draw.rounded_rectangle((932, 77, 1118, 114), radius=8, fill="#ecfdf5")
+ draw.text((954, 84), "Verified milestone", font=font("DejaVuSans-Bold.ttf", 17), fill=green)
+ draw.text((84, 158), "TOTAL UNIQUE SCANS", font=font("DejaVuSans-Bold.ttf", 18), fill="#94a3b8")
+ total = int(card.get("totalUniqueScans") or card.get("threshold") or 0)
+ draw.text((78, 184), f"{total:,}", font=display, fill=navy)
+ label = "eindeutige Scans" if card.get("language") == "de" else "unique scans"
+ draw.text((86, 325), label, font=regular, fill=slate)
+ trend = card.get("trend") or None
+ if trend and len(trend.get("series", [])) > 1:
+ series = trend["series"]
+ left, top, width, height = 84, 390, 1030, 88
+ max_value = max(series) or 1
+ points = [(left + round(index * width / (len(series) - 1)), top + height - round(value / max_value * height)) for index, value in enumerate(series)]
+ for y in (top, top + height // 2, top + height):
+ draw.line((left, y, left + width, y), fill="#edf2f7", width=2)
+ draw.line(points, fill=blue, width=6, joint="curve")
+ for x, y in (points[0], points[-1]):
+ draw.ellipse((x - 7, y - 7, x + 7, y + 7), fill=blue)
+ draw.text((84, 495), f"Last {trend.get('periodDays', 7)} days · {trend.get('recentTotal', 0)} unique scans", font=font("DejaVuSans.ttf", 18), fill=slate)
+ else:
+ draw.rounded_rectangle((84, 398, 357, 452), radius=10, fill="#eff6ff")
+ copy = "Erste Dynamik" if card.get("language") == "de" else "Early momentum"
+ draw.text((106, 411), copy, font=font("DejaVuSans-Bold.ttf", 20), fill=blue)
+ draw.text((84, 495), "Trend appears once enough real scan history exists." if card.get("language") != "de" else "Der Trend erscheint mit ausreichend echten Scan-Daten.", font=font("DejaVuSans.ttf", 18), fill=slate)
+ draw.line((84, 532, 1116, 532), fill=border, width=2)
+ draw.text((84, 549), card.get("qrTitle") or card.get("title") or "QR code", font=medium, fill=navy)
path = Path(tempfile.mkstemp(suffix=".png")[1])
image.save(path, "PNG", optimize=True)
return path
@@ -70,10 +95,12 @@ def run_once():
return
try:
result = post_x(milestone["text"], milestone.get("card"))
- api("PATCH", base, {"id": milestone["id"], "result": "posted"})
+ 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"})
+ api("PATCH", base, {"id": milestone["id"], "result": "failed", "error": str(error)[:500]})
print(f"Milestone post failed: {error}", flush=True)
diff --git a/sql/2026-08-13_social_milestones.sql b/sql/2026-08-13_social_milestones.sql
index 3d56422..838f377 100644
--- a/sql/2026-08-13_social_milestones.sql
+++ b/sql/2026-08-13_social_milestones.sql
@@ -28,3 +28,16 @@ ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "language" TEXT NOT NULL
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;
diff --git a/src/app/(main)/(marketing)/s/m/[token]/og/route.tsx b/src/app/(main)/(marketing)/s/m/[token]/og/route.tsx
new file mode 100644
index 0000000..03a5db5
--- /dev/null
+++ b/src/app/(main)/(marketing)/s/m/[token]/og/route.tsx
@@ -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(
QR MASTERVerified milestone
TOTAL UNIQUE SCANS{(card?.totalUniqueScans || 0).toLocaleString(german ? 'de-DE' : 'en-US')}{german ? 'eindeutige Scans' : 'unique scans'}
{card?.qrTitle || 'QR code'}{card?.trend ? `${card.trend.recentTotal} in ${card.trend.periodDays} days` : (german ? 'Erste Dynamik' : 'Early momentum')}
, { width: 1200, height: 630, headers: { 'Cache-Control': 'no-store' } });
+}
diff --git a/src/app/(main)/(marketing)/s/m/[token]/page.tsx b/src/app/(main)/(marketing)/s/m/[token]/page.tsx
new file mode 100644
index 0000000..fe2181a
--- /dev/null
+++ b/src/app/(main)/(marketing)/s/m/[token]/page.tsx
@@ -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 {
+ 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 QR MASTER · VERIFIED MILESTONE
{(card?.totalUniqueScans || 0).toLocaleString(share.language === 'de' ? 'de-DE' : 'en-US')}
{share.language === 'de' ? 'eindeutige Scans' : 'unique scans'}
{card?.qrTitle}
;
+}
diff --git a/src/app/(main)/api/internal/social-milestones/route.ts b/src/app/(main)/api/internal/social-milestones/route.ts
index 52d3a57..35445c7 100644
--- a/src/app/(main)/api/internal/social-milestones/route.ts
+++ b/src/app/(main)/api/internal/social-milestones/route.ts
@@ -20,14 +20,10 @@ 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' },
+ 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
@@ -37,14 +33,10 @@ export async function GET(request: NextRequest) {
}
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() },
+ where: { id: milestone.id, brandStatus: 'approved' }, data: { brandStatus: 'processing', claimedAt: new Date() },
});
return result.count;
});
@@ -54,11 +46,16 @@ export async function GET(request: NextRequest) {
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;
+ 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, status: 'processing' },
- data: { status: body.result!, postedAt: body.result === 'posted' ? new Date() : null },
+ 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 });
diff --git a/src/app/(main)/api/social-milestones/[id]/route.ts b/src/app/(main)/api/social-milestones/[id]/route.ts
index 117db86..76236e7 100644
--- a/src/app/(main)/api/social-milestones/[id]/route.ts
+++ b/src/app/(main)/api/social-milestones/[id]/route.ts
@@ -1,11 +1,36 @@
+import { randomUUID } from 'crypto';
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';
+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 });
@@ -16,33 +41,70 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st
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 } } },
- });
+ 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 (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() } });
+ 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 });
}
- 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 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 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 card = milestone.cardData || buildMilestoneCardSnapshot({
+ primaryUseCase: milestone.user.primaryUseCase,
+ qrTitle: milestone.qr.title,
+ totalUniqueScans: threshold,
+ milestoneThreshold: threshold,
+ reachedAt: milestone.detectedAt,
+ trend: null,
+ locale: language,
+ });
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 });
+
+ if (body.action === 'self_share') {
+ const token = milestone.shareToken || randomUUID().replace(/-/g, '');
+ 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) });
}
diff --git a/src/app/(main)/api/social-milestones/route.ts b/src/app/(main)/api/social-milestones/route.ts
index 40daa60..f4f4df1 100644
--- a/src/app/(main)/api/social-milestones/route.ts
+++ b/src/app/(main)/api/social-milestones/route.ts
@@ -1,7 +1,7 @@
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';
+import { buildMilestoneCardSnapshot, buildMilestonePostForQr, milestoneThreshold, socialLocale } from '@/lib/social-milestones';
export const dynamic = 'force-dynamic';
@@ -33,9 +33,26 @@ export async function GET(request: NextRequest) {
milestone: {
id: milestone.id, qrTitle: milestone.qr.title, threshold,
defaultXHandle: user.xHandle,
+ brandStatus: milestone.brandStatus,
+ brandPostUrl: milestone.brandPostUrl,
+ brandPostError: milestone.brandPostError,
language: locale,
- preview: buildMilestonePost(user.primaryUseCase, threshold, null, locale),
- card: buildMilestoneCard(user.primaryUseCase, threshold, locale),
+ preview: buildMilestonePostForQr(
+ user.primaryUseCase,
+ ((milestone.cardData as { totalUniqueScans?: number } | null)?.totalUniqueScans || threshold),
+ null,
+ locale,
+ milestone.qr.title,
+ ),
+ card: milestone.cardData || buildMilestoneCardSnapshot({
+ primaryUseCase: user.primaryUseCase,
+ qrTitle: milestone.qr.title,
+ totalUniqueScans: threshold,
+ milestoneThreshold: threshold,
+ reachedAt: milestone.detectedAt,
+ trend: null,
+ locale,
+ }),
},
});
}
diff --git a/src/components/dashboard/SocialMilestoneDialog.tsx b/src/components/dashboard/SocialMilestoneDialog.tsx
index c640fee..d65f4a3 100644
--- a/src/components/dashboard/SocialMilestoneDialog.tsx
+++ b/src/components/dashboard/SocialMilestoneDialog.tsx
@@ -1,22 +1,22 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
-import { QrCode, Sparkles } from 'lucide-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 Milestone = {
- id: string;
- qrTitle: string;
- threshold: number;
- defaultXHandle: string | null;
- preview: string;
- language: 'en' | 'de';
- card: { title: string; label: string };
-};
+type Card = { language: 'en' | 'de'; qrTitle: string; label: string; title: string; totalUniqueScans: number; milestoneThreshold: number; trend: { periodDays: number; series: number[]; recentTotal: 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({ series }: { series: number[] }) {
+ const max = Math.max(...series, 1);
+ const points = series.map((value, index) => `${(index / Math.max(series.length - 1, 1)) * 100},${92 - (value / max) * 70}`).join(' ');
+ return ;
+}
export function SocialMilestoneDialog() {
const { fetchWithCsrf } = useCsrf();
@@ -24,81 +24,95 @@ export function SocialMilestoneDialog() {
const [milestone, setMilestone] = useState(null);
const [withName, setWithName] = useState(false);
const [xHandle, setXHandle] = useState('');
- const [saving, setSaving] = useState(false);
+ const [saving, setSaving] = useState<'brand' | 'self' | null>(null);
+ const [brand, setBrand] = useState(null);
useEffect(() => {
- fetch(`/api/social-milestones?locale=${locale}`)
- .then(async (response) => response.ok && setMilestone((await response.json()).milestone))
- .catch(() => undefined);
+ 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 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.' };
-
+ ? { 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 respond = async (action: 'approve_brand' | 'self_share' | 'decline' | 'opt_out') => {
+ 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;
- setSaving(true);
+ setSaving('self');
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');
+ const result = await update('self_share');
+ const shareUrl = `${window.location.origin}/s/m/${result.shareToken}`;
+ const text = `${preview} ${shareUrl}`;
+ if (network === 'x') window.open(`https://x.com/intent/post?text=${encodeURIComponent(text)}`, '_blank', 'noopener,noreferrer');
+ else {
+ await navigator.clipboard?.writeText(text);
+ window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,noreferrer');
}
- setMilestone(null);
- } catch (error) {
- showToast(error instanceof Error ? error.message : 'Could not save your choice', 'error');
- } finally {
- setSaving(false);
- }
+ setBrand(result.milestone);
+ showToast(network === 'linkedin' ? 'Share text copied and LinkedIn opened.' : 'X share composer opened.', 'success');
+ } catch (error) { 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;
-
- return