From 55c04761cebe135e95428d305562aeda843cb16e Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Fri, 14 Aug 2026 23:43:20 +0200 Subject: [PATCH] Polish milestone dialog and one-time delivery --- .../api/social-milestones/[id]/route.ts | 8 ++- src/app/(main)/api/social-milestones/route.ts | 24 ++++---- .../dashboard/SocialMilestoneDialog.tsx | 55 +++++++++++-------- src/components/ui/Dialog.tsx | 19 ++++--- src/lib/social-milestone-image.tsx | 34 ++++++------ 5 files changed, 77 insertions(+), 63 deletions(-) diff --git a/src/app/(main)/api/social-milestones/[id]/route.ts b/src/app/(main)/api/social-milestones/[id]/route.ts index 65c26ad..0d11102 100644 --- a/src/app/(main)/api/social-milestones/[id]/route.ts +++ b/src/app/(main)/api/social-milestones/[id]/route.ts @@ -16,8 +16,9 @@ async function ownedMilestone(id: string, userId: string) { }); } -function clientState(milestone: { brandStatus: string; brandPostUrl: string | null; brandPostError: string | null; selfSharedAt: Date | null }) { +function clientState(milestone: { status: string; brandStatus: string; brandPostUrl: string | null; brandPostError: string | null; selfSharedAt: Date | null }) { return { + promptStatus: milestone.status, brandStatus: milestone.brandStatus, brandPostUrl: milestone.brandPostUrl, brandPostError: milestone.brandPostError, @@ -86,7 +87,10 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st // much less disruptive in an X compose window than a full UUID. const updated = await db.socialMilestone.update({ where: { id: milestone.id }, - data: { selfSharedAt: now, publicShareApprovedAt: now, shareToken: token, cardData: card, language }, + data: { + status: 'self_shared', respondedAt: milestone.respondedAt || now, + selfSharedAt: now, publicShareApprovedAt: now, shareToken: token, cardData: card, language, + }, }); return NextResponse.json({ ok: true, shareToken: token, shareUrl, shareVersion: now.getTime(), milestone: clientState(updated) }); } diff --git a/src/app/(main)/api/social-milestones/route.ts b/src/app/(main)/api/social-milestones/route.ts index 5e6c40b..2102eb0 100644 --- a/src/app/(main)/api/social-milestones/route.ts +++ b/src/app/(main)/api/social-milestones/route.ts @@ -19,28 +19,18 @@ export async function GET(request: NextRequest) { if (!user || user.socialPromptOptOut) return NextResponse.json({ milestone: null }); const milestone = await db.socialMilestone.findFirst({ - where: { - userId, - OR: [ - { status: { in: ['detected', 'shown'] } }, - { status: 'approved', brandStatus: { in: ['approved', 'processing', 'failed'] } }, - ], - }, + // `shown` is a durable delivery receipt: one milestone may auto-open only + // once, even across refreshes, tabs and later dashboard visits. + where: { userId, status: 'detected' }, orderBy: { detectedAt: 'asc' }, include: { qr: { select: { id: true, title: true, createdAt: true } } }, }); if (!milestone) return NextResponse.json({ milestone: null }); - if (milestone.status === 'detected') { - await db.socialMilestone.update({ where: { id: milestone.id }, data: { status: 'shown', shownAt: new Date() } }); - } const threshold = milestoneThreshold(milestone.kind); if (!threshold) return NextResponse.json({ milestone: null }); const locale = socialLocale(request.nextUrl.searchParams.get('locale')); const shareToken = milestone.shareToken || randomBytes(9).toString('base64url'); - if (!milestone.shareToken) { - await db.socialMilestone.update({ where: { id: milestone.id }, data: { shareToken } }); - } const shareUrl = `${getWwwOrigin()}/s/m/${shareToken}`; const card = await ensureSocialMilestoneCard({ milestoneId: milestone.id, @@ -50,15 +40,21 @@ export async function GET(request: NextRequest) { language: locale, qr: milestone.qr, primaryUseCase: user.primaryUseCase, - refresh: ['detected', 'shown'].includes(milestone.status), + refresh: true, snapshotAt: new Date(), }); + const delivered = await db.socialMilestone.updateMany({ + where: { id: milestone.id, status: 'detected' }, + data: { status: 'shown', shownAt: new Date(), shareToken }, + }); + if (!delivered.count) return NextResponse.json({ milestone: null }); return NextResponse.json({ milestone: { id: milestone.id, qrTitle: milestone.qr.title, threshold, defaultXHandle: user.xHandle, brandStatus: milestone.brandStatus, + promptStatus: 'shown', brandPostUrl: milestone.brandPostUrl, brandPostError: milestone.brandPostError, language: locale, diff --git a/src/components/dashboard/SocialMilestoneDialog.tsx b/src/components/dashboard/SocialMilestoneDialog.tsx index 9d48fdc..071e409 100644 --- a/src/components/dashboard/SocialMilestoneDialog.tsx +++ b/src/components/dashboard/SocialMilestoneDialog.tsx @@ -1,7 +1,7 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; -import { Check, Copy, ExternalLink, LineChart, Linkedin, Send, Sparkles, X } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Check, Copy, ExternalLink, LineChart, Linkedin, QrCode, Send, 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'; @@ -9,8 +9,8 @@ import { useTranslation } from '@/hooks/useTranslation'; import { showToast } from '@/components/ui/Toast'; type Card = { language: 'en' | 'de'; qrTitle: string; label: string; title: string; totalScans?: number; totalUniqueScans: number; milestoneThreshold: number; trend: { points: Array<{ at: string; total: number }>; startLabel: string; endLabel: string; target: number; ceiling: number } | null }; -type Milestone = { id: string; qrTitle: string; threshold: number; defaultXHandle: string | null; preview: string; shareUrl: 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 }; +type Milestone = { id: string; qrTitle: string; threshold: number; defaultXHandle: string | null; preview: string; shareUrl: string; language: 'en' | 'de'; card: Card; promptStatus: string; brandStatus: string; brandPostUrl: string | null; brandPostError: string | null }; +type BrandState = { promptStatus: string; brandStatus: string; brandPostUrl: string | null; brandPostError: string | null; selfSharedAt: string | null }; function Trend({ trend, locale }: { trend: NonNullable; locale: 'en' | 'de' }) { const ceiling = trend.target <= 5 ? 5 : trend.target * 1.25; @@ -64,13 +64,16 @@ export function SocialMilestoneDialog() { const [xHandle, setXHandle] = useState(''); const [saving, setSaving] = useState<'brand' | 'self' | 'copy' | null>(null); const [brand, setBrand] = useState(null); + const loadedMilestone = useRef(false); useEffect(() => { + if (loadedMilestone.current) return; + loadedMilestone.current = true; 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 }); + if (next) setBrand({ promptStatus: next.promptStatus, brandStatus: next.brandStatus, brandPostUrl: next.brandPostUrl, brandPostError: next.brandPostError, selfSharedAt: null }); } }).catch(() => undefined); }, [locale]); @@ -170,25 +173,33 @@ export function SocialMilestoneDialog() { const card = milestone.card; const count = card.totalUniqueScans || milestone.threshold; const status = brand?.brandStatus || milestone.brandStatus || 'pending'; + const promptStatus = brand?.promptStatus || milestone.promptStatus; const canApprove = ['pending', 'failed', 'revoked'].includes(status); - return !open && setMilestone(null)}> - -
{copy.heading}{milestone.qrTitle} {copy.subtitle}
-
-
-
QR MASTERVerified scan milestone
-
UNIQUE SCANS
{count.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}
{milestone.language === 'de' ? 'SCANS INSGESAMT' : 'TOTAL SCANS'}
{(card.totalScans || count).toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}
-
{card.trend ? :
{milestone.language === 'de' ? 'Noch keine Verlaufskurve verfügbar.' : 'No scan timeline available yet.'}
}
- {card.trend &&
{milestone.language === 'de' ? 'Kumulierte eindeutige Scans' : 'Cumulative unique scans'}
} -
{card.qrTitle}
-
-

{copy.consent}

{preview}
- - {withName && setXHandle(event.target.value)} disabled={saving !== null} maxLength={16} pattern="@?[A-Za-z0-9_]{1,15}" 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}

{milestone.language === 'de' ? 'LinkedIn erlaubt kein automatisches Text-Vorausfüllen. Der Button kopiert den fertigen Text und öffnet den Beitrag.' : 'LinkedIn does not allow text prefill. The button copies the finished text and opens the composer.'}

- {status !== 'pending' &&
{status === 'posted' ? copy.posted : status === 'failed' ? `${copy.failed}${brand?.brandPostError ? `: ${brand.brandPostError}` : ''}` : copy.queued}{brand?.brandPostUrl && View}
} + return !open && setMilestone(null)} containerClassName="max-w-[960px]"> + +
+ +
+
{copy.heading}{milestone.qrTitle} {copy.subtitle}
+ +
-
{status === 'pending' && }
+
+
+
QR MASTERVerified scan milestone
+
UNIQUE SCANS
{count.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}
{milestone.language === 'de' ? 'SCANS INSGESAMT' : 'TOTAL SCANS'}
{(card.totalScans || count).toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}
+
{card.trend ? :
{milestone.language === 'de' ? 'Noch keine Verlaufskurve verfügbar.' : 'No scan timeline available yet.'}
}
+ {card.trend &&
{milestone.language === 'de' ? 'Kumulierte eindeutige Scans' : 'Cumulative unique scans'}
} +
{card.qrTitle}
+
+
+

{copy.consent}

{preview}
+
{withName && setXHandle(event.target.value)} disabled={saving !== null} maxLength={16} pattern="@?[A-Za-z0-9_]{1,15}" 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}

