Compare commits
3 Commits
925540f3c6
...
8ef5221f71
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ef5221f71 | |||
| 8e34f97afb | |||
| aa3b4d02ab |
@@ -1,4 +1,5 @@
|
|||||||
"""Always-on QR Master X milestone worker. The web app never receives X keys."""
|
"""Always-on QR Master X milestone worker. The web app never receives X keys."""
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -24,49 +25,86 @@ def api(method, url, payload=None):
|
|||||||
|
|
||||||
|
|
||||||
def font(name, size):
|
def font(name, size):
|
||||||
return ImageFont.truetype(f"/usr/share/fonts/truetype/dejavu/{name}", size)
|
for candidate in (f"/usr/share/fonts/truetype/dejavu/{name}", f"C:/Windows/Fonts/{'arialbd.ttf' if 'Bold' in name else 'arial.ttf'}"):
|
||||||
|
if Path(candidate).exists():
|
||||||
|
return ImageFont.truetype(candidate, size)
|
||||||
|
return ImageFont.load_default()
|
||||||
|
|
||||||
|
|
||||||
|
def logo():
|
||||||
|
"""Use the actual deployed QR Master favicon, not an invented icon."""
|
||||||
|
try:
|
||||||
|
base = required("QRMASTER_API_BASE").rstrip("/")
|
||||||
|
response = requests.get(f"{base}/favicon.ico", timeout=10)
|
||||||
|
response.raise_for_status()
|
||||||
|
mark = Image.open(io.BytesIO(response.content)).convert("RGBA")
|
||||||
|
mark.thumbnail((56, 56))
|
||||||
|
return mark
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def render_card(card):
|
def render_card(card):
|
||||||
"""Render the consented immutable scan snapshot; never invent trend data."""
|
"""Render an immutable cumulative scan timeline from the stored snapshot."""
|
||||||
image = Image.new("RGB", (1200, 630), "#f8fafc")
|
image = Image.new("RGB", (1200, 630), "#f8f7f4")
|
||||||
draw = ImageDraw.Draw(image)
|
draw = ImageDraw.Draw(image)
|
||||||
navy, blue, slate, border, green = "#061b31", "#0256ff", "#64748b", "#e2e8f0", "#059669"
|
navy, blue, slate, mint = "#061b31", "#0256ff", "#64748b", "#059669"
|
||||||
regular, medium, bold, display = font("DejaVuSans.ttf", 28), font("DejaVuSans-Bold.ttf", 28), font("DejaVuSans-Bold.ttf", 42), font("DejaVuSans-Bold.ttf", 116)
|
regular, medium, display = font("DejaVuSans.ttf", 27), font("DejaVuSans-Bold.ttf", 27), font("DejaVuSans.ttf", 142)
|
||||||
draw.rounded_rectangle((45, 42, 1155, 588), radius=24, fill="#ffffff", outline=border, width=2)
|
mark = logo()
|
||||||
draw.rounded_rectangle((82, 79, 114, 111), radius=7, fill=blue)
|
if mark:
|
||||||
draw.text((130, 81), "QR MASTER", font=medium, fill=navy)
|
image.paste(mark, (68, 58), mark)
|
||||||
draw.rounded_rectangle((932, 77, 1118, 114), radius=8, fill="#ecfdf5")
|
logo_x = 140
|
||||||
draw.text((954, 84), "Verified milestone", font=font("DejaVuSans-Bold.ttf", 17), fill=green)
|
|
||||||
draw.text((84, 158), "TOTAL UNIQUE SCANS", font=font("DejaVuSans-Bold.ttf", 18), fill="#94a3b8")
|
|
||||||
total = int(card.get("totalUniqueScans") or card.get("threshold") or 0)
|
|
||||||
draw.text((78, 184), f"{total:,}", font=display, fill=navy)
|
|
||||||
label = "eindeutige Scans" if card.get("language") == "de" else "unique scans"
|
|
||||||
draw.text((86, 325), label, font=regular, fill=slate)
|
|
||||||
trend = card.get("trend") or None
|
|
||||||
if trend and len(trend.get("series", [])) > 1:
|
|
||||||
series = trend["series"]
|
|
||||||
left, top, width, height = 84, 390, 1030, 88
|
|
||||||
max_value = max(series) or 1
|
|
||||||
points = [(left + round(index * width / (len(series) - 1)), top + height - round(value / max_value * height)) for index, value in enumerate(series)]
|
|
||||||
for y in (top, top + height // 2, top + height):
|
|
||||||
draw.line((left, y, left + width, y), fill="#edf2f7", width=2)
|
|
||||||
draw.line(points, fill=blue, width=6, joint="curve")
|
|
||||||
for x, y in (points[0], points[-1]):
|
|
||||||
draw.ellipse((x - 7, y - 7, x + 7, y + 7), fill=blue)
|
|
||||||
draw.text((84, 495), f"Last {trend.get('periodDays', 7)} days · {trend.get('recentTotal', 0)} unique scans", font=font("DejaVuSans.ttf", 18), fill=slate)
|
|
||||||
else:
|
else:
|
||||||
draw.rounded_rectangle((84, 398, 357, 452), radius=10, fill="#eff6ff")
|
logo_x = 68
|
||||||
copy = "Erste Dynamik" if card.get("language") == "de" else "Early momentum"
|
draw.text((logo_x, 70), "QR MASTER", font=medium, fill=navy)
|
||||||
draw.text((106, 411), copy, font=font("DejaVuSans-Bold.ttf", 20), fill=blue)
|
|
||||||
draw.text((84, 495), "Trend appears once enough real scan history exists." if card.get("language") != "de" else "Der Trend erscheint mit ausreichend echten Scan-Daten.", font=font("DejaVuSans.ttf", 18), fill=slate)
|
total = int(card.get("totalUniqueScans") or card.get("threshold") or 0)
|
||||||
draw.line((84, 532, 1116, 532), fill=border, width=2)
|
total_scans = int(card.get("totalScans") or total)
|
||||||
draw.text((84, 549), card.get("qrTitle") or card.get("title") or "QR code", font=medium, fill=navy)
|
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)
|
||||||
|
|
||||||
|
trend = card.get("trend") or {}
|
||||||
|
raw_points = trend.get("points") or []
|
||||||
|
left, top, width, height = 590, 140, 540, 330
|
||||||
|
if raw_points:
|
||||||
|
start_ms = min(_timestamp(point.get("at")) for point in raw_points)
|
||||||
|
end_ms = max(_timestamp(point.get("at")) for point in raw_points)
|
||||||
|
span = max(1, end_ms - start_ms)
|
||||||
|
ceiling = float(trend.get("ceiling") or max(total * 1.25, 1.25))
|
||||||
|
points = [(left + round((_timestamp(point.get("at")) - start_ms) / span * width), top + height - round(float(point.get("total", 0)) / ceiling * height)) for point in raw_points]
|
||||||
|
target_y = top + height - round(float(trend.get("target") or total) / ceiling * height)
|
||||||
|
# Exactly five levels: for 20 scans, 5 / 10 / 15 / 20 / 25.
|
||||||
|
for index in range(1, 6):
|
||||||
|
value = total * index / 4
|
||||||
|
y = top + height - round(value / ceiling * height)
|
||||||
|
is_target = index == 4
|
||||||
|
draw.line((left, y, left + width, y), fill="#bfdbfe" if is_target else "#dde5ef", width=2 if is_target else 1)
|
||||||
|
draw.text((left - 15, y - 12), _format_axis(value), font=font("DejaVuSans.ttf", 18), fill=blue if is_target else slate, anchor="ra")
|
||||||
|
draw.line(points, fill=blue, width=5, joint="curve")
|
||||||
|
x, y = points[-1]
|
||||||
|
draw.ellipse((x - 8, y - 8, x + 8, y + 8), fill="#ffffff", outline=blue, width=5)
|
||||||
|
draw.text((left, top + height + 27), trend.get("startLabel") or "Created", font=font("DejaVuSans.ttf", 19), fill=slate)
|
||||||
|
draw.text((left + width, top + height + 27), trend.get("endLabel") or "Reached", font=font("DejaVuSans.ttf", 19), fill=slate, anchor="ra")
|
||||||
|
draw.text((870, 530), "VERIFIED SCAN DATA", font=font("DejaVuSans-Bold.ttf", 18), fill=mint)
|
||||||
path = Path(tempfile.mkstemp(suffix=".png")[1])
|
path = Path(tempfile.mkstemp(suffix=".png")[1])
|
||||||
image.save(path, "PNG", optimize=True)
|
image.save(path, "PNG", optimize=True)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _timestamp(value):
|
||||||
|
try:
|
||||||
|
return int(__import__("datetime").datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() * 1000)
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _format_axis(value):
|
||||||
|
return f"{value:g}" if value < 1000 else f"{value:,.0f}"
|
||||||
|
|
||||||
|
|
||||||
def post_x(text, card):
|
def post_x(text, card):
|
||||||
oauth = OAuth1Session(required("X_API_KEY"), client_secret=required("X_API_SECRET"), resource_owner_key=required("X_ACCESS_TOKEN"), resource_owner_secret=required("X_ACCESS_TOKEN_SECRET"))
|
oauth = OAuth1Session(required("X_API_KEY"), client_secret=required("X_API_SECRET"), resource_owner_key=required("X_ACCESS_TOKEN"), resource_owner_secret=required("X_ACCESS_TOKEN_SECRET"))
|
||||||
path = render_card(card) if card else None
|
path = render_card(card) if card else None
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { randomUUID } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { csrfProtection } from '@/lib/csrf';
|
import { csrfProtection } from '@/lib/csrf';
|
||||||
@@ -69,6 +69,7 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st
|
|||||||
const card = milestone.cardData || buildMilestoneCardSnapshot({
|
const card = milestone.cardData || buildMilestoneCardSnapshot({
|
||||||
primaryUseCase: milestone.user.primaryUseCase,
|
primaryUseCase: milestone.user.primaryUseCase,
|
||||||
qrTitle: milestone.qr.title,
|
qrTitle: milestone.qr.title,
|
||||||
|
totalScans: threshold,
|
||||||
totalUniqueScans: threshold,
|
totalUniqueScans: threshold,
|
||||||
milestoneThreshold: threshold,
|
milestoneThreshold: threshold,
|
||||||
reachedAt: milestone.detectedAt,
|
reachedAt: milestone.detectedAt,
|
||||||
@@ -78,7 +79,9 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
if (body.action === 'self_share') {
|
if (body.action === 'self_share') {
|
||||||
const token = milestone.shareToken || randomUUID().replace(/-/g, '');
|
// 72 random bits keep public URLs unguessable while making the share URL
|
||||||
|
// much less disruptive in an X compose window than a full UUID.
|
||||||
|
const token = milestone.shareToken || randomBytes(9).toString('base64url');
|
||||||
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: { selfSharedAt: now, publicShareApprovedAt: now, shareToken: token, cardData: card, language },
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const milestone = await db.socialMilestone.findFirst({
|
const milestone = await db.socialMilestone.findFirst({
|
||||||
where: { userId, status: { in: ['detected', 'shown'] } },
|
where: { userId, status: { in: ['detected', 'shown'] } },
|
||||||
orderBy: { detectedAt: 'asc' },
|
orderBy: { detectedAt: 'asc' },
|
||||||
include: { qr: { select: { title: true } } },
|
include: { qr: { select: { id: true, title: true } } },
|
||||||
});
|
});
|
||||||
if (!milestone) return NextResponse.json({ milestone: null });
|
if (!milestone) return NextResponse.json({ milestone: null });
|
||||||
|
|
||||||
@@ -28,6 +28,20 @@ export async function GET(request: NextRequest) {
|
|||||||
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 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({
|
return NextResponse.json({
|
||||||
milestone: {
|
milestone: {
|
||||||
@@ -44,15 +58,7 @@ export async function GET(request: NextRequest) {
|
|||||||
locale,
|
locale,
|
||||||
milestone.qr.title,
|
milestone.qr.title,
|
||||||
),
|
),
|
||||||
card: milestone.cardData || buildMilestoneCardSnapshot({
|
card,
|
||||||
primaryUseCase: user.primaryUseCase,
|
|
||||||
qrTitle: milestone.qr.title,
|
|
||||||
totalUniqueScans: threshold,
|
|
||||||
milestoneThreshold: threshold,
|
|
||||||
reachedAt: milestone.detectedAt,
|
|
||||||
trend: null,
|
|
||||||
locale,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,21 @@ import { useCsrf } from '@/hooks/useCsrf';
|
|||||||
import { useTranslation } from '@/hooks/useTranslation';
|
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; totalUniqueScans: number; milestoneThreshold: number; trend: { periodDays: number; series: number[]; recentTotal: 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 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 };
|
type BrandState = { brandStatus: string; brandPostUrl: string | null; brandPostError: string | null; selfSharedAt: string | null };
|
||||||
|
|
||||||
function Trend({ series }: { series: number[] }) {
|
function Trend({ trend }: { trend: NonNullable<Card['trend']> }) {
|
||||||
const max = Math.max(...series, 1);
|
const first = new Date(trend.points[0].at).getTime();
|
||||||
const points = series.map((value, index) => `${(index / Math.max(series.length - 1, 1)) * 100},${92 - (value / max) * 70}`).join(' ');
|
const last = Math.max(new Date(trend.points[trend.points.length - 1].at).getTime(), first + 1);
|
||||||
return <svg viewBox="0 0 100 100" preserveAspectRatio="none" className="h-20 w-full overflow-visible" aria-label="Real scan trend"><polyline points={points} fill="none" stroke="currentColor" strokeWidth="3" vectorEffect="non-scaling-stroke" /></svg>;
|
const points = trend.points.map(point => {
|
||||||
|
const x = 5 + ((new Date(point.at).getTime() - first) / (last - first)) * 90;
|
||||||
|
const y = 88 - (point.total / trend.ceiling) * 72;
|
||||||
|
return `${x},${y}`;
|
||||||
|
}).join(' ');
|
||||||
|
const targetY = 88 - (trend.target / trend.ceiling) * 72;
|
||||||
|
const ticks = Array.from({ length: 5 }, (_, index) => trend.target * (index + 1) / 4);
|
||||||
|
return <div className="grid grid-cols-[2.5rem_1fr] gap-2"><div className="flex h-24 flex-col justify-between py-1 text-right text-[10px] font-medium tabular-nums text-[#45617f]">{ticks.slice().reverse().map(tick => <span key={tick} className={tick === trend.target ? 'text-[#0256ff]' : undefined}>{tick.toLocaleString()}</span>)}</div><div><svg viewBox="0 0 100 100" preserveAspectRatio="none" className="h-24 w-full overflow-visible" aria-label="Cumulative unique scan trend">{ticks.map(tick => { const y = 88 - (tick / trend.ceiling) * 72; return <line key={tick} x1="5" x2="95" y1={y} y2={y} stroke={tick === trend.target ? '#bfdbfe' : '#e2e8f0'} strokeWidth={tick === trend.target ? '1.4' : '0.8'} vectorEffect="non-scaling-stroke" />; })}<polyline points={points} fill="none" stroke="currentColor" strokeWidth="3" vectorEffect="non-scaling-stroke" /><circle cx="95" cy={targetY} r="2.7" fill="#0256ff" /></svg><div className="mt-1 flex justify-between text-[11px] font-medium tracking-wide text-[#45617f]"><span>{trend.startLabel}</span><span>{trend.endLabel}</span></div></div></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SocialMilestoneDialog() {
|
export function SocialMilestoneDialog() {
|
||||||
@@ -64,19 +71,22 @@ export function SocialMilestoneDialog() {
|
|||||||
};
|
};
|
||||||
const shareSelf = async (network: 'x' | 'linkedin') => {
|
const shareSelf = async (network: 'x' | 'linkedin') => {
|
||||||
if (!milestone) return;
|
if (!milestone) return;
|
||||||
|
// Open synchronously from the user gesture. Awaiting the API first can make
|
||||||
|
// LinkedIn treat the new window as a blocked popup.
|
||||||
|
const shareWindow = window.open('', '_blank', 'noopener,noreferrer');
|
||||||
setSaving('self');
|
setSaving('self');
|
||||||
try {
|
try {
|
||||||
const result = await update('self_share');
|
const result = await update('self_share');
|
||||||
const shareUrl = `${window.location.origin}/s/m/${result.shareToken}`;
|
const shareUrl = `${window.location.origin}/s/m/${result.shareToken}`;
|
||||||
const text = `${preview} ${shareUrl}`;
|
const text = `${preview} ${shareUrl}`;
|
||||||
if (network === 'x') window.open(`https://x.com/intent/post?text=${encodeURIComponent(text)}`, '_blank', 'noopener,noreferrer');
|
if (network === 'x') shareWindow?.location.replace(`https://x.com/intent/post?text=${encodeURIComponent(text)}`);
|
||||||
else {
|
else {
|
||||||
await navigator.clipboard?.writeText(text);
|
await navigator.clipboard?.writeText(text);
|
||||||
window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,noreferrer');
|
shareWindow?.location.replace(`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`);
|
||||||
}
|
}
|
||||||
setBrand(result.milestone);
|
setBrand(result.milestone);
|
||||||
showToast(network === 'linkedin' ? 'Share text copied and LinkedIn opened.' : 'X share composer opened.', 'success');
|
showToast(network === 'linkedin' ? 'Share text copied and LinkedIn opened.' : 'X share composer opened.', 'success');
|
||||||
} catch (error) { 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); }
|
||||||
};
|
};
|
||||||
const approveBrand = async () => {
|
const approveBrand = async () => {
|
||||||
@@ -101,9 +111,9 @@ export function SocialMilestoneDialog() {
|
|||||||
<div className="px-7 pb-5 pt-7"><DialogHeader><div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-[#eaf1ff] text-[#0256ff]"><Sparkles className="h-5 w-5" /></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="px-7 pb-5 pt-7"><DialogHeader><div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-[#eaf1ff] text-[#0256ff]"><Sparkles className="h-5 w-5" /></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="space-y-5 border-y border-slate-100 px-7 py-6">
|
<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)]">
|
<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="font-semibold tracking-wide text-[#061b31]">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 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 series={card.trend.series} /><div className="mt-2 flex items-center gap-2 text-xs text-[#45617f]"><LineChart className="h-3.5 w-3.5 text-[#0256ff]" />{card.trend.recentTotal} {milestone.language === 'de' ? 'eindeutige Scans in den letzten' : 'unique scans in the last'} {card.trend.periodDays} days</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>}
|
{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>
|
<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 border-l-2 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 border-l-2 border-[#0256ff] pl-3 text-sm leading-6 text-[#273951]">{preview}</blockquote></div>
|
||||||
|
|||||||
@@ -45,37 +45,38 @@ export async function detectSocialMilestones(qrId?: string) {
|
|||||||
|
|
||||||
async function createCardSnapshot(qr: { id: string; title: string; createdAt: Date; user: { primaryUseCase: string | null } }) {
|
async function createCardSnapshot(qr: { id: string; title: string; createdAt: Date; user: { primaryUseCase: string | null } }) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const sevenDaysAgo = new Date(now);
|
|
||||||
sevenDaysAgo.setUTCHours(0, 0, 0, 0);
|
|
||||||
sevenDaysAgo.setUTCDate(sevenDaysAgo.getUTCDate() - 6);
|
|
||||||
const scans = await db.qRScan.findMany({
|
const scans = await db.qRScan.findMany({
|
||||||
where: { qrId: qr.id, isUnique: true },
|
where: { qrId: qr.id },
|
||||||
select: { ts: true },
|
select: { ts: true, isUnique: true },
|
||||||
orderBy: { ts: 'asc' },
|
orderBy: { ts: 'asc' },
|
||||||
});
|
});
|
||||||
const daily = new Map<string, number>();
|
const uniqueScans = scans.filter(scan => scan.isUnique);
|
||||||
for (const scan of scans) {
|
// Keep a representative, cumulative history in the immutable snapshot.
|
||||||
if (scan.ts >= sevenDaysAgo) {
|
// It starts at QR creation and ends at the moment the milestone is detected.
|
||||||
const key = scan.ts.toISOString().slice(0, 10);
|
const stride = Math.max(1, Math.ceil(uniqueScans.length / 24));
|
||||||
daily.set(key, (daily.get(key) || 0) + 1);
|
const points = [{ at: qr.createdAt.toISOString(), total: 0 }];
|
||||||
}
|
uniqueScans.forEach((scan, index) => {
|
||||||
}
|
const total = index + 1;
|
||||||
const series = Array.from({ length: 7 }, (_, index) => {
|
if (total % stride === 0 || total === uniqueScans.length) points.push({ at: scan.ts.toISOString(), total });
|
||||||
const date = new Date(sevenDaysAgo);
|
|
||||||
date.setUTCDate(date.getUTCDate() + index);
|
|
||||||
return daily.get(date.toISOString().slice(0, 10)) || 0;
|
|
||||||
});
|
});
|
||||||
const activeDays = series.filter(Boolean).length;
|
const month = new Intl.DateTimeFormat('en', { month: 'short', year: '2-digit', timeZone: 'UTC' });
|
||||||
const trend = activeDays >= 2 && scans.length >= 5
|
const trend = {
|
||||||
? { periodDays: 7, series, recentTotal: series.reduce((total, value) => total + value, 0) }
|
points,
|
||||||
: null;
|
startLabel: month.format(qr.createdAt),
|
||||||
|
endLabel: month.format(now),
|
||||||
|
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: 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.
|
// The snapshot is made at detection time and never silently changes after consent.
|
||||||
return buildMilestoneCardSnapshot({
|
return buildMilestoneCardSnapshot({
|
||||||
primaryUseCase: qr.user.primaryUseCase,
|
primaryUseCase: qr.user.primaryUseCase,
|
||||||
qrTitle: qr.title,
|
qrTitle: qr.title,
|
||||||
totalUniqueScans: scans.length,
|
totalScans: scans.length,
|
||||||
milestoneThreshold: scans.length,
|
totalUniqueScans: uniqueScans.length,
|
||||||
|
milestoneThreshold: uniqueScans.length,
|
||||||
reachedAt: now,
|
reachedAt: now,
|
||||||
trend,
|
trend,
|
||||||
locale: 'en' as SocialLocale,
|
locale: 'en' as SocialLocale,
|
||||||
|
|||||||
@@ -45,10 +45,17 @@ export type SocialMilestoneCard = {
|
|||||||
qrTitle: string;
|
qrTitle: string;
|
||||||
label: string;
|
label: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
totalScans: number;
|
||||||
totalUniqueScans: number;
|
totalUniqueScans: number;
|
||||||
milestoneThreshold: number;
|
milestoneThreshold: number;
|
||||||
reachedAt: string;
|
reachedAt: string;
|
||||||
trend: { periodDays: number; series: number[]; recentTotal: number } | null;
|
trend: {
|
||||||
|
points: Array<{ at: string; total: number }>;
|
||||||
|
startLabel: string;
|
||||||
|
endLabel: string;
|
||||||
|
target: number;
|
||||||
|
ceiling: number;
|
||||||
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function socialLocale(value?: string | null): SocialLocale {
|
export function socialLocale(value?: string | null): SocialLocale {
|
||||||
@@ -87,10 +94,11 @@ export function buildMilestonePostForQr(primaryUseCase: string | null, totalUniq
|
|||||||
export function buildMilestoneCardSnapshot(input: {
|
export function buildMilestoneCardSnapshot(input: {
|
||||||
primaryUseCase: string | null;
|
primaryUseCase: string | null;
|
||||||
qrTitle: string;
|
qrTitle: string;
|
||||||
|
totalScans: number;
|
||||||
totalUniqueScans: number;
|
totalUniqueScans: number;
|
||||||
milestoneThreshold: number;
|
milestoneThreshold: number;
|
||||||
reachedAt: Date;
|
reachedAt: Date;
|
||||||
trend: { periodDays: number; series: number[]; recentTotal: number } | null;
|
trend: SocialMilestoneCard['trend'];
|
||||||
locale: SocialLocale;
|
locale: SocialLocale;
|
||||||
}): SocialMilestoneCard {
|
}): SocialMilestoneCard {
|
||||||
return {
|
return {
|
||||||
@@ -99,6 +107,7 @@ export function buildMilestoneCardSnapshot(input: {
|
|||||||
qrTitle: input.qrTitle,
|
qrTitle: input.qrTitle,
|
||||||
label: usageLabel(input.primaryUseCase, input.locale),
|
label: usageLabel(input.primaryUseCase, input.locale),
|
||||||
title: input.locale === 'de' ? 'Erfolgsmeilenstein' : 'Success milestone',
|
title: input.locale === 'de' ? 'Erfolgsmeilenstein' : 'Success milestone',
|
||||||
|
totalScans: input.totalScans,
|
||||||
totalUniqueScans: input.totalUniqueScans,
|
totalUniqueScans: input.totalUniqueScans,
|
||||||
milestoneThreshold: input.milestoneThreshold,
|
milestoneThreshold: input.milestoneThreshold,
|
||||||
reachedAt: input.reachedAt.toISOString(),
|
reachedAt: input.reachedAt.toISOString(),
|
||||||
|
|||||||
Reference in New Issue
Block a user