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
-
-
{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