Fix milestone publisher and social previews

This commit is contained in:
2026-08-14 19:35:29 +02:00
parent 4ec70ed30f
commit 13879e3d3a
4 changed files with 150 additions and 114 deletions

View File

@@ -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 <div style={{ display: 'flex', flexDirection: 'column', width: 570 }}>
<svg width="570" height="270" viewBox="0 0 570 270">
{ticks.map(tick => {
const y = plotBottom - tick / ceiling * (plotBottom - plotTop);
const reached = tick === target;
return <g key={tick}>
<text x="58" y={y + 5} textAnchor="end" fontSize="16" fontWeight={reached ? 700 : 500} fill={reached ? '#0256ff' : '#64748b'}>{number.format(tick)}</text>
<line x1={plotLeft} x2={plotRight} y1={y} y2={y} stroke={reached ? '#bfdbfe' : '#e2e8f0'} strokeWidth={reached ? 2 : 1} />
</g>;
})}
<polyline points={points} fill="none" stroke="#0256ff" strokeWidth="5" strokeLinejoin="round" strokeLinecap="round" />
{points && <circle cx={Number(points.split(' ').at(-1)?.split(',')[0])} cy={Number(points.split(' ').at(-1)?.split(',')[1])} r="7" fill="white" stroke="#0256ff" strokeWidth="4" />}
<text x={plotLeft} y="263" fontSize="16" fontWeight="600" fill="#45617f">{trend.startLabel || (german ? 'Erstellt' : 'Created')}</text>
<text x={plotRight} y="263" textAnchor="end" fontSize="16" fontWeight="600" fill="#45617f">{trend.endLabel || (german ? 'Erreicht' : 'Reached')}</text>
</svg>
<div style={{ display: 'flex', justifyContent: 'flex-end', color: '#45617f', fontSize: 16, marginTop: 8 }}>{german ? 'Kumulierte eindeutige Scans' : 'Cumulative unique scans'}</div>
</div>;
}
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(
<div style={{ height: '100%', width: '100%', display: 'flex', background: '#f8f7f4', padding: 48, color: '#061b31' }}>
<div style={{ display: 'flex', flexDirection: 'column', width: '100%', background: 'white', padding: 38, boxShadow: '0 24px 50px rgba(50,50,93,.16)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderBottom: '1px solid #e5edf5', paddingBottom: 22 }}>
<span style={{ fontSize: 22, fontWeight: 700 }}>QR MASTER</span>
<span style={{ color: '#108c3d', background: '#eafaf0', padding: '8px 13px', borderRadius: 6, fontSize: 18 }}> {german ? 'Verifizierter Scan-Meilenstein' : 'Verified scan milestone'}</span>
</div>
<div style={{ display: 'flex', flex: 1, alignItems: 'center', gap: 40, paddingTop: 24 }}>
<div style={{ display: 'flex', flexDirection: 'column', width: 430 }}>
<span style={{ fontSize: 17, color: '#64748b', letterSpacing: 1.4 }}>UNIQUE SCANS</span>
<span style={{ fontSize: 116, fontWeight: 400, letterSpacing: -5 }}>{unique.toLocaleString(locale)}</span>
<span style={{ fontSize: 22, color: '#45617f' }}><b>{total.toLocaleString(locale)}</b> {german ? 'Scans insgesamt' : 'total scans'}</span>
</div>
{chart(card, german)}
</div>
<div style={{ display: 'flex', borderTop: '1px solid #e5edf5', paddingTop: 20, fontSize: 23, fontWeight: 600 }}>{card.qrTitle || (german ? 'QR-Code' : 'QR code')}</div>
</div>
</div>,
{ width: 1200, height: 630, headers: { 'Cache-Control': 'no-store' } },
return createSocialMilestoneImage(
(share.cardData || {}) as SocialMilestoneImageCard,
share.language === 'de',
);
}