Fix milestone sharing previews and publisher recovery
This commit is contained in:
@@ -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 <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 } },
|
||||
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(<div style={{ height: '100%', width: '100%', display: 'flex', background: '#f8fafc', padding: 46, color: '#061b31' }}><div style={{ display: 'flex', flexDirection: 'column', width: '100%', border: '2px solid #e2e8f0', borderRadius: 24, background: 'white', padding: 44 }}><div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 23, fontWeight: 700 }}><span>QR MASTER</span><span style={{ color: '#059669', background: '#ecfdf5', padding: '8px 14px', borderRadius: 8 }}>Verified milestone</span></div><div style={{ display: 'flex', flexDirection: 'column', marginTop: 58 }}><span style={{ fontSize: 20, color: '#94a3b8', fontWeight: 700 }}>TOTAL UNIQUE SCANS</span><span style={{ fontSize: 116, fontWeight: 700, letterSpacing: -5 }}>{(card?.totalUniqueScans || 0).toLocaleString(german ? 'de-DE' : 'en-US')}</span><span style={{ fontSize: 29, color: '#64748b' }}>{german ? 'eindeutige Scans' : 'unique scans'}</span></div><div style={{ display: 'flex', marginTop: 'auto', paddingTop: 28, borderTop: '2px solid #e2e8f0', justifyContent: 'space-between', fontSize: 25 }}><span>{card?.qrTitle || 'QR code'}</span><span style={{ color: '#0256ff' }}>{card?.trend ? `${card.trend.recentTotal} in ${card.trend.periodDays} days` : (german ? 'Erste Dynamik' : 'Early momentum')}</span></div></div></div>, { 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(
|
||||
<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' } },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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 <main className="min-h-screen bg-slate-50 px-6 py-20 text-center text-[#061b31]"><div className="mx-auto max-w-xl rounded-xl border border-slate-200 bg-white p-10 shadow-[0_30px_45px_-30px_rgba(50,50,93,0.35)]"><p className="text-xs font-semibold tracking-[0.14em] text-[#0256ff]">QR MASTER · VERIFIED MILESTONE</p><h1 className="mt-5 text-5xl font-semibold tracking-[-0.05em] tabular-nums">{(card?.totalUniqueScans || 0).toLocaleString(share.language === 'de' ? 'de-DE' : 'en-US')}</h1><p className="mt-2 text-slate-500">{share.language === 'de' ? 'eindeutige Scans' : 'unique scans'}</p><p className="mt-8 text-lg font-medium">{card?.qrTitle}</p></div></main>;
|
||||
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 <main className="min-h-screen bg-[#f8f7f4] px-4 py-12 text-center text-[#061b31] sm:px-6 sm:py-20"><div className="mx-auto max-w-5xl"><img src={imageUrl} alt={alt} width={1200} height={630} className="h-auto w-full shadow-[0_30px_45px_-30px_rgba(50,50,93,0.35)]" /><p className="mt-6 text-sm text-slate-500">{share.language === 'de' ? 'Verifizierter Scan-Meilenstein von QR Master' : 'Verified scan milestone from QR Master'}</p></div></main>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user