Fix milestone publisher and social previews
This commit is contained in:
@@ -1,67 +1,9 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import { createSocialMilestoneImage } from '@/lib/social-milestone-image';
|
||||||
|
import type { SocialMilestoneImageCard } from '@/lib/social-milestone-image';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
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 } }) {
|
export async function GET(_request: Request, { params }: { params: { token: string } }) {
|
||||||
const share = await db.socialMilestone.findFirst({
|
const share = await db.socialMilestone.findFirst({
|
||||||
where: { shareToken: params.token, publicShareApprovedAt: { not: null } },
|
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' } });
|
if (!share) return new Response('Not found', { status: 404, headers: { 'Cache-Control': 'no-store' } });
|
||||||
|
|
||||||
const card = (share.cardData || {}) as Card;
|
return createSocialMilestoneImage(
|
||||||
const german = share.language === 'de';
|
(share.cardData || {}) as SocialMilestoneImageCard,
|
||||||
const unique = Math.max(0, Number(card.totalUniqueScans || card.milestoneThreshold || 0));
|
share.language === 'de',
|
||||||
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' } },
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
if (dryRun) return NextResponse.json({ milestone: { id: milestone.id, text: milestone.consentText, shareUrl }, dryRun: true });
|
||||||
|
|
||||||
const claimed = await db.$transaction(async (tx) => {
|
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<Array<{ acquired: boolean }>>`
|
||||||
|
SELECT pg_try_advisory_xact_lock(920241) AS acquired
|
||||||
|
`;
|
||||||
|
if (!lock?.acquired) return 0;
|
||||||
const result = await tx.socialMilestone.updateMany({
|
const result = await tx.socialMilestone.updateMany({
|
||||||
where: { id: milestone.id, brandStatus: 'approved' }, data: { brandStatus: 'processing', claimedAt: new Date() },
|
where: { id: milestone.id, brandStatus: 'approved' }, data: { brandStatus: 'processing', claimedAt: new Date() },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,24 +20,42 @@ function Trend({ trend, locale }: { trend: NonNullable<Card['trend']>; locale: '
|
|||||||
const first = new Date(trend.points[0].at).getTime();
|
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 last = Math.max(new Date(trend.points[trend.points.length - 1].at).getTime(), first + 1);
|
||||||
const points = trend.points.map(point => {
|
const points = trend.points.map(point => {
|
||||||
const x = 52 + ((new Date(point.at).getTime() - first) / (last - first)) * 356;
|
const x = 60 + ((new Date(point.at).getTime() - first) / (last - first)) * 410;
|
||||||
const y = 104 - (point.total / ceiling) * 88;
|
const y = 142 - (point.total / ceiling) * 126;
|
||||||
return `${x},${y}`;
|
return `${x},${y}`;
|
||||||
}).join(' ');
|
}).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 });
|
const number = new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { maximumFractionDigits: 2 });
|
||||||
return <svg viewBox="0 0 420 132" className="w-full" role="img" aria-label="Cumulative unique scan trend">
|
return <svg viewBox="0 0 480 184" className="w-full" role="img" aria-label="Cumulative unique scan trend">
|
||||||
{ticks.map(tick => {
|
{ticks.map(tick => {
|
||||||
const y = 104 - (tick / ceiling) * 88;
|
const y = 142 - (tick / ceiling) * 126;
|
||||||
return <g key={tick}><text x="42" y={y + 3} textAnchor="end" fontSize="9" fontWeight={tick === trend.target ? 700 : 500} fill={tick === trend.target ? '#0256ff' : '#64748b'}>{number.format(tick)}</text><line x1="52" x2="408" y1={y} y2={y} stroke={tick === trend.target ? '#bfdbfe' : '#e2e8f0'} strokeWidth={tick === trend.target ? 1.4 : 0.8} /></g>;
|
return <g key={tick}><text x="48" y={y + 4} textAnchor="end" fontSize="11" fontWeight={tick === trend.target ? 700 : 500} fill={tick === trend.target ? '#0256ff' : '#64748b'}>{number.format(tick)}</text><line x1="60" x2="470" y1={y} y2={y} stroke={tick === trend.target ? '#bfdbfe' : '#e2e8f0'} strokeWidth={tick === trend.target ? 1.6 : 1} /></g>;
|
||||||
})}
|
})}
|
||||||
<polyline points={points} fill="none" stroke="#0256ff" strokeWidth="3" strokeLinejoin="round" strokeLinecap="round" />
|
<polyline points={points} fill="none" stroke="#0256ff" strokeWidth="3.5" strokeLinejoin="round" strokeLinecap="round" />
|
||||||
<circle cx="408" cy={targetY} r="3.5" fill="#ffffff" stroke="#0256ff" strokeWidth="2.5" />
|
<circle cx="470" cy={targetY} r="4.5" fill="#ffffff" stroke="#0256ff" strokeWidth="3" />
|
||||||
<text x="52" y="126" fontSize="9" fontWeight="600" fill="#45617f">{trend.startLabel}</text>
|
<text x="60" y="176" fontSize="11" fontWeight="600" fill="#45617f">{trend.startLabel}</text>
|
||||||
<text x="408" y="126" textAnchor="end" fontSize="9" fontWeight="600" fill="#45617f">{trend.endLabel}</text>
|
<text x="470" y="176" textAnchor="end" fontSize="11" fontWeight="600" fill="#45617f">{trend.endLabel}</text>
|
||||||
</svg>;
|
</svg>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
export function SocialMilestoneDialog() {
|
||||||
const { fetchWithCsrf } = useCsrf();
|
const { fetchWithCsrf } = useCsrf();
|
||||||
const { locale } = useTranslation();
|
const { locale } = useTranslation();
|
||||||
@@ -92,6 +110,12 @@ export function SocialMilestoneDialog() {
|
|||||||
};
|
};
|
||||||
const shareSelf = async (network: 'x' | 'linkedin') => {
|
const shareSelf = async (network: 'x' | 'linkedin') => {
|
||||||
if (!milestone) return;
|
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
|
// Open synchronously from the user gesture. Awaiting the API first can make
|
||||||
// LinkedIn treat the new window as a blocked popup.
|
// LinkedIn treat the new window as a blocked popup.
|
||||||
const shareWindow = window.open('about:blank', '_blank');
|
const shareWindow = window.open('about:blank', '_blank');
|
||||||
@@ -99,12 +123,17 @@ export function SocialMilestoneDialog() {
|
|||||||
setSaving('self');
|
setSaving('self');
|
||||||
try {
|
try {
|
||||||
const { shareUrl, text } = await prepareSelfShare();
|
const { shareUrl, text } = await prepareSelfShare();
|
||||||
|
const copied = await linkedinCopy;
|
||||||
const targetUrl = network === 'x'
|
const targetUrl = network === 'x'
|
||||||
? `https://x.com/intent/post?text=${encodeURIComponent(text)}`
|
? `https://x.com/intent/post?text=${encodeURIComponent(text)}`
|
||||||
: `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`;
|
: `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`;
|
||||||
if (shareWindow) shareWindow.location.href = targetUrl;
|
if (shareWindow) shareWindow.location.href = targetUrl;
|
||||||
else window.location.assign(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'); }
|
} catch (error) { shareWindow?.close(); showToast(error instanceof Error ? error.message : 'Could not prepare this share', 'error'); }
|
||||||
finally { setSaving(null); }
|
finally { setSaving(null); }
|
||||||
};
|
};
|
||||||
@@ -112,20 +141,7 @@ export function SocialMilestoneDialog() {
|
|||||||
setSaving('copy');
|
setSaving('copy');
|
||||||
try {
|
try {
|
||||||
const { text } = await prepareSelfShare();
|
const { text } = await prepareSelfShare();
|
||||||
try {
|
await copyShareText(text);
|
||||||
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');
|
|
||||||
}
|
|
||||||
showToast(milestone?.language === 'de' ? 'LinkedIn-Text kopiert.' : 'LinkedIn post text copied.', 'success');
|
showToast(milestone?.language === 'de' ? 'LinkedIn-Text kopiert.' : 'LinkedIn post text copied.', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error instanceof Error ? error.message : 'Could not copy the LinkedIn text', '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 status = brand?.brandStatus || milestone.brandStatus || 'pending';
|
||||||
const canApprove = ['pending', 'failed', 'revoked'].includes(status);
|
const canApprove = ['pending', 'failed', 'revoked'].includes(status);
|
||||||
return <Dialog open onOpenChange={open => !open && setMilestone(null)}>
|
return <Dialog open onOpenChange={open => !open && setMilestone(null)}>
|
||||||
<DialogContent className="flex max-h-[calc(100dvh-2rem)] max-w-xl flex-col overflow-hidden border-slate-200 p-0 shadow-[0_30px_45px_-30px_rgba(50,50,93,0.45)]">
|
<DialogContent className="flex max-h-[calc(100dvh-2rem)] max-w-2xl flex-col overflow-hidden border-slate-200 p-0 shadow-[0_30px_45px_-30px_rgba(50,50,93,0.45)]">
|
||||||
<div className="shrink-0 px-6 pb-4 pt-5"><DialogHeader><div className="mb-3 flex h-9 w-9 items-center justify-center rounded-lg bg-[#eaf1ff] text-[#0256ff]"><Sparkles className="h-4 w-4" /></div><DialogTitle className="text-2xl font-semibold tracking-[-0.03em] text-[#061b31]">{copy.heading}</DialogTitle><DialogDescription className="pt-1 text-sm leading-6 text-[#4b5e76]"><strong className="font-medium text-[#061b31]">{milestone.qrTitle}</strong> {copy.subtitle}</DialogDescription></DialogHeader></div>
|
<div className="shrink-0 px-6 pb-4 pt-5"><DialogHeader><div className="mb-3 flex h-9 w-9 items-center justify-center rounded-lg bg-[#eaf1ff] text-[#0256ff]"><Sparkles className="h-4 w-4" /></div><DialogTitle className="text-2xl font-semibold tracking-[-0.03em] text-[#061b31]">{copy.heading}</DialogTitle><DialogDescription className="pt-1 text-sm leading-6 text-[#4b5e76]"><strong className="font-medium text-[#061b31]">{milestone.qrTitle}</strong> {copy.subtitle}</DialogDescription></DialogHeader></div>
|
||||||
<div className="min-h-0 space-y-4 overflow-y-auto border-y border-slate-100 px-6 py-5 overscroll-contain">
|
<div className="min-h-0 space-y-4 overflow-y-auto border-y border-slate-100 px-6 py-5 overscroll-contain">
|
||||||
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-[0_14px_28px_-22px_rgba(50,50,93,0.4)]">
|
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-[0_14px_28px_-22px_rgba(50,50,93,0.4)]">
|
||||||
<div className="flex items-center justify-between border-b border-slate-100 pb-4 text-xs"><span className="flex items-center gap-2 font-semibold tracking-wide text-[#061b31]"><img src="/favicon.ico" alt="" className="h-5 w-5" />QR MASTER</span><span className="rounded bg-emerald-50 px-2 py-1 font-medium text-emerald-700"><Check className="mr-1 inline h-3 w-3" />Verified scan milestone</span></div>
|
<div className="flex items-center justify-between border-b border-slate-100 pb-4 text-xs"><span className="flex items-center gap-2 font-semibold tracking-wide text-[#061b31]"><img src="/favicon.ico" alt="" className="h-5 w-5" />QR MASTER</span><span className="rounded bg-emerald-50 px-2 py-1 font-medium text-emerald-700"><Check className="mr-1 inline h-3 w-3" />Verified scan milestone</span></div>
|
||||||
<div className="mt-5 grid grid-cols-[0.72fr_1.28fr] items-end gap-4"><div><div className="text-[10px] font-semibold tracking-[0.1em] text-slate-400">UNIQUE SCANS</div><div className="mt-1 text-5xl font-normal tracking-[-0.04em] tabular-nums text-[#061b31]">{count.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}</div><div className="mt-2 text-xs text-[#4b5e76]"><span className="font-semibold tabular-nums text-[#061b31]">{(card.totalScans || count).toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}</span> {milestone.language === 'de' ? 'Scans insgesamt' : 'total scans'}</div></div>{card.trend ? <Trend trend={card.trend} locale={milestone.language} /> : <div className="text-xs text-[#45617f]">{milestone.language === 'de' ? 'Noch keine Verlaufskurve verfügbar.' : 'No scan timeline available yet.'}</div>}</div>
|
<div className="mt-5 flex items-end justify-between gap-6"><div><div className="text-[10px] font-semibold tracking-[0.1em] text-slate-400">UNIQUE SCANS</div><div className="mt-1 text-5xl font-normal tracking-[-0.04em] tabular-nums text-[#061b31]">{count.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}</div></div><div className="pb-1 text-right"><div className="text-[10px] font-semibold tracking-[0.1em] text-slate-400">{milestone.language === 'de' ? 'SCANS INSGESAMT' : 'TOTAL SCANS'}</div><div className="mt-1 text-2xl font-normal tabular-nums text-[#061b31]">{(card.totalScans || count).toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}</div></div></div>
|
||||||
|
<div className="mt-4 border-t border-slate-100 pt-4">{card.trend ? <Trend trend={card.trend} locale={milestone.language} /> : <div className="py-8 text-center text-xs text-[#45617f]">{milestone.language === 'de' ? 'Noch keine Verlaufskurve verfügbar.' : 'No scan timeline available yet.'}</div>}</div>
|
||||||
{card.trend && <div className="mt-3 flex items-center justify-end gap-2 text-[11px] text-[#45617f]"><LineChart className="h-3.5 w-3.5 text-[#0256ff]" />{milestone.language === 'de' ? 'Kumulierte eindeutige Scans' : 'Cumulative unique scans'}</div>}
|
{card.trend && <div className="mt-3 flex items-center justify-end gap-2 text-[11px] text-[#45617f]"><LineChart className="h-3.5 w-3.5 text-[#0256ff]" />{milestone.language === 'de' ? 'Kumulierte eindeutige Scans' : 'Cumulative unique scans'}</div>}
|
||||||
<div className="mt-5 border-t border-slate-100 pt-3 text-sm font-medium text-[#061b31]">{card.qrTitle}</div>
|
<div className="mt-5 border-t border-slate-100 pt-3 text-sm font-medium text-[#061b31]">{card.qrTitle}</div>
|
||||||
</section>
|
</section>
|
||||||
<div><p className="text-sm leading-6 text-[#4b5e76]">{copy.consent}</p><blockquote className="mt-3 whitespace-pre-line border-l border-[#0256ff] pl-3 text-sm leading-6 text-[#273951]">{preview}</blockquote></div>
|
<div><p className="text-sm leading-6 text-[#4b5e76]">{copy.consent}</p><blockquote className="mt-3 whitespace-pre-line border-l border-[#0256ff] pl-3 text-sm leading-6 text-[#273951]">{preview}</blockquote></div>
|
||||||
<label className="flex cursor-pointer items-center gap-3 text-sm font-medium text-slate-700"><input type="checkbox" checked={withName} onChange={event => setWithName(event.target.checked)} disabled={saving !== null} className="h-4 w-4 rounded border-slate-300 text-[#0256ff] focus:ring-[#0256ff]" />{copy.name}</label>
|
<label className="flex cursor-pointer items-center gap-3 text-sm font-medium text-slate-700"><input type="checkbox" checked={withName} onChange={event => setWithName(event.target.checked)} disabled={saving !== null} className="h-4 w-4 rounded border-slate-300 text-[#0256ff] focus:ring-[#0256ff]" />{copy.name}</label>
|
||||||
{withName && <input aria-label="X handle" value={xHandle} onChange={event => 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" />}
|
{withName && <input aria-label="X handle" value={xHandle} onChange={event => 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" />}
|
||||||
<div className="space-y-2"><div className="flex flex-wrap items-center gap-2"><span className="mr-1 text-xs font-medium text-slate-500">{copy.self}</span><Button variant="outline" size="sm" onClick={() => shareSelf('x')} disabled={saving !== null}><X className="mr-1.5 h-3.5 w-3.5" />X</Button><Button variant="outline" size="sm" onClick={copyLinkedInText} disabled={saving !== null}><Copy className="mr-1.5 h-3.5 w-3.5" />{milestone.language === 'de' ? 'Text kopieren' : 'Copy text'}</Button><Button variant="outline" size="sm" onClick={() => shareSelf('linkedin')} disabled={saving !== null}><Linkedin className="mr-1.5 h-3.5 w-3.5" />LinkedIn</Button></div><p className="text-[11px] leading-4 text-slate-500">{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.'}</p></div>
|
<div className="space-y-2"><div className="flex flex-wrap items-center gap-2"><span className="mr-1 text-xs font-medium text-slate-500">{copy.self}</span><Button variant="outline" size="sm" onClick={() => shareSelf('x')} disabled={saving !== null}><X className="mr-1.5 h-3.5 w-3.5" />X</Button><Button variant="outline" size="sm" onClick={copyLinkedInText} disabled={saving !== null}><Copy className="mr-1.5 h-3.5 w-3.5" />{milestone.language === 'de' ? 'Text kopieren' : 'Copy text'}</Button><Button variant="outline" size="sm" onClick={() => shareSelf('linkedin')} disabled={saving !== null}><Linkedin className="mr-1.5 h-3.5 w-3.5" />{milestone.language === 'de' ? 'Kopieren & LinkedIn öffnen' : 'Copy & open LinkedIn'}</Button></div><p className="text-[11px] leading-4 text-slate-500">{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.'}</p></div>
|
||||||
{status !== 'pending' && <div className={`flex items-start justify-between gap-3 rounded-md px-3 py-2 text-sm ${status === 'posted' ? 'bg-emerald-50 text-emerald-800' : status === 'failed' ? 'bg-rose-50 text-rose-800' : 'bg-blue-50 text-blue-800'}`}><span>{status === 'posted' ? copy.posted : status === 'failed' ? `${copy.failed}${brand?.brandPostError ? `: ${brand.brandPostError}` : ''}` : copy.queued}</span>{brand?.brandPostUrl && <a href={brand.brandPostUrl} target="_blank" rel="noreferrer" className="inline-flex shrink-0 items-center gap-1 font-medium underline"><ExternalLink className="h-3.5 w-3.5" />View</a>}</div>}
|
{status !== 'pending' && <div className={`flex items-start justify-between gap-3 rounded-md px-3 py-2 text-sm ${status === 'posted' ? 'bg-emerald-50 text-emerald-800' : status === 'failed' ? 'bg-rose-50 text-rose-800' : 'bg-blue-50 text-blue-800'}`}><span>{status === 'posted' ? copy.posted : status === 'failed' ? `${copy.failed}${brand?.brandPostError ? `: ${brand.brandPostError}` : ''}` : copy.queued}</span>{brand?.brandPostUrl && <a href={brand.brandPostUrl} target="_blank" rel="noreferrer" className="inline-flex shrink-0 items-center gap-1 font-medium underline"><ExternalLink className="h-3.5 w-3.5" />View</a>}</div>}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="shrink-0 bg-slate-50 px-6 py-4"><div className="flex w-full flex-wrap items-center justify-end gap-2"><Button variant="outline" onClick={() => status === 'pending' ? dismiss('decline') : setMilestone(null)} disabled={saving !== null}>{copy.decline}</Button><Button variant="primary" onClick={approveBrand} disabled={saving !== null || !canApprove}><Send className="mr-1.5 h-4 w-4" />{status === 'approved' || status === 'processing' ? copy.queued : status === 'failed' ? (milestone.language === 'de' ? 'Erneut versuchen' : 'Retry post') : copy.approve}</Button>{status === 'pending' && <button type="button" className="w-full pt-1 text-xs text-slate-500 underline underline-offset-2 hover:text-slate-700" onClick={optOut} disabled={saving !== null}>{milestone.language === 'de' ? 'Nicht mehr anzeigen' : 'Do not show again'}</button>}</div></DialogFooter>
|
<DialogFooter className="shrink-0 bg-slate-50 px-6 py-4"><div className="flex w-full flex-wrap items-center justify-end gap-2"><Button variant="outline" onClick={() => status === 'pending' ? dismiss('decline') : setMilestone(null)} disabled={saving !== null}>{copy.decline}</Button><Button variant="primary" onClick={approveBrand} disabled={saving !== null || !canApprove}><Send className="mr-1.5 h-4 w-4" />{status === 'approved' || status === 'processing' ? copy.queued : status === 'failed' ? (milestone.language === 'de' ? 'Erneut versuchen' : 'Retry post') : copy.approve}</Button>{status === 'pending' && <button type="button" className="w-full pt-1 text-xs text-slate-500 underline underline-offset-2 hover:text-slate-700" onClick={optOut} disabled={saving !== null}>{milestone.language === 'de' ? 'Nicht mehr anzeigen' : 'Do not show again'}</button>}</div></DialogFooter>
|
||||||
|
|||||||
93
src/lib/social-milestone-image.tsx
Normal file
93
src/lib/social-milestone-image.tsx
Normal file
@@ -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 <text> nodes in the deployed Node runtime. SVG
|
||||||
|
// draws geometry only; the aligned labels are ordinary positioned text.
|
||||||
|
return <div style={{ display: 'flex', position: 'relative', width: 570, height: 300 }}>
|
||||||
|
{ticks.map(tick => {
|
||||||
|
const y = plotBottom - tick / ceiling * (plotBottom - plotTop);
|
||||||
|
const reached = tick === target;
|
||||||
|
return <div key={tick} style={{ display: 'flex', position: 'absolute', left: 0, top: y - 10, width: 540, height: 22, alignItems: 'center' }}>
|
||||||
|
<div style={{ display: 'flex', width: 58, justifyContent: 'flex-end', paddingRight: 14, color: reached ? '#0256ff' : '#64748b', fontSize: 16, fontWeight: reached ? 700 : 500 }}>{number.format(tick)}</div>
|
||||||
|
<div style={{ display: 'flex', width: 468, height: reached ? 2 : 1, background: reached ? '#bfdbfe' : '#e2e8f0' }} />
|
||||||
|
</div>;
|
||||||
|
})}
|
||||||
|
<svg width="570" height="250" viewBox="0 0 570 250" style={{ position: 'absolute', left: 0, top: 0 }}>
|
||||||
|
<polyline points={points} fill="none" stroke="#0256ff" strokeWidth="5" strokeLinejoin="round" strokeLinecap="round" />
|
||||||
|
{points && <circle cx={endPoint[0]} cy={endPoint[1]} r="7" fill="white" stroke="#0256ff" strokeWidth="4" />}
|
||||||
|
</svg>
|
||||||
|
<div style={{ display: 'flex', position: 'absolute', left: plotLeft, right: 30, top: 252, justifyContent: 'space-between', color: '#45617f', fontSize: 16, fontWeight: 600 }}>
|
||||||
|
<span>{trend.startLabel || (german ? 'Erstellt' : 'Created')}</span>
|
||||||
|
<span>{trend.endLabel || (german ? 'Erreicht' : 'Reached')}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', position: 'absolute', right: 30, bottom: 0, color: '#45617f', fontSize: 16 }}>{german ? 'Kumulierte eindeutige Scans' : 'Cumulative unique scans'}</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
<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={{ display: 'flex', alignItems: 'center', gap: 8, color: '#108c3d', background: '#eafaf0', padding: '8px 13px', borderRadius: 6, fontSize: 18 }}><span style={{ display: 'flex', width: 8, height: 8, borderRadius: 4, background: '#15be53' }} />{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' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user