Align milestone charts and self-share flow
This commit is contained in:
@@ -74,14 +74,16 @@ def render_card(card):
|
||||
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)
|
||||
for fraction in (0, 0.25, 0.5, 0.75, 1):
|
||||
y = top + round(height * fraction)
|
||||
draw.line((left, y, left + width, y), fill="#dde5ef", width=1)
|
||||
draw.line((left, target_y, left + width, target_y), fill="#bfdbfe", width=2)
|
||||
# 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 - 12, target_y - 34), f"{total:,}", font=font("DejaVuSans-Bold.ttf", 20), fill=blue, anchor="ra")
|
||||
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)
|
||||
@@ -97,6 +99,10 @@ def _timestamp(value):
|
||||
return 0
|
||||
|
||||
|
||||
def _format_axis(value):
|
||||
return f"{value:g}" if value < 1000 else f"{value:,.0f}"
|
||||
|
||||
|
||||
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"))
|
||||
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 { db } from '@/lib/db';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
@@ -78,7 +78,9 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st
|
||||
const now = new Date();
|
||||
|
||||
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({
|
||||
where: { id: milestone.id },
|
||||
data: { selfSharedAt: now, publicShareApprovedAt: now, shareToken: token, cardData: card, language },
|
||||
|
||||
@@ -21,7 +21,8 @@ function Trend({ trend }: { trend: NonNullable<Card['trend']> }) {
|
||||
return `${x},${y}`;
|
||||
}).join(' ');
|
||||
const targetY = 88 - (trend.target / trend.ceiling) * 72;
|
||||
return <div><svg viewBox="0 0 100 100" preserveAspectRatio="none" className="h-24 w-full overflow-visible" aria-label="Cumulative unique scan trend"><line x1="5" x2="95" y1={targetY} y2={targetY} stroke="#bfdbfe" strokeWidth="1" 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 className="text-[#0256ff]">{trend.target.toLocaleString()}</span><span>{trend.endLabel}</span></div></div>;
|
||||
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() {
|
||||
@@ -70,19 +71,22 @@ export function SocialMilestoneDialog() {
|
||||
};
|
||||
const shareSelf = async (network: 'x' | 'linkedin') => {
|
||||
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');
|
||||
try {
|
||||
const result = await update('self_share');
|
||||
const shareUrl = `${window.location.origin}/s/m/${result.shareToken}`;
|
||||
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 {
|
||||
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);
|
||||
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); }
|
||||
};
|
||||
const approveBrand = async () => {
|
||||
|
||||
@@ -64,7 +64,8 @@ async function createCardSnapshot(qr: { id: string; title: string; createdAt: Da
|
||||
startLabel: month.format(qr.createdAt),
|
||||
endLabel: month.format(now),
|
||||
target: scans.length,
|
||||
// This makes the reached total sit one grid level below the chart top.
|
||||
// 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),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user