From 925540f3c61c2022206a8b7c95baa8bb008cdadc Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Fri, 14 Aug 2026 12:31:10 +0200 Subject: [PATCH] Improve social milestone sharing flow --- prisma/schema.prisma | 8 + scripts/social-worker/worker.py | 57 +++++-- sql/2026-08-13_social_milestones.sql | 13 ++ .../(marketing)/s/m/[token]/og/route.tsx | 15 ++ .../(main)/(marketing)/s/m/[token]/page.tsx | 38 +++++ .../api/internal/social-milestones/route.ts | 25 ++- .../api/social-milestones/[id]/route.ts | 98 +++++++++--- src/app/(main)/api/social-milestones/route.ts | 23 ++- .../dashboard/SocialMilestoneDialog.tsx | 150 ++++++++++-------- src/lib/social-milestones-server.ts | 51 +++++- src/lib/social-milestones.ts | 43 +++++ 11 files changed, 400 insertions(+), 121 deletions(-) create mode 100644 src/app/(main)/(marketing)/s/m/[token]/og/route.tsx create mode 100644 src/app/(main)/(marketing)/s/m/[token]/page.tsx 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 !open && setMilestone(null)}> - -
- -
- {copy.heading} - {milestone.qrTitle} {copy.subtitle} -
+ const card = milestone.card; + const count = card.totalUniqueScans || milestone.threshold; + const status = brand?.brandStatus || milestone.brandStatus || 'pending'; + return !open && setMilestone(null)}> + +
{copy.heading}{milestone.qrTitle} {copy.subtitle}
+
+
+
QR MASTERVerified scan milestone
+
TOTAL UNIQUE SCANS
{count.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}
{milestone.language === 'de' ? 'eindeutige Scans' : 'unique scans'}
+ {card.trend ?
{card.trend.recentTotal} {milestone.language === 'de' ? 'eindeutige Scans in den letzten' : 'unique scans in the last'} {card.trend.periodDays} days
:
{milestone.language === 'de' ? 'Erste Dynamik' : 'Early momentum'}
} +
{card.qrTitle}
+
+

{copy.consent}

{preview}
+ + {withName && 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" />} +
{copy.self}
+ {status !== 'pending' &&
{status === 'posted' ? copy.posted : status === 'failed' ? copy.failed : copy.queued}{brand?.brandPostUrl && View}
}
-
-
-
QR MASTERVerified
-
{milestone.threshold.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}
-
{milestone.language === 'de' ? 'eindeutige Scans' : 'unique scans'}
-
{milestone.card.label} · {milestone.card.title}
-
-

{copy.consent}

-
{preview}
- - {withName && 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" />} -
- -
- - - - -
-
+
; } diff --git a/src/lib/social-milestones-server.ts b/src/lib/social-milestones-server.ts index f3dc76e..9ec4e70 100644 --- a/src/lib/social-milestones-server.ts +++ b/src/lib/social-milestones-server.ts @@ -1,5 +1,5 @@ import { db } from '@/lib/db'; -import { getSocialMilestoneThresholds, milestoneKind } from '@/lib/social-milestones'; +import { buildMilestoneCardSnapshot, getSocialMilestoneThresholds, milestoneKind, SocialLocale } from '@/lib/social-milestones'; function excludedEmails() { return (process.env.SOCIAL_MILESTONE_EXCLUDED_EMAILS || '') @@ -27,12 +27,57 @@ export async function detectSocialMilestones(qrId?: string) { const qrs = await db.qRCode.findMany({ where: { id: { in: Array.from(new Set(records.map(record => record.qrId))) } }, - select: { id: true, userId: true }, + select: { id: true, userId: true, title: true, createdAt: true, user: { select: { primaryUseCase: true } } }, }); + const cardByQr = new Map>>(); + 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)! })), + 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 sevenDaysAgo = new Date(now); + sevenDaysAgo.setUTCHours(0, 0, 0, 0); + sevenDaysAgo.setUTCDate(sevenDaysAgo.getUTCDate() - 6); + const scans = await db.qRScan.findMany({ + where: { qrId: qr.id, isUnique: true }, + select: { ts: true }, + orderBy: { ts: 'asc' }, + }); + const daily = new Map(); + for (const scan of scans) { + if (scan.ts >= sevenDaysAgo) { + const key = scan.ts.toISOString().slice(0, 10); + daily.set(key, (daily.get(key) || 0) + 1); + } + } + const series = Array.from({ length: 7 }, (_, index) => { + const date = new Date(sevenDaysAgo); + date.setUTCDate(date.getUTCDate() + index); + return daily.get(date.toISOString().slice(0, 10)) || 0; + }); + const activeDays = series.filter(Boolean).length; + const trend = activeDays >= 2 && scans.length >= 5 + ? { periodDays: 7, series, recentTotal: series.reduce((total, value) => total + value, 0) } + : null; + + // The snapshot is made at detection time and never silently changes after consent. + return buildMilestoneCardSnapshot({ + primaryUseCase: qr.user.primaryUseCase, + qrTitle: qr.title, + totalUniqueScans: scans.length, + milestoneThreshold: scans.length, + reachedAt: now, + trend, + locale: 'en' as SocialLocale, + }); +} diff --git a/src/lib/social-milestones.ts b/src/lib/social-milestones.ts index c2b91a2..87a0921 100644 --- a/src/lib/social-milestones.ts +++ b/src/lib/social-milestones.ts @@ -39,6 +39,18 @@ export function milestoneThreshold(kind: string): number | null { export type SocialLocale = 'en' | 'de'; +export type SocialMilestoneCard = { + version: 'milestone-card-v2'; + language: SocialLocale; + qrTitle: string; + label: string; + title: string; + totalUniqueScans: number; + milestoneThreshold: number; + reachedAt: string; + trend: { periodDays: number; series: number[]; recentTotal: number } | null; +}; + export function socialLocale(value?: string | null): SocialLocale { return value === 'de' ? 'de' : 'en'; } @@ -63,6 +75,37 @@ export function buildMilestoneCard(primaryUseCase: string | null, threshold: num 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; + totalUniqueScans: number; + milestoneThreshold: number; + reachedAt: Date; + trend: { periodDays: number; series: number[]; recentTotal: number } | null; + 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', + 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;