diff --git a/scripts/social-worker/Dockerfile b/scripts/social-worker/Dockerfile index d3d3458..061217d 100644 --- a/scripts/social-worker/Dockerfile +++ b/scripts/social-worker/Dockerfile @@ -3,4 +3,5 @@ WORKDIR /worker COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY worker.py . +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 CMD python -c "import os,requests; base=os.environ['QRMASTER_API_BASE'].rstrip('/'); secret=os.environ['INTERNAL_API_SECRET']; requests.get(base + '/api/internal/social-milestones?dryRun=true', headers={'Authorization':'Bearer ' + secret}, timeout=5).raise_for_status()" CMD ["python", "worker.py"] diff --git a/scripts/social-worker/worker.py b/scripts/social-worker/worker.py index e76ae1f..fb5cdf4 100644 --- a/scripts/social-worker/worker.py +++ b/scripts/social-worker/worker.py @@ -57,14 +57,19 @@ def render_card(card): else: logo_x = 68 draw.text((logo_x, 70), "QR MASTER", font=medium, fill=navy) + badge_text = "Verified scan milestone" + badge_font = font("DejaVuSans.ttf", 19) + badge_box = draw.textbbox((0, 0), badge_text, font=badge_font) + badge_width = badge_box[2] - badge_box[0] + draw.rounded_rectangle((1120 - badge_width - 32, 61, 1132, 106), radius=7, fill="#ecfdf5") + draw.text((1116, 73), badge_text, font=badge_font, fill=mint, anchor="ra") + draw.line((68, 124, 1132, 124), fill="#e5edf5", width=2) total = int(card.get("totalUniqueScans") or card.get("threshold") or 0) total_scans = int(card.get("totalScans") or total) draw.text((66, 212), f"{total:,}", font=display, fill=navy) draw.text((73, 374), "UNIQUE SCANS", font=medium, fill=navy) draw.text((74, 410), f"{total_scans:,} total scans", font=font("DejaVuSans.ttf", 20), fill=slate) - draw.line((68, 548, 108, 548), fill=blue, width=4) - draw.text((126, 530), "QR code milestone", font=regular, fill=slate) trend = card.get("trend") or {} raw_points = trend.get("points") or [] @@ -89,7 +94,11 @@ def render_card(card): draw.ellipse((x - 8, y - 8, x + 8, y + 8), fill="#ffffff", outline=blue, width=5) draw.text((left, top + height + 27), trend.get("startLabel") or "Created", font=font("DejaVuSans.ttf", 19), fill=slate) draw.text((left + width, top + height + 27), trend.get("endLabel") or "Reached", font=font("DejaVuSans.ttf", 19), fill=slate, anchor="ra") - draw.text((870, 530), "VERIFIED SCAN DATA", font=font("DejaVuSans-Bold.ttf", 18), fill=mint) + draw.line((68, 536, 1132, 536), fill="#e5edf5", width=2) + title = str(card.get("qrTitle") or "QR code") + if len(title) > 52: + title = title[:49].rstrip() + "..." + draw.text((68, 558), title, font=medium, fill=navy) path = Path(tempfile.mkstemp(suffix=".png")[1]) image.save(path, "PNG", optimize=True) return path diff --git a/src/app/(main)/(app)/settings/page.tsx b/src/app/(main)/(app)/settings/page.tsx index 1f52914..de68722 100644 --- a/src/app/(main)/(app)/settings/page.tsx +++ b/src/app/(main)/(app)/settings/page.tsx @@ -14,7 +14,10 @@ export default function SettingsPage() { const { fetchWithCsrf } = useCsrf(); const [activeTab, setActiveTab] = useState('profile'); const [loading, setLoading] = useState(false); - const [showPasswordModal, setShowPasswordModal] = useState(false); + const [showPasswordModal, setShowPasswordModal] = useState(false); + const [socialPromptsEnabled, setSocialPromptsEnabled] = useState(true); + const [socialTestResetAvailable, setSocialTestResetAvailable] = useState(false); + const [socialSaving, setSocialSaving] = useState(false); // Profile states const [name, setName] = useState(''); @@ -49,10 +52,17 @@ export default function SettingsPage() { // Fetch usage stats from API const statsResponse = await fetch('/api/user/stats'); - if (statsResponse.ok) { - const data = await statsResponse.json(); - setUsageStats(data); - } + if (statsResponse.ok) { + const data = await statsResponse.json(); + setUsageStats(data); + } + + const socialResponse = await fetch('/api/social-milestones/preferences'); + if (socialResponse.ok) { + const data = await socialResponse.json(); + setSocialPromptsEnabled(data.promptsEnabled !== false); + setSocialTestResetAvailable(data.testResetAvailable === true); + } } catch (e) { console.error('Failed to load user data:', e); } @@ -92,7 +102,25 @@ export default function SettingsPage() { } finally { setLoading(false); } - }; + }; + + const updateSocialPrompts = async (action: 'enable' | 'disable' | 'reset_test') => { + setSocialSaving(true); + try { + const response = await fetchWithCsrf('/api/social-milestones/preferences', { + method: 'PATCH', + body: JSON.stringify({ action }), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Could not update milestone prompts'); + setSocialPromptsEnabled(data.promptsEnabled !== false); + showToast(action === 'reset_test' ? 'Milestone test reset. Open the dashboard to test it again.' : 'Milestone preference updated.', 'success'); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Could not update milestone prompts', 'error'); + } finally { + setSocialSaving(false); + } + }; const handleManageSubscription = async () => { setLoading(true); @@ -245,9 +273,32 @@ export default function SettingsPage() {

- - - {/* Security */} + + + + + Milestone sharing + + +
+
+

Show scan milestone prompts

+

Choose whether QR Master may ask you to share verified scan achievements. Nothing is published without your confirmation.

+
+ +
+ {socialTestResetAvailable &&
+
+

Test environment: reopen the latest milestone and clear its publishing state.

+ +
+
} +
+
+ + {/* Security */} Security diff --git a/src/app/(main)/(marketing)/s/m/[token]/og/route.tsx b/src/app/(main)/(marketing)/s/m/[token]/og/route.tsx index 03a5db5..74d01dc 100644 --- a/src/app/(main)/(marketing)/s/m/[token]/og/route.tsx +++ b/src/app/(main)/(marketing)/s/m/[token]/og/route.tsx @@ -3,13 +3,96 @@ import { db } from '@/lib/db'; export const runtime = 'nodejs'; +type Trend = { + points: Array<{ at: string; total: number }>; + startLabel: string; + endLabel: string; + target: number; +}; + +type Card = { + qrTitle?: string; + totalScans?: number; + totalUniqueScans?: number; + milestoneThreshold?: number; + trend?: Trend | null; +}; + +function chart(card: Card, german: boolean) { + const target = Math.max(1, Number(card.totalUniqueScans || card.milestoneThreshold || 1)); + const trend = card.trend; + const rawPoints = Array.isArray(trend?.points) ? trend.points : []; + if (!trend || rawPoints.length === 0) return null; + + const ceiling = target <= 5 ? 5 : target * 1.25; + const ticks = target <= 5 + ? [1, 2, 3, 4, 5] + : Array.from({ length: 5 }, (_, index) => target * (index + 1) / 4); + const timestamps = rawPoints.map(point => new Date(point.at).getTime()).filter(Number.isFinite); + const first = timestamps.length ? Math.min(...timestamps) : 0; + const last = Math.max(timestamps.length ? Math.max(...timestamps) : first, first + 1); + const plotLeft = 72; + const plotRight = 540; + const plotTop = 20; + const plotBottom = 236; + const points = rawPoints.map(point => { + const time = new Date(point.at).getTime(); + const x = plotLeft + ((Number.isFinite(time) ? time : first) - first) / (last - first) * (plotRight - plotLeft); + const y = plotBottom - Math.min(Math.max(Number(point.total) || 0, 0), ceiling) / ceiling * (plotBottom - plotTop); + return `${x},${y}`; + }).join(' '); + const number = new Intl.NumberFormat(german ? 'de-DE' : 'en-US', { maximumFractionDigits: 2 }); + + return
+ + {ticks.map(tick => { + const y = plotBottom - tick / ceiling * (plotBottom - plotTop); + const reached = tick === target; + return + {number.format(tick)} + + ; + })} + + {points && } + {trend.startLabel || (german ? 'Erstellt' : 'Created')} + {trend.endLabel || (german ? 'Erreicht' : 'Reached')} + +
{german ? 'Kumulierte eindeutige Scans' : 'Cumulative unique scans'}
+
; +} + 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 card = (share.cardData || {}) as Card; 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' } }); + const unique = Math.max(0, Number(card.totalUniqueScans || card.milestoneThreshold || 0)); + const total = Math.max(unique, Number(card.totalScans || unique)); + const locale = german ? 'de-DE' : 'en-US'; + + return new ImageResponse( +
+
+
+ QR MASTER + ✓ {german ? 'Verifizierter Scan-Meilenstein' : 'Verified scan milestone'} +
+
+
+ UNIQUE SCANS + {unique.toLocaleString(locale)} + {total.toLocaleString(locale)} {german ? 'Scans insgesamt' : 'total scans'} +
+ {chart(card, german)} +
+
{card.qrTitle || (german ? 'QR-Code' : 'QR code')}
+
+
, + { 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 index fe2181a..33bca42 100644 --- a/src/app/(main)/(marketing)/s/m/[token]/page.tsx +++ b/src/app/(main)/(marketing)/s/m/[token]/page.tsx @@ -8,7 +8,7 @@ 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 }, + select: { cardData: true, language: true, publicShareApprovedAt: true }, }); } @@ -20,13 +20,17 @@ export async function generateMetadata({ params }: Props): Promise { const title = share.language === 'de' ? `${count.toLocaleString('de-DE')} eindeutige QR-Scans erreicht` : `${count.toLocaleString('en-US')} unique QR scans reached`; + const description = share.language === 'de' + ? `${card?.qrTitle || 'Ein QR-Code'} hat einen verifizierten Scan-Meilenstein mit QR Master erreicht.` + : `${card?.qrTitle || 'A QR code'} reached a verified scan milestone with QR Master.`; const url = `${getWwwOrigin()}/s/m/${params.token}`; + const imageUrl = `${url}/og?v=${share.publicShareApprovedAt?.getTime() || 0}`; return { title, - description: card?.qrTitle || 'A verified QR Master scan milestone.', + description, 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`] }, + openGraph: { type: 'website', title, description, url, images: [{ url: imageUrl, width: 1200, height: 630, alt: title }] }, + twitter: { card: 'summary_large_image', title, description, images: [imageUrl] }, }; } @@ -34,5 +38,9 @@ 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}

; + const imageUrl = `${getWwwOrigin()}/s/m/${params.token}/og?v=${share.publicShareApprovedAt?.getTime() || 0}`; + const alt = share.language === 'de' + ? `${card?.qrTitle || 'QR-Code'}: ${(card?.totalUniqueScans || 0).toLocaleString('de-DE')} eindeutige Scans` + : `${card?.qrTitle || 'QR code'}: ${(card?.totalUniqueScans || 0).toLocaleString('en-US')} unique scans`; + return
{alt}

{share.language === 'de' ? 'Verifizierter Scan-Meilenstein von QR Master' : 'Verified scan milestone from QR Master'}

; } diff --git a/src/app/(main)/api/internal/social-milestones/route.ts b/src/app/(main)/api/internal/social-milestones/route.ts index 35445c7..a7e6335 100644 --- a/src/app/(main)/api/internal/social-milestones/route.ts +++ b/src/app/(main)/api/internal/social-milestones/route.ts @@ -20,6 +20,12 @@ 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(); + // A worker can be interrupted after claiming a row. Surface that state as a + // retryable failure instead of leaving the dashboard in "processing" forever. + await db.socialMilestone.updateMany({ + where: { brandStatus: 'processing', claimedAt: { lt: new Date(now - 5 * 60 * 1000) } }, + data: { brandStatus: 'failed', brandPostError: 'The publisher was interrupted before it confirmed the post. Please retry.' }, + }); const approvalNotBefore = new Date(now - approvalDelayHours() * 60 * 60 * 1000); const milestone = await db.socialMilestone.findFirst({ where: { brandStatus: 'approved', brandApprovedAt: { lte: approvalNotBefore } }, diff --git a/src/app/(main)/api/social-milestones/[id]/route.ts b/src/app/(main)/api/social-milestones/[id]/route.ts index f47b4b8..171c579 100644 --- a/src/app/(main)/api/social-milestones/[id]/route.ts +++ b/src/app/(main)/api/social-milestones/[id]/route.ts @@ -86,7 +86,7 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st where: { id: milestone.id }, data: { selfSharedAt: now, publicShareApprovedAt: now, shareToken: token, cardData: card, language }, }); - return NextResponse.json({ ok: true, shareToken: token, milestone: clientState(updated) }); + return NextResponse.json({ ok: true, shareToken: token, shareVersion: now.getTime(), milestone: clientState(updated) }); } if (!['pending', 'failed', 'revoked'].includes(milestone.brandStatus)) { diff --git a/src/app/(main)/api/social-milestones/preferences/route.ts b/src/app/(main)/api/social-milestones/preferences/route.ts new file mode 100644 index 0000000..a6744c2 --- /dev/null +++ b/src/app/(main)/api/social-milestones/preferences/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { csrfProtection } from '@/lib/csrf'; +import { db } from '@/lib/db'; +import { getSessionUserId } from '@/lib/session'; +import { getSocialMilestoneThresholds } from '@/lib/social-milestones'; + +export const dynamic = 'force-dynamic'; + +function testResetAvailable() { + return getSocialMilestoneThresholds().some(threshold => threshold < 100); +} + +export async function GET() { + const userId = getSessionUserId(); + if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const user = await db.user.findUnique({ where: { id: userId }, select: { socialPromptOptOut: true } }); + if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + return NextResponse.json({ promptsEnabled: !user.socialPromptOptOut, testResetAvailable: testResetAvailable() }); +} + +export async function PATCH(request: NextRequest) { + 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?: 'enable' | 'disable' | 'reset_test' } | null; + if (!body?.action || !['enable', 'disable', 'reset_test'].includes(body.action)) { + return NextResponse.json({ error: 'Invalid action' }, { status: 400 }); + } + + if (body.action === 'reset_test') { + if (!testResetAvailable()) return NextResponse.json({ error: 'Test reset is not available in this environment' }, { status: 403 }); + const latest = await db.socialMilestone.findFirst({ + where: { userId }, + orderBy: { detectedAt: 'desc' }, + select: { id: true }, + }); + await db.$transaction([ + db.user.update({ where: { id: userId }, data: { socialPromptOptOut: false } }), + ...(latest ? [db.socialMilestone.update({ + where: { id: latest.id }, + data: { + status: 'detected', shownAt: null, respondedAt: null, + brandStatus: 'pending', brandApprovedAt: null, claimedAt: null, + brandPostedAt: null, brandPostUrl: null, brandPostError: null, + consentText: null, withName: false, + selfSharedAt: null, publicShareApprovedAt: null, shareToken: null, + }, + })] : []), + ]); + return NextResponse.json({ ok: true, promptsEnabled: true, resetMilestone: Boolean(latest) }); + } + + const promptsEnabled = body.action === 'enable'; + await db.user.update({ where: { id: userId }, data: { socialPromptOptOut: !promptsEnabled } }); + return NextResponse.json({ ok: true, promptsEnabled }); +} diff --git a/src/components/dashboard/SocialMilestoneDialog.tsx b/src/components/dashboard/SocialMilestoneDialog.tsx index 2a7acef..8622a88 100644 --- a/src/components/dashboard/SocialMilestoneDialog.tsx +++ b/src/components/dashboard/SocialMilestoneDialog.tsx @@ -70,10 +70,11 @@ export function SocialMilestoneDialog() { const copy = milestone?.language === 'de' ? { heading: 'Ein echter Erfolg', subtitle: 'hat einen Scan-Meilenstein erreicht.', consent: 'Darf QR Master diesen Erfolg auf dem eigenen X-Account veröffentlichen?', name: 'Meinen X-Handle nennen', decline: 'Nicht jetzt', self: 'Selbst teilen', approve: 'Auf QR Master posten', queued: 'Wird auf X veröffentlicht …', posted: 'Auf X veröffentlicht', failed: 'Veröffentlichung fehlgeschlagen' } - : { heading: 'A real milestone', subtitle: 'just reached a scan milestone.', consent: 'May QR Master publish this success from our X account?', name: 'Mention my X handle', decline: 'Not now', self: 'Share myself', approve: 'Post from QR Master', queued: 'Publishing to X …', posted: 'Published on X', failed: 'Publishing failed' }; + : { 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: 'Waiting for the X publisher …', 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(/^@/, '')}.`; + const mention = milestone.language === 'de' ? 'Glückwunsch' : 'Congratulations'; + return `${milestone.preview}\n\n${mention} @${xHandle.trim().replace(/^@/, '')}.`; }, [milestone, withName, xHandle]); const update = async (action: 'approve_brand' | 'self_share' | 'decline' | 'opt_out') => { if (!milestone) return null; @@ -91,22 +92,15 @@ export function SocialMilestoneDialog() { setSaving('self'); try { const result = await update('self_share'); - const shareUrl = `${window.location.origin}/s/m/${result.shareToken}`; - const text = `${preview} ${shareUrl}`; + const shareUrl = `${window.location.origin}/s/m/${result.shareToken}?v=${result.shareVersion}`; + const text = `${preview}\n\n${shareUrl}`; const targetUrl = network === 'x' ? `https://x.com/intent/post?text=${encodeURIComponent(text)}` : `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`; - if (network === 'x') { - if (shareWindow) shareWindow.location.href = targetUrl; - else window.location.assign(targetUrl); - } - else { - await navigator.clipboard?.writeText(text); - if (shareWindow) shareWindow.location.href = targetUrl; - else window.location.assign(targetUrl); - } + if (shareWindow) shareWindow.location.href = targetUrl; + else window.location.assign(targetUrl); setBrand(result.milestone); - showToast(network === 'linkedin' ? 'Share text copied and LinkedIn opened.' : 'X share composer opened.', 'success'); + showToast(network === 'linkedin' ? 'LinkedIn share window opened.' : 'X share composer opened.', 'success'); } catch (error) { shareWindow?.close(); showToast(error instanceof Error ? error.message : 'Could not prepare this share', 'error'); } finally { setSaving(null); } }; @@ -122,11 +116,18 @@ export function SocialMilestoneDialog() { 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'); } }; + const optOut = () => { + const message = milestone?.language === 'de' + ? 'Meilenstein-Hinweise dauerhaft ausblenden? Du kannst sie später in den Einstellungen wieder aktivieren.' + : 'Turn off milestone prompts? You can enable them again later in Settings.'; + if (window.confirm(message)) void dismiss('opt_out'); + }; if (!milestone) return null; const card = milestone.card; const count = card.totalUniqueScans || milestone.threshold; const status = brand?.brandStatus || milestone.brandStatus || 'pending'; + const canApprove = ['pending', 'failed', 'revoked'].includes(status); return !open && setMilestone(null)}>
{copy.heading}{milestone.qrTitle} {copy.subtitle}
@@ -137,13 +138,13 @@ export function SocialMilestoneDialog() { {card.trend &&
{milestone.language === 'de' ? 'Kumulierte eindeutige Scans' : 'Cumulative unique scans'}
}
{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.consent}

{preview}
+ + {withName && setXHandle(event.target.value)} disabled={!canApprove} 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}
} + {status !== 'pending' &&
{status === 'posted' ? copy.posted : status === 'failed' ? `${copy.failed}${brand?.brandPostError ? `: ${brand.brandPostError}` : ''}` : copy.queued}{brand?.brandPostUrl && View}
} -
+
; } diff --git a/src/lib/social-milestones.ts b/src/lib/social-milestones.ts index 60565ad..0e2616d 100644 --- a/src/lib/social-milestones.ts +++ b/src/lib/social-milestones.ts @@ -84,11 +84,17 @@ export function buildMilestoneCard(primaryUseCase: string | null, threshold: num 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 rawSubject = qrTitle.trim() || usageLabel(primaryUseCase, locale); + const subject = rawSubject.length > 72 ? `${rawSubject.slice(0, 69).trimEnd()}…` : rawSubject; + const scanLabel = locale === 'de' + ? `${count} ${totalUniqueScans === 1 ? 'verifizierten eindeutigen Scan' : 'verifizierte eindeutige Scans'}` + : `${count} verified unique ${totalUniqueScans === 1 ? 'scan' : 'scans'}`; const base = locale === 'de' - ? `„${subject}“ hat ${count} eindeutige Scans erreicht.` - : `“${subject}” reached ${count} unique scans.`; - return xHandle ? `${base} By @${xHandle.replace(/^@/, '')}.` : base; + ? `QR-Meilenstein erreicht.\n\n„${subject}“ hat ${scanLabel} erzielt.\n\nErstellt und gemessen mit QR Master.` + : `QR milestone unlocked.\n\n“${subject}” has reached ${scanLabel}.\n\nCreated and measured with QR Master.`; + if (!xHandle) return base; + const mention = locale === 'de' ? 'Glückwunsch' : 'Congratulations'; + return `${base}\n\n${mention} @${xHandle.replace(/^@/, '')}.`; } export function buildMilestoneCardSnapshot(input: {