{milestone.language === 'de' ? 'LinkedIn öffnet den Editor und kopiert den fertigen Text automatisch.' : 'LinkedIn opens the composer and copies the finished text automatically.'}

+ {status !== 'pending' &&
{status === 'posted' ? copy.posted : status === 'failed' ? `${copy.failed}${brand?.brandPostError ? `: ${brand.brandPostError}` : ''}` : copy.queued}{brand?.brandPostUrl && View}
} +
+
+
{promptStatus === 'shown' && }
; } diff --git a/src/components/ui/Dialog.tsx b/src/components/ui/Dialog.tsx index a23f659..c95b772 100644 --- a/src/components/ui/Dialog.tsx +++ b/src/components/ui/Dialog.tsx @@ -1,13 +1,14 @@ import React from 'react'; import { cn } from '@/lib/utils'; -interface DialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - children: React.ReactNode; -} - -export const Dialog: React.FC = ({ open, onOpenChange, children }) => { +interface DialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + children: React.ReactNode; + containerClassName?: string; +} + +export const Dialog: React.FC = ({ open, onOpenChange, children, containerClassName }) => { if (!open) return null; return ( @@ -16,7 +17,7 @@ export const Dialog: React.FC = ({ open, onOpenChange, children }) className="fixed inset-0 bg-black/50" onClick={() => onOpenChange(false)} /> -
+
{children}
@@ -89,4 +90,4 @@ export const DialogFooter = React.forwardRef( /> ) ); -DialogFooter.displayName = 'DialogFooter'; \ No newline at end of file +DialogFooter.displayName = 'DialogFooter'; diff --git a/src/lib/social-milestone-image.tsx b/src/lib/social-milestone-image.tsx index b47a391..b5da71f 100644 --- a/src/lib/social-milestone-image.tsx +++ b/src/lib/social-milestone-image.tsx @@ -30,9 +30,9 @@ function chart(card: SocialMilestoneImageCard, german: boolean) { 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 = 18; - const plotBottom = 238; + const plotRight = 620; + const plotTop = 12; + const plotBottom = 218; 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); @@ -44,20 +44,20 @@ function chart(card: SocialMilestoneImageCard, german: boolean) { // Satori cannot render SVG nodes in the deployed Node runtime. SVG // draws geometry only; the aligned labels are ordinary positioned text. - return
+ return
{ticks.map(tick => { const y = plotBottom - tick / ceiling * (plotBottom - plotTop); const reached = tick === target; - return
-
{number.format(tick)}
-
+ return
+
{number.format(tick)}
+
; })} - + {points && } -
+
{trend.startLabel || (german ? 'Erstellt' : 'Created')} {trend.endLabel || (german ? 'Erreicht' : 'Reached')}
@@ -69,23 +69,25 @@ export function createSocialMilestoneImage(card: SocialMilestoneImageCard, germa 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'; + const uniqueText = unique.toLocaleString(locale); + const uniqueFontSize = uniqueText.length >= 9 ? 78 : uniqueText.length >= 6 ? 96 : uniqueText.length >= 4 ? 108 : 124; return new ImageResponse( -
-
-
+
+
+
QR MASTER {german ? 'Verifizierter Scan-Meilenstein' : 'Verified scan milestone'}
-
-
+
+
UNIQUE SCANS - {unique.toLocaleString(locale)} + {uniqueText} {total.toLocaleString(locale)} {german ? 'Scans insgesamt' : 'total scans'}
{chart(card, german)}
-
{card.qrTitle || (german ? 'QR-Code' : 'QR code')}
+
{card.qrTitle || (german ? 'QR-Code' : 'QR code')}
, { width: 1200, height: 630, headers: { 'Cache-Control': 'no-store' } },