Show total scans in social milestones
This commit is contained in:
@@ -59,8 +59,10 @@ def render_card(card):
|
||||
draw.text((logo_x, 70), "QR MASTER", font=medium, fill=navy)
|
||||
|
||||
total = int(card.get("totalUniqueScans") or card.get("threshold") or 0)
|
||||
total_scans = int(card.get("totalScans") or total)
|
||||
draw.text((66, 212), f"{total:,}", font=display, fill=navy)
|
||||
draw.text((73, 374), "UNIQUE SCANS", font=medium, fill=navy)
|
||||
draw.text((74, 410), f"{total_scans:,} total scans", font=font("DejaVuSans.ttf", 20), fill=slate)
|
||||
draw.line((68, 548, 108, 548), fill=blue, width=4)
|
||||
draw.text((126, 530), "QR code milestone", font=regular, fill=slate)
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st
|
||||
const card = milestone.cardData || buildMilestoneCardSnapshot({
|
||||
primaryUseCase: milestone.user.primaryUseCase,
|
||||
qrTitle: milestone.qr.title,
|
||||
totalScans: threshold,
|
||||
totalUniqueScans: threshold,
|
||||
milestoneThreshold: threshold,
|
||||
reachedAt: milestone.detectedAt,
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function GET(request: NextRequest) {
|
||||
const milestone = await db.socialMilestone.findFirst({
|
||||
where: { userId, status: { in: ['detected', 'shown'] } },
|
||||
orderBy: { detectedAt: 'asc' },
|
||||
include: { qr: { select: { title: true } } },
|
||||
include: { qr: { select: { id: true, title: true } } },
|
||||
});
|
||||
if (!milestone) return NextResponse.json({ milestone: null });
|
||||
|
||||
@@ -28,6 +28,20 @@ export async function GET(request: NextRequest) {
|
||||
const threshold = milestoneThreshold(milestone.kind);
|
||||
if (!threshold) return NextResponse.json({ milestone: null });
|
||||
const locale = socialLocale(request.nextUrl.searchParams.get('locale'));
|
||||
const allScanCount = await db.qRScan.count({ where: { qrId: milestone.qr.id } });
|
||||
const storedCard = milestone.cardData as Record<string, unknown> | null;
|
||||
const card = storedCard
|
||||
? { ...storedCard, totalScans: typeof storedCard.totalScans === 'number' ? storedCard.totalScans : allScanCount }
|
||||
: buildMilestoneCardSnapshot({
|
||||
primaryUseCase: user.primaryUseCase,
|
||||
qrTitle: milestone.qr.title,
|
||||
totalScans: allScanCount,
|
||||
totalUniqueScans: threshold,
|
||||
milestoneThreshold: threshold,
|
||||
reachedAt: milestone.detectedAt,
|
||||
trend: null,
|
||||
locale,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
milestone: {
|
||||
@@ -44,15 +58,7 @@ export async function GET(request: NextRequest) {
|
||||
locale,
|
||||
milestone.qr.title,
|
||||
),
|
||||
card: milestone.cardData || buildMilestoneCardSnapshot({
|
||||
primaryUseCase: user.primaryUseCase,
|
||||
qrTitle: milestone.qr.title,
|
||||
totalUniqueScans: threshold,
|
||||
milestoneThreshold: threshold,
|
||||
reachedAt: milestone.detectedAt,
|
||||
trend: null,
|
||||
locale,
|
||||
}),
|
||||
card,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useCsrf } from '@/hooks/useCsrf';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
|
||||
type Card = { language: 'en' | 'de'; qrTitle: string; label: string; title: string; 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; language: 'en' | 'de'; card: Card; brandStatus: string; brandPostUrl: string | null; brandPostError: string | null };
|
||||
type BrandState = { brandStatus: string; brandPostUrl: string | null; brandPostError: string | null; selfSharedAt: string | null };
|
||||
|
||||
@@ -112,7 +112,7 @@ export function SocialMilestoneDialog() {
|
||||
<div className="space-y-5 border-y border-slate-100 px-7 py-6">
|
||||
<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="mt-5 text-[11px] font-semibold tracking-[0.12em] text-slate-400">TOTAL UNIQUE SCANS</div><div className="mt-1 text-5xl font-semibold tracking-[-0.05em] tabular-nums text-[#061b31]">{count.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}</div><div className="mt-1 text-sm text-slate-500">{milestone.language === 'de' ? 'eindeutige Scans' : 'unique scans'}</div>
|
||||
<div className="mt-5 text-[11px] font-semibold tracking-[0.12em] text-slate-400">TOTAL UNIQUE SCANS</div><div className="mt-1 text-5xl font-semibold tracking-[-0.05em] tabular-nums text-[#061b31]">{count.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}</div><div className="mt-1 text-sm text-slate-500">{milestone.language === 'de' ? 'eindeutige Scans' : 'unique scans'}</div><div className="mt-3 border-l border-slate-200 pl-3 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>
|
||||
{card.trend ? <div className="mt-5 text-[#0256ff]"><Trend trend={card.trend} /><div className="mt-2 flex items-center gap-2 text-xs text-[#45617f]"><LineChart className="h-3.5 w-3.5 text-[#0256ff]" />{milestone.language === 'de' ? 'Kumulierte eindeutige Scans seit Erstellung' : 'Cumulative unique scans since creation'}</div></div> : <div className="mt-5 inline-flex items-center gap-2 rounded-md bg-blue-50 px-3 py-2 text-xs font-medium text-blue-700"><LineChart className="h-3.5 w-3.5" />{milestone.language === 'de' ? 'Erste Dynamik' : 'Early momentum'}</div>}
|
||||
<div className="mt-5 border-t border-slate-100 pt-3 text-sm font-medium text-[#061b31]">{card.qrTitle}</div>
|
||||
</section>
|
||||
|
||||
@@ -46,35 +46,37 @@ export async function detectSocialMilestones(qrId?: string) {
|
||||
async function createCardSnapshot(qr: { id: string; title: string; createdAt: Date; user: { primaryUseCase: string | null } }) {
|
||||
const now = new Date();
|
||||
const scans = await db.qRScan.findMany({
|
||||
where: { qrId: qr.id, isUnique: true },
|
||||
select: { ts: true },
|
||||
where: { qrId: qr.id },
|
||||
select: { ts: true, isUnique: true },
|
||||
orderBy: { ts: 'asc' },
|
||||
});
|
||||
const uniqueScans = scans.filter(scan => scan.isUnique);
|
||||
// Keep a representative, cumulative history in the immutable snapshot.
|
||||
// It starts at QR creation and ends at the moment the milestone is detected.
|
||||
const stride = Math.max(1, Math.ceil(scans.length / 24));
|
||||
const stride = Math.max(1, Math.ceil(uniqueScans.length / 24));
|
||||
const points = [{ at: qr.createdAt.toISOString(), total: 0 }];
|
||||
scans.forEach((scan, index) => {
|
||||
uniqueScans.forEach((scan, index) => {
|
||||
const total = index + 1;
|
||||
if (total % stride === 0 || total === scans.length) points.push({ at: scan.ts.toISOString(), total });
|
||||
if (total % stride === 0 || total === uniqueScans.length) points.push({ at: scan.ts.toISOString(), total });
|
||||
});
|
||||
const month = new Intl.DateTimeFormat('en', { month: 'short', year: '2-digit', timeZone: 'UTC' });
|
||||
const trend = {
|
||||
points,
|
||||
startLabel: month.format(qr.createdAt),
|
||||
endLabel: month.format(now),
|
||||
target: scans.length,
|
||||
target: uniqueScans.length,
|
||||
// Five labelled grid lines: 1/4, 1/2, 3/4, target, then one level above.
|
||||
// For 20 scans this is precisely 5, 10, 15, 20, 25.
|
||||
ceiling: Math.max(1.25, scans.length * 1.25),
|
||||
ceiling: uniqueScans.length < 5 ? 5 : Math.max(1.25, uniqueScans.length * 1.25),
|
||||
};
|
||||
|
||||
// The snapshot is made at detection time and never silently changes after consent.
|
||||
return buildMilestoneCardSnapshot({
|
||||
primaryUseCase: qr.user.primaryUseCase,
|
||||
qrTitle: qr.title,
|
||||
totalUniqueScans: scans.length,
|
||||
milestoneThreshold: scans.length,
|
||||
totalScans: scans.length,
|
||||
totalUniqueScans: uniqueScans.length,
|
||||
milestoneThreshold: uniqueScans.length,
|
||||
reachedAt: now,
|
||||
trend,
|
||||
locale: 'en' as SocialLocale,
|
||||
|
||||
@@ -45,6 +45,7 @@ export type SocialMilestoneCard = {
|
||||
qrTitle: string;
|
||||
label: string;
|
||||
title: string;
|
||||
totalScans: number;
|
||||
totalUniqueScans: number;
|
||||
milestoneThreshold: number;
|
||||
reachedAt: string;
|
||||
@@ -93,6 +94,7 @@ export function buildMilestonePostForQr(primaryUseCase: string | null, totalUniq
|
||||
export function buildMilestoneCardSnapshot(input: {
|
||||
primaryUseCase: string | null;
|
||||
qrTitle: string;
|
||||
totalScans: number;
|
||||
totalUniqueScans: number;
|
||||
milestoneThreshold: number;
|
||||
reachedAt: Date;
|
||||
@@ -105,6 +107,7 @@ export function buildMilestoneCardSnapshot(input: {
|
||||
qrTitle: input.qrTitle,
|
||||
label: usageLabel(input.primaryUseCase, input.locale),
|
||||
title: input.locale === 'de' ? 'Erfolgsmeilenstein' : 'Success milestone',
|
||||
totalScans: input.totalScans,
|
||||
totalUniqueScans: input.totalUniqueScans,
|
||||
milestoneThreshold: input.milestoneThreshold,
|
||||
reachedAt: input.reachedAt.toISOString(),
|
||||
|
||||
Reference in New Issue
Block a user