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 74d01dc..a7d9d0a 100644 --- a/src/app/(main)/(marketing)/s/m/[token]/og/route.tsx +++ b/src/app/(main)/(marketing)/s/m/[token]/og/route.tsx @@ -1,67 +1,9 @@ -import { ImageResponse } from 'next/og'; import { db } from '@/lib/db'; +import { createSocialMilestoneImage } from '@/lib/social-milestone-image'; +import type { SocialMilestoneImageCard } from '@/lib/social-milestone-image'; 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 } }, @@ -69,30 +11,8 @@ export async function GET(_request: Request, { params }: { params: { token: stri }); if (!share) return new Response('Not found', { status: 404, headers: { 'Cache-Control': 'no-store' } }); - const card = (share.cardData || {}) as Card; - const german = share.language === 'de'; - 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' } }, + return createSocialMilestoneImage( + (share.cardData || {}) as SocialMilestoneImageCard, + share.language === 'de', ); } diff --git a/src/app/(main)/api/internal/social-milestones/route.ts b/src/app/(main)/api/internal/social-milestones/route.ts index 3994644..ae9c488 100644 --- a/src/app/(main)/api/internal/social-milestones/route.ts +++ b/src/app/(main)/api/internal/social-milestones/route.ts @@ -42,7 +42,13 @@ export async function GET(request: NextRequest) { if (dryRun) return NextResponse.json({ milestone: { id: milestone.id, text: milestone.consentText, shareUrl }, dryRun: true }); const claimed = await db.$transaction(async (tx) => { - await tx.$queryRawUnsafe('SELECT pg_advisory_xact_lock(920241)'); + // The blocking advisory-lock function returns PostgreSQL `void`, which + // Prisma cannot deserialize. The try variant returns a real boolean and + // keeps the lock scoped to this transaction. + const [lock] = await tx.$queryRaw>` + SELECT pg_try_advisory_xact_lock(920241) AS acquired + `; + if (!lock?.acquired) return 0; const result = await tx.socialMilestone.updateMany({ where: { id: milestone.id, brandStatus: 'approved' }, data: { brandStatus: 'processing', claimedAt: new Date() }, }); diff --git a/src/components/dashboard/SocialMilestoneDialog.tsx b/src/components/dashboard/SocialMilestoneDialog.tsx index 67d282b..9d48fdc 100644 --- a/src/components/dashboard/SocialMilestoneDialog.tsx +++ b/src/components/dashboard/SocialMilestoneDialog.tsx @@ -20,24 +20,42 @@ function Trend({ trend, locale }: { trend: NonNullable; locale: ' const first = new Date(trend.points[0].at).getTime(); const last = Math.max(new Date(trend.points[trend.points.length - 1].at).getTime(), first + 1); const points = trend.points.map(point => { - const x = 52 + ((new Date(point.at).getTime() - first) / (last - first)) * 356; - const y = 104 - (point.total / ceiling) * 88; + const x = 60 + ((new Date(point.at).getTime() - first) / (last - first)) * 410; + const y = 142 - (point.total / ceiling) * 126; return `${x},${y}`; }).join(' '); - const targetY = 104 - (trend.target / ceiling) * 88; + const targetY = 142 - (trend.target / ceiling) * 126; const number = new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { maximumFractionDigits: 2 }); - return + return {ticks.map(tick => { - const y = 104 - (tick / ceiling) * 88; - return {number.format(tick)}; + const y = 142 - (tick / ceiling) * 126; + return {number.format(tick)}; })} - - - {trend.startLabel} - {trend.endLabel} + + + {trend.startLabel} + {trend.endLabel} ; } +async function copyShareText(text: string) { + try { + await navigator.clipboard.writeText(text); + return; + } catch { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + const copied = document.execCommand('copy'); + textarea.remove(); + if (!copied) throw new Error('Copying is blocked by this browser'); + } +} + export function SocialMilestoneDialog() { const { fetchWithCsrf } = useCsrf(); const { locale } = useTranslation(); @@ -92,6 +110,12 @@ export function SocialMilestoneDialog() { }; const shareSelf = async (network: 'x' | 'linkedin') => { if (!milestone) return; + // LinkedIn's public share dialog accepts only a URL. Start copying the + // prepared commentary while this click still owns browser focus, then + // open the LinkedIn share dialog after public-share consent is persisted. + const linkedinCopy = network === 'linkedin' + ? copyShareText(postCopy).then(() => true).catch(() => false) + : Promise.resolve(true); // Open synchronously from the user gesture. Awaiting the API first can make // LinkedIn treat the new window as a blocked popup. const shareWindow = window.open('about:blank', '_blank'); @@ -99,12 +123,17 @@ export function SocialMilestoneDialog() { setSaving('self'); try { const { shareUrl, text } = await prepareSelfShare(); + const copied = await linkedinCopy; const targetUrl = network === 'x' ? `https://x.com/intent/post?text=${encodeURIComponent(text)}` : `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`; if (shareWindow) shareWindow.location.href = targetUrl; else window.location.assign(targetUrl); - showToast(network === 'linkedin' ? 'LinkedIn share window opened.' : 'X share composer opened.', 'success'); + showToast(network === 'linkedin' + ? copied + ? (milestone.language === 'de' ? 'LinkedIn-Text kopiert. Jetzt nur noch einfügen.' : 'LinkedIn text copied. Paste it into the composer.') + : (milestone.language === 'de' ? 'LinkedIn ist geöffnet. Bitte nutze „Text kopieren“ im QR-Master-Fenster.' : 'LinkedIn is open. Please use “Copy text” in the QR Master window.') + : 'X share composer opened.', copied ? 'success' : 'error'); } catch (error) { shareWindow?.close(); showToast(error instanceof Error ? error.message : 'Could not prepare this share', 'error'); } finally { setSaving(null); } }; @@ -112,20 +141,7 @@ export function SocialMilestoneDialog() { setSaving('copy'); try { const { text } = await prepareSelfShare(); - try { - await navigator.clipboard.writeText(text); - } catch { - const textarea = document.createElement('textarea'); - textarea.value = text; - textarea.style.position = 'fixed'; - textarea.style.opacity = '0'; - document.body.appendChild(textarea); - textarea.focus(); - textarea.select(); - const copied = document.execCommand('copy'); - textarea.remove(); - if (!copied) throw new Error('Copying is blocked by this browser'); - } + await copyShareText(text); showToast(milestone?.language === 'de' ? 'LinkedIn-Text kopiert.' : 'LinkedIn post text copied.', 'success'); } catch (error) { showToast(error instanceof Error ? error.message : 'Could not copy the LinkedIn text', 'error'); @@ -156,19 +172,20 @@ export function SocialMilestoneDialog() { const status = brand?.brandStatus || milestone.brandStatus || 'pending'; 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')}
{(card.totalScans || count).toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')} {milestone.language === 'de' ? 'Scans insgesamt' : 'total scans'}
{card.trend ? :
{milestone.language === 'de' ? 'Noch keine Verlaufskurve verfügbar.' : 'No scan timeline available yet.'}
}
+
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' ? 'Für LinkedIn zuerst den Text kopieren, dann LinkedIn öffnen und einfügen.' : 'For LinkedIn, copy the post text first, then open LinkedIn and paste it.'}

+
{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}
}
{status === 'pending' && }
diff --git a/src/lib/social-milestone-image.tsx b/src/lib/social-milestone-image.tsx new file mode 100644 index 0000000..b47a391 --- /dev/null +++ b/src/lib/social-milestone-image.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { ImageResponse } from 'next/og'; + +type Trend = { + points: Array<{ at: string; total: number }>; + startLabel: string; + endLabel: string; + target: number; +}; + +export type SocialMilestoneImageCard = { + qrTitle?: string; + totalScans?: number; + totalUniqueScans?: number; + milestoneThreshold?: number; + trend?: Trend | null; +}; + +function chart(card: SocialMilestoneImageCard, 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 = 18; + const plotBottom = 238; + 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 }); + const endPoint = points.split(' ').at(-1)?.split(',').map(Number) || [plotRight, plotBottom]; + + // Satori cannot render SVG nodes in the deployed Node runtime. SVG + // draws geometry only; the aligned labels are ordinary positioned text. + 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 function createSocialMilestoneImage(card: SocialMilestoneImageCard, german: boolean) { + 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' } }, + ); +}