Polish milestone dialog and one-time delivery
This commit is contained in:
@@ -16,8 +16,9 @@ async function ownedMilestone(id: string, userId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function clientState(milestone: { brandStatus: string; brandPostUrl: string | null; brandPostError: string | null; selfSharedAt: Date | null }) {
|
function clientState(milestone: { status: string; brandStatus: string; brandPostUrl: string | null; brandPostError: string | null; selfSharedAt: Date | null }) {
|
||||||
return {
|
return {
|
||||||
|
promptStatus: milestone.status,
|
||||||
brandStatus: milestone.brandStatus,
|
brandStatus: milestone.brandStatus,
|
||||||
brandPostUrl: milestone.brandPostUrl,
|
brandPostUrl: milestone.brandPostUrl,
|
||||||
brandPostError: milestone.brandPostError,
|
brandPostError: milestone.brandPostError,
|
||||||
@@ -86,7 +87,10 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st
|
|||||||
// much less disruptive in an X compose window than a full UUID.
|
// much less disruptive in an X compose window than a full UUID.
|
||||||
const updated = await db.socialMilestone.update({
|
const updated = await db.socialMilestone.update({
|
||||||
where: { id: milestone.id },
|
where: { id: milestone.id },
|
||||||
data: { selfSharedAt: now, publicShareApprovedAt: now, shareToken: token, cardData: card, language },
|
data: {
|
||||||
|
status: 'self_shared', respondedAt: milestone.respondedAt || now,
|
||||||
|
selfSharedAt: now, publicShareApprovedAt: now, shareToken: token, cardData: card, language,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return NextResponse.json({ ok: true, shareToken: token, shareUrl, shareVersion: now.getTime(), milestone: clientState(updated) });
|
return NextResponse.json({ ok: true, shareToken: token, shareUrl, shareVersion: now.getTime(), milestone: clientState(updated) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,28 +19,18 @@ export async function GET(request: NextRequest) {
|
|||||||
if (!user || user.socialPromptOptOut) return NextResponse.json({ milestone: null });
|
if (!user || user.socialPromptOptOut) return NextResponse.json({ milestone: null });
|
||||||
|
|
||||||
const milestone = await db.socialMilestone.findFirst({
|
const milestone = await db.socialMilestone.findFirst({
|
||||||
where: {
|
// `shown` is a durable delivery receipt: one milestone may auto-open only
|
||||||
userId,
|
// once, even across refreshes, tabs and later dashboard visits.
|
||||||
OR: [
|
where: { userId, status: 'detected' },
|
||||||
{ status: { in: ['detected', 'shown'] } },
|
|
||||||
{ status: 'approved', brandStatus: { in: ['approved', 'processing', 'failed'] } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
orderBy: { detectedAt: 'asc' },
|
orderBy: { detectedAt: 'asc' },
|
||||||
include: { qr: { select: { id: true, title: true, createdAt: true } } },
|
include: { qr: { select: { id: true, title: true, createdAt: true } } },
|
||||||
});
|
});
|
||||||
if (!milestone) return NextResponse.json({ milestone: null });
|
if (!milestone) return NextResponse.json({ milestone: null });
|
||||||
|
|
||||||
if (milestone.status === 'detected') {
|
|
||||||
await db.socialMilestone.update({ where: { id: milestone.id }, data: { status: 'shown', shownAt: new Date() } });
|
|
||||||
}
|
|
||||||
const threshold = milestoneThreshold(milestone.kind);
|
const threshold = milestoneThreshold(milestone.kind);
|
||||||
if (!threshold) return NextResponse.json({ milestone: null });
|
if (!threshold) return NextResponse.json({ milestone: null });
|
||||||
const locale = socialLocale(request.nextUrl.searchParams.get('locale'));
|
const locale = socialLocale(request.nextUrl.searchParams.get('locale'));
|
||||||
const shareToken = milestone.shareToken || randomBytes(9).toString('base64url');
|
const shareToken = milestone.shareToken || randomBytes(9).toString('base64url');
|
||||||
if (!milestone.shareToken) {
|
|
||||||
await db.socialMilestone.update({ where: { id: milestone.id }, data: { shareToken } });
|
|
||||||
}
|
|
||||||
const shareUrl = `${getWwwOrigin()}/s/m/${shareToken}`;
|
const shareUrl = `${getWwwOrigin()}/s/m/${shareToken}`;
|
||||||
const card = await ensureSocialMilestoneCard({
|
const card = await ensureSocialMilestoneCard({
|
||||||
milestoneId: milestone.id,
|
milestoneId: milestone.id,
|
||||||
@@ -50,15 +40,21 @@ export async function GET(request: NextRequest) {
|
|||||||
language: locale,
|
language: locale,
|
||||||
qr: milestone.qr,
|
qr: milestone.qr,
|
||||||
primaryUseCase: user.primaryUseCase,
|
primaryUseCase: user.primaryUseCase,
|
||||||
refresh: ['detected', 'shown'].includes(milestone.status),
|
refresh: true,
|
||||||
snapshotAt: new Date(),
|
snapshotAt: new Date(),
|
||||||
});
|
});
|
||||||
|
const delivered = await db.socialMilestone.updateMany({
|
||||||
|
where: { id: milestone.id, status: 'detected' },
|
||||||
|
data: { status: 'shown', shownAt: new Date(), shareToken },
|
||||||
|
});
|
||||||
|
if (!delivered.count) return NextResponse.json({ milestone: null });
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
milestone: {
|
milestone: {
|
||||||
id: milestone.id, qrTitle: milestone.qr.title, threshold,
|
id: milestone.id, qrTitle: milestone.qr.title, threshold,
|
||||||
defaultXHandle: user.xHandle,
|
defaultXHandle: user.xHandle,
|
||||||
brandStatus: milestone.brandStatus,
|
brandStatus: milestone.brandStatus,
|
||||||
|
promptStatus: 'shown',
|
||||||
brandPostUrl: milestone.brandPostUrl,
|
brandPostUrl: milestone.brandPostUrl,
|
||||||
brandPostError: milestone.brandPostError,
|
brandPostError: milestone.brandPostError,
|
||||||
language: locale,
|
language: locale,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Check, Copy, ExternalLink, LineChart, Linkedin, Send, Sparkles, X } from 'lucide-react';
|
import { Check, Copy, ExternalLink, LineChart, Linkedin, QrCode, Send, X } from 'lucide-react';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { useCsrf } from '@/hooks/useCsrf';
|
import { useCsrf } from '@/hooks/useCsrf';
|
||||||
@@ -9,8 +9,8 @@ import { useTranslation } from '@/hooks/useTranslation';
|
|||||||
import { showToast } from '@/components/ui/Toast';
|
import { showToast } from '@/components/ui/Toast';
|
||||||
|
|
||||||
type Card = { language: 'en' | 'de'; qrTitle: string; label: string; title: string; totalScans?: number; totalUniqueScans: number; milestoneThreshold: number; trend: { points: Array<{ at: string; total: number }>; startLabel: string; endLabel: string; target: number; ceiling: number } | null };
|
type Card = { language: 'en' | 'de'; qrTitle: string; label: string; title: string; totalScans?: number; totalUniqueScans: number; milestoneThreshold: number; trend: { points: Array<{ at: string; total: number }>; startLabel: string; endLabel: string; target: number; ceiling: number } | null };
|
||||||
type Milestone = { id: string; qrTitle: string; threshold: number; defaultXHandle: string | null; preview: string; shareUrl: string; language: 'en' | 'de'; card: Card; brandStatus: string; brandPostUrl: string | null; brandPostError: string | null };
|
type Milestone = { id: string; qrTitle: string; threshold: number; defaultXHandle: string | null; preview: string; shareUrl: string; language: 'en' | 'de'; card: Card; promptStatus: string; brandStatus: string; brandPostUrl: string | null; brandPostError: string | null };
|
||||||
type BrandState = { brandStatus: string; brandPostUrl: string | null; brandPostError: string | null; selfSharedAt: string | null };
|
type BrandState = { promptStatus: string; brandStatus: string; brandPostUrl: string | null; brandPostError: string | null; selfSharedAt: string | null };
|
||||||
|
|
||||||
function Trend({ trend, locale }: { trend: NonNullable<Card['trend']>; locale: 'en' | 'de' }) {
|
function Trend({ trend, locale }: { trend: NonNullable<Card['trend']>; locale: 'en' | 'de' }) {
|
||||||
const ceiling = trend.target <= 5 ? 5 : trend.target * 1.25;
|
const ceiling = trend.target <= 5 ? 5 : trend.target * 1.25;
|
||||||
@@ -64,13 +64,16 @@ export function SocialMilestoneDialog() {
|
|||||||
const [xHandle, setXHandle] = useState('');
|
const [xHandle, setXHandle] = useState('');
|
||||||
const [saving, setSaving] = useState<'brand' | 'self' | 'copy' | null>(null);
|
const [saving, setSaving] = useState<'brand' | 'self' | 'copy' | null>(null);
|
||||||
const [brand, setBrand] = useState<BrandState | null>(null);
|
const [brand, setBrand] = useState<BrandState | null>(null);
|
||||||
|
const loadedMilestone = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (loadedMilestone.current) return;
|
||||||
|
loadedMilestone.current = true;
|
||||||
fetch(`/api/social-milestones?locale=${locale}`).then(async response => {
|
fetch(`/api/social-milestones?locale=${locale}`).then(async response => {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const next = (await response.json()).milestone as Milestone | null;
|
const next = (await response.json()).milestone as Milestone | null;
|
||||||
setMilestone(next);
|
setMilestone(next);
|
||||||
if (next) setBrand({ brandStatus: next.brandStatus, brandPostUrl: next.brandPostUrl, brandPostError: next.brandPostError, selfSharedAt: null });
|
if (next) setBrand({ promptStatus: next.promptStatus, brandStatus: next.brandStatus, brandPostUrl: next.brandPostUrl, brandPostError: next.brandPostError, selfSharedAt: null });
|
||||||
}
|
}
|
||||||
}).catch(() => undefined);
|
}).catch(() => undefined);
|
||||||
}, [locale]);
|
}, [locale]);
|
||||||
@@ -170,25 +173,33 @@ export function SocialMilestoneDialog() {
|
|||||||
const card = milestone.card;
|
const card = milestone.card;
|
||||||
const count = card.totalUniqueScans || milestone.threshold;
|
const count = card.totalUniqueScans || milestone.threshold;
|
||||||
const status = brand?.brandStatus || milestone.brandStatus || 'pending';
|
const status = brand?.brandStatus || milestone.brandStatus || 'pending';
|
||||||
|
const promptStatus = brand?.promptStatus || milestone.promptStatus;
|
||||||
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)} containerClassName="max-w-[960px]">
|
||||||
<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)]">
|
<DialogContent className="flex max-h-[calc(100dvh-1rem)] w-full max-w-none flex-col overflow-hidden border-slate-200 p-0 shadow-[0_30px_45px_-30px_rgba(50,50,93,0.45)] sm:max-h-[calc(100dvh-1.5rem)]">
|
||||||
<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-4 py-4 sm:px-6">
|
||||||
<div className="min-h-0 space-y-4 overflow-y-auto border-y border-slate-100 px-6 py-5 overscroll-contain">
|
<DialogHeader className="flex-row items-center space-y-0 text-left">
|
||||||
<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="mr-3 flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-slate-200 bg-white text-[#0256ff] shadow-sm"><QrCode className="h-5 w-5" /></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="min-w-0 flex-1"><DialogTitle className="text-xl font-semibold tracking-[-0.03em] text-[#061b31] sm:text-[22px]">{copy.heading}</DialogTitle><DialogDescription className="truncate pt-1 text-sm text-[#4b5e76]"><strong className="font-medium text-[#061b31]">{milestone.qrTitle}</strong> {copy.subtitle}</DialogDescription></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>
|
<button type="button" aria-label={milestone.language === 'de' ? 'Schließen' : 'Close'} onClick={() => setMilestone(null)} className="ml-3 inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800 focus:outline-none focus:ring-2 focus:ring-[#0256ff]"><X className="h-5 w-5" /></button>
|
||||||
<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>
|
</DialogHeader>
|
||||||
{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>
|
||||||
<div className="mt-5 border-t border-slate-100 pt-3 text-sm font-medium text-[#061b31]">{card.qrTitle}</div>
|
<div className="min-h-0 space-y-4 overflow-y-auto border-y border-slate-100 px-4 py-4 overscroll-contain sm:px-6 md:grid md:grid-cols-[minmax(0,1.12fr)_minmax(320px,0.88fr)] md:gap-6 md:space-y-0">
|
||||||
|
<section className="rounded-xl border border-slate-200 bg-white p-4 shadow-[0_14px_28px_-22px_rgba(50,50,93,0.4)]">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-100 pb-3 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-4 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-3 border-t border-slate-100 pt-3">{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-1 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-3 truncate 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 className="min-w-0 space-y-4 md:pt-1">
|
||||||
<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>
|
<div><p className="text-sm leading-6 text-[#4b5e76]">{copy.consent}</p><blockquote className="mt-3 max-h-44 overflow-y-auto whitespace-pre-line break-words border-l border-[#0256ff] pl-3 text-sm leading-6 text-[#273951]">{preview}</blockquote></div>
|
||||||
{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"><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" />}</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>
|
<div className="space-y-2"><div className="text-xs font-medium text-slate-500">{copy.self}</div><div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-1 lg:grid-cols-2"><Button variant="outline" size="sm" className="w-full" onClick={() => shareSelf('x')} disabled={saving !== null}><X className="mr-1.5 h-3.5 w-3.5" />X</Button><Button variant="outline" size="sm" className="w-full" onClick={() => shareSelf('linkedin')} disabled={saving !== null}><Linkedin className="mr-1.5 h-3.5 w-3.5" />{milestone.language === 'de' ? 'Kopieren & LinkedIn' : 'Copy & open LinkedIn'}</Button></div><button type="button" onClick={copyLinkedInText} disabled={saving !== null} className="inline-flex items-center gap-1.5 text-xs font-medium text-[#45617f] underline underline-offset-2 hover:text-[#0256ff] disabled:opacity-50"><Copy className="h-3.5 w-3.5" />{milestone.language === 'de' ? 'Nur Text kopieren' : 'Copy text only'}</button><p className="text-[11px] leading-4 text-slate-500">{milestone.language === 'de' ? 'LinkedIn öffnet den Editor und kopiert den fertigen Text automatisch.' : 'LinkedIn opens the composer and copies the finished text automatically.'}</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>
|
</div>
|
||||||
|
<DialogFooter className="shrink-0 bg-slate-50 px-4 py-3 sm:px-6"><div className="grid w-full grid-cols-2 gap-2 sm:ml-auto sm:flex sm:w-auto sm:flex-wrap sm:justify-end"><Button variant="outline" onClick={() => promptStatus === 'shown' ? 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>{promptStatus === 'shown' && <button type="button" className="col-span-2 pt-1 text-xs text-slate-500 underline underline-offset-2 hover:text-slate-700 sm:w-full" onClick={optOut} disabled={saving !== null}>{milestone.language === 'de' ? 'Nicht mehr anzeigen' : 'Do not show again'}</button>}</div></DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>;
|
</Dialog>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ interface DialogProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
containerClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Dialog: React.FC<DialogProps> = ({ open, onOpenChange, children }) => {
|
export const Dialog: React.FC<DialogProps> = ({ open, onOpenChange, children, containerClassName }) => {
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -16,7 +17,7 @@ export const Dialog: React.FC<DialogProps> = ({ open, onOpenChange, children })
|
|||||||
className="fixed inset-0 bg-black/50"
|
className="fixed inset-0 bg-black/50"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
/>
|
/>
|
||||||
<div className="relative z-50 w-full max-w-lg mx-4">
|
<div className={cn('relative z-50 mx-4 w-full max-w-lg', containerClassName)}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ function chart(card: SocialMilestoneImageCard, german: boolean) {
|
|||||||
const first = timestamps.length ? Math.min(...timestamps) : 0;
|
const first = timestamps.length ? Math.min(...timestamps) : 0;
|
||||||
const last = Math.max(timestamps.length ? Math.max(...timestamps) : first, first + 1);
|
const last = Math.max(timestamps.length ? Math.max(...timestamps) : first, first + 1);
|
||||||
const plotLeft = 72;
|
const plotLeft = 72;
|
||||||
const plotRight = 540;
|
const plotRight = 620;
|
||||||
const plotTop = 18;
|
const plotTop = 12;
|
||||||
const plotBottom = 238;
|
const plotBottom = 218;
|
||||||
const points = rawPoints.map(point => {
|
const points = rawPoints.map(point => {
|
||||||
const time = new Date(point.at).getTime();
|
const time = new Date(point.at).getTime();
|
||||||
const x = plotLeft + ((Number.isFinite(time) ? time : first) - first) / (last - first) * (plotRight - plotLeft);
|
const x = plotLeft + ((Number.isFinite(time) ? time : first) - first) / (last - first) * (plotRight - plotLeft);
|
||||||
@@ -44,20 +44,20 @@ function chart(card: SocialMilestoneImageCard, german: boolean) {
|
|||||||
|
|
||||||
// Satori cannot render SVG <text> nodes in the deployed Node runtime. SVG
|
// Satori cannot render SVG <text> nodes in the deployed Node runtime. SVG
|
||||||
// draws geometry only; the aligned labels are ordinary positioned text.
|
// draws geometry only; the aligned labels are ordinary positioned text.
|
||||||
return <div style={{ display: 'flex', position: 'relative', width: 570, height: 300 }}>
|
return <div style={{ display: 'flex', position: 'relative', width: 650, height: 285 }}>
|
||||||
{ticks.map(tick => {
|
{ticks.map(tick => {
|
||||||
const y = plotBottom - tick / ceiling * (plotBottom - plotTop);
|
const y = plotBottom - tick / ceiling * (plotBottom - plotTop);
|
||||||
const reached = tick === target;
|
const reached = tick === target;
|
||||||
return <div key={tick} style={{ display: 'flex', position: 'absolute', left: 0, top: y - 10, width: 540, height: 22, alignItems: 'center' }}>
|
return <div key={tick} style={{ display: 'flex', position: 'absolute', left: 0, top: y - 10, width: 620, 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: 72, 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 style={{ display: 'flex', width: 548, height: reached ? 2 : 1, background: reached ? '#bfdbfe' : '#e2e8f0' }} />
|
||||||
</div>;
|
</div>;
|
||||||
})}
|
})}
|
||||||
<svg width="570" height="250" viewBox="0 0 570 250" style={{ position: 'absolute', left: 0, top: 0 }}>
|
<svg width="650" height="230" viewBox="0 0 650 230" style={{ position: 'absolute', left: 0, top: 0 }}>
|
||||||
<polyline points={points} fill="none" stroke="#0256ff" strokeWidth="5" strokeLinejoin="round" strokeLinecap="round" />
|
<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" />}
|
{points && <circle cx={endPoint[0]} cy={endPoint[1]} r="7" fill="white" stroke="#0256ff" strokeWidth="4" />}
|
||||||
</svg>
|
</svg>
|
||||||
<div style={{ display: 'flex', position: 'absolute', left: plotLeft, right: 30, top: 252, justifyContent: 'space-between', color: '#45617f', fontSize: 16, fontWeight: 600 }}>
|
<div style={{ display: 'flex', position: 'absolute', left: plotLeft, right: 30, top: 232, justifyContent: 'space-between', color: '#45617f', fontSize: 16, fontWeight: 600 }}>
|
||||||
<span>{trend.startLabel || (german ? 'Erstellt' : 'Created')}</span>
|
<span>{trend.startLabel || (german ? 'Erstellt' : 'Created')}</span>
|
||||||
<span>{trend.endLabel || (german ? 'Erreicht' : 'Reached')}</span>
|
<span>{trend.endLabel || (german ? 'Erreicht' : 'Reached')}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,23 +69,25 @@ export function createSocialMilestoneImage(card: SocialMilestoneImageCard, germa
|
|||||||
const unique = Math.max(0, Number(card.totalUniqueScans || card.milestoneThreshold || 0));
|
const unique = Math.max(0, Number(card.totalUniqueScans || card.milestoneThreshold || 0));
|
||||||
const total = Math.max(unique, Number(card.totalScans || unique));
|
const total = Math.max(unique, Number(card.totalScans || unique));
|
||||||
const locale = german ? 'de-DE' : 'en-US';
|
const locale = german ? 'de-DE' : 'en-US';
|
||||||
|
const uniqueText = unique.toLocaleString(locale);
|
||||||
|
const uniqueFontSize = uniqueText.length >= 9 ? 78 : uniqueText.length >= 6 ? 96 : uniqueText.length >= 4 ? 108 : 124;
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
<div style={{ height: '100%', width: '100%', display: 'flex', background: '#f8f7f4', padding: 48, color: '#061b31' }}>
|
<div style={{ height: '100%', width: '100%', display: 'flex', background: '#edf3fa', padding: 28, 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', flexDirection: 'column', width: '100%', background: 'white', padding: '30px 34px', borderRadius: 14, boxShadow: '0 24px 50px rgba(50,50,93,.16)' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderBottom: '1px solid #e5edf5', paddingBottom: 22 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderBottom: '1px solid #e5edf5', paddingBottom: 16 }}>
|
||||||
<span style={{ fontSize: 22, fontWeight: 700 }}>QR MASTER</span>
|
<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>
|
<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>
|
||||||
<div style={{ display: 'flex', flex: 1, alignItems: 'center', gap: 40, paddingTop: 24 }}>
|
<div style={{ display: 'flex', flex: 1, alignItems: 'center', gap: 28, paddingTop: 14 }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', width: 430 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', width: 340 }}>
|
||||||
<span style={{ fontSize: 17, color: '#64748b', letterSpacing: 1.4 }}>UNIQUE SCANS</span>
|
<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: uniqueFontSize, fontWeight: 400, letterSpacing: -4 }}>{uniqueText}</span>
|
||||||
<span style={{ fontSize: 22, color: '#45617f' }}><b>{total.toLocaleString(locale)}</b> {german ? 'Scans insgesamt' : 'total scans'}</span>
|
<span style={{ fontSize: 22, color: '#45617f' }}><b>{total.toLocaleString(locale)}</b> {german ? 'Scans insgesamt' : 'total scans'}</span>
|
||||||
</div>
|
</div>
|
||||||
{chart(card, german)}
|
{chart(card, german)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', borderTop: '1px solid #e5edf5', paddingTop: 20, fontSize: 23, fontWeight: 600 }}>{card.qrTitle || (german ? 'QR-Code' : 'QR code')}</div>
|
<div style={{ display: 'flex', borderTop: '1px solid #e5edf5', paddingTop: 14, fontSize: 21, fontWeight: 600 }}>{card.qrTitle || (german ? 'QR-Code' : 'QR code')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
{ width: 1200, height: 630, headers: { 'Cache-Control': 'no-store' } },
|
{ width: 1200, height: 630, headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
|||||||
Reference in New Issue
Block a user