Fix milestone sharing previews and publisher recovery
This commit is contained in:
@@ -3,4 +3,5 @@ WORKDIR /worker
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY worker.py .
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 CMD python -c "import os,requests; base=os.environ['QRMASTER_API_BASE'].rstrip('/'); secret=os.environ['INTERNAL_API_SECRET']; requests.get(base + '/api/internal/social-milestones?dryRun=true', headers={'Authorization':'Bearer ' + secret}, timeout=5).raise_for_status()"
|
||||
CMD ["python", "worker.py"]
|
||||
|
||||
@@ -57,14 +57,19 @@ def render_card(card):
|
||||
else:
|
||||
logo_x = 68
|
||||
draw.text((logo_x, 70), "QR MASTER", font=medium, fill=navy)
|
||||
badge_text = "Verified scan milestone"
|
||||
badge_font = font("DejaVuSans.ttf", 19)
|
||||
badge_box = draw.textbbox((0, 0), badge_text, font=badge_font)
|
||||
badge_width = badge_box[2] - badge_box[0]
|
||||
draw.rounded_rectangle((1120 - badge_width - 32, 61, 1132, 106), radius=7, fill="#ecfdf5")
|
||||
draw.text((1116, 73), badge_text, font=badge_font, fill=mint, anchor="ra")
|
||||
draw.line((68, 124, 1132, 124), fill="#e5edf5", width=2)
|
||||
|
||||
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)
|
||||
|
||||
trend = card.get("trend") or {}
|
||||
raw_points = trend.get("points") or []
|
||||
@@ -89,7 +94,11 @@ def render_card(card):
|
||||
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)
|
||||
draw.line((68, 536, 1132, 536), fill="#e5edf5", width=2)
|
||||
title = str(card.get("qrTitle") or "QR code")
|
||||
if len(title) > 52:
|
||||
title = title[:49].rstrip() + "..."
|
||||
draw.text((68, 558), title, font=medium, fill=navy)
|
||||
path = Path(tempfile.mkstemp(suffix=".png")[1])
|
||||
image.save(path, "PNG", optimize=True)
|
||||
return path
|
||||
|
||||
@@ -14,7 +14,10 @@ export default function SettingsPage() {
|
||||
const { fetchWithCsrf } = useCsrf();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('profile');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
const [socialPromptsEnabled, setSocialPromptsEnabled] = useState(true);
|
||||
const [socialTestResetAvailable, setSocialTestResetAvailable] = useState(false);
|
||||
const [socialSaving, setSocialSaving] = useState(false);
|
||||
|
||||
// Profile states
|
||||
const [name, setName] = useState('');
|
||||
@@ -49,10 +52,17 @@ export default function SettingsPage() {
|
||||
|
||||
// Fetch usage stats from API
|
||||
const statsResponse = await fetch('/api/user/stats');
|
||||
if (statsResponse.ok) {
|
||||
const data = await statsResponse.json();
|
||||
setUsageStats(data);
|
||||
}
|
||||
if (statsResponse.ok) {
|
||||
const data = await statsResponse.json();
|
||||
setUsageStats(data);
|
||||
}
|
||||
|
||||
const socialResponse = await fetch('/api/social-milestones/preferences');
|
||||
if (socialResponse.ok) {
|
||||
const data = await socialResponse.json();
|
||||
setSocialPromptsEnabled(data.promptsEnabled !== false);
|
||||
setSocialTestResetAvailable(data.testResetAvailable === true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load user data:', e);
|
||||
}
|
||||
@@ -92,7 +102,25 @@ export default function SettingsPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const updateSocialPrompts = async (action: 'enable' | 'disable' | 'reset_test') => {
|
||||
setSocialSaving(true);
|
||||
try {
|
||||
const response = await fetchWithCsrf('/api/social-milestones/preferences', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || 'Could not update milestone prompts');
|
||||
setSocialPromptsEnabled(data.promptsEnabled !== false);
|
||||
showToast(action === 'reset_test' ? 'Milestone test reset. Open the dashboard to test it again.' : 'Milestone preference updated.', 'success');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Could not update milestone prompts', 'error');
|
||||
} finally {
|
||||
setSocialSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleManageSubscription = async () => {
|
||||
setLoading(true);
|
||||
@@ -245,9 +273,32 @@ export default function SettingsPage() {
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Security */}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Milestone sharing</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="max-w-xl">
|
||||
<h3 className="text-sm font-medium text-gray-900">Show scan milestone prompts</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Choose whether QR Master may ask you to share verified scan achievements. Nothing is published without your confirmation.</p>
|
||||
</div>
|
||||
<Button variant="outline" disabled={socialSaving} onClick={() => updateSocialPrompts(socialPromptsEnabled ? 'disable' : 'enable')}>
|
||||
{socialPromptsEnabled ? 'Turn off' : 'Turn on'}
|
||||
</Button>
|
||||
</div>
|
||||
{socialTestResetAvailable && <div className="border-t border-gray-100 pt-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-gray-500">Test environment: reopen the latest milestone and clear its publishing state.</p>
|
||||
<Button variant="outline" disabled={socialSaving} onClick={() => updateSocialPrompts('reset_test')}>Reset milestone test</Button>
|
||||
</div>
|
||||
</div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Security */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Security</CardTitle>
|
||||
|
||||
@@ -3,13 +3,96 @@ import { db } from '@/lib/db';
|
||||
|
||||
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 } }) {
|
||||
const share = await db.socialMilestone.findFirst({
|
||||
where: { shareToken: params.token, publicShareApprovedAt: { not: null } },
|
||||
select: { cardData: true, language: true },
|
||||
});
|
||||
if (!share) return new Response('Not found', { status: 404, headers: { 'Cache-Control': 'no-store' } });
|
||||
const card = share.cardData as { qrTitle?: string; totalUniqueScans?: number; trend?: { periodDays: number; recentTotal: number } | null } | null;
|
||||
|
||||
const card = (share.cardData || {}) as Card;
|
||||
const german = share.language === 'de';
|
||||
return new ImageResponse(<div style={{ height: '100%', width: '100%', display: 'flex', background: '#f8fafc', padding: 46, color: '#061b31' }}><div style={{ display: 'flex', flexDirection: 'column', width: '100%', border: '2px solid #e2e8f0', borderRadius: 24, background: 'white', padding: 44 }}><div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 23, fontWeight: 700 }}><span>QR MASTER</span><span style={{ color: '#059669', background: '#ecfdf5', padding: '8px 14px', borderRadius: 8 }}>Verified milestone</span></div><div style={{ display: 'flex', flexDirection: 'column', marginTop: 58 }}><span style={{ fontSize: 20, color: '#94a3b8', fontWeight: 700 }}>TOTAL UNIQUE SCANS</span><span style={{ fontSize: 116, fontWeight: 700, letterSpacing: -5 }}>{(card?.totalUniqueScans || 0).toLocaleString(german ? 'de-DE' : 'en-US')}</span><span style={{ fontSize: 29, color: '#64748b' }}>{german ? 'eindeutige Scans' : 'unique scans'}</span></div><div style={{ display: 'flex', marginTop: 'auto', paddingTop: 28, borderTop: '2px solid #e2e8f0', justifyContent: 'space-between', fontSize: 25 }}><span>{card?.qrTitle || 'QR code'}</span><span style={{ color: '#0256ff' }}>{card?.trend ? `${card.trend.recentTotal} in ${card.trend.periodDays} days` : (german ? 'Erste Dynamik' : 'Early momentum')}</span></div></div></div>, { width: 1200, height: 630, headers: { 'Cache-Control': 'no-store' } });
|
||||
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={{ 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' } },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ type Props = { params: { token: string } };
|
||||
async function getShare(token: string) {
|
||||
return db.socialMilestone.findFirst({
|
||||
where: { shareToken: token, publicShareApprovedAt: { not: null } },
|
||||
select: { cardData: true, language: true },
|
||||
select: { cardData: true, language: true, publicShareApprovedAt: true },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,13 +20,17 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const title = share.language === 'de'
|
||||
? `${count.toLocaleString('de-DE')} eindeutige QR-Scans erreicht`
|
||||
: `${count.toLocaleString('en-US')} unique QR scans reached`;
|
||||
const description = share.language === 'de'
|
||||
? `${card?.qrTitle || 'Ein QR-Code'} hat einen verifizierten Scan-Meilenstein mit QR Master erreicht.`
|
||||
: `${card?.qrTitle || 'A QR code'} reached a verified scan milestone with QR Master.`;
|
||||
const url = `${getWwwOrigin()}/s/m/${params.token}`;
|
||||
const imageUrl = `${url}/og?v=${share.publicShareApprovedAt?.getTime() || 0}`;
|
||||
return {
|
||||
title,
|
||||
description: card?.qrTitle || 'A verified QR Master scan milestone.',
|
||||
description,
|
||||
robots: { index: false, follow: false },
|
||||
openGraph: { type: 'website', title, description: card?.qrTitle, url, images: [`${url}/og`] },
|
||||
twitter: { card: 'summary_large_image', title, description: card?.qrTitle, images: [`${url}/og`] },
|
||||
openGraph: { type: 'website', title, description, url, images: [{ url: imageUrl, width: 1200, height: 630, alt: title }] },
|
||||
twitter: { card: 'summary_large_image', title, description, images: [imageUrl] },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,5 +38,9 @@ export default async function SocialMilestoneSharePage({ params }: Props) {
|
||||
const share = await getShare(params.token);
|
||||
if (!share) notFound();
|
||||
const card = share.cardData as { qrTitle?: string; totalUniqueScans?: number } | null;
|
||||
return <main className="min-h-screen bg-slate-50 px-6 py-20 text-center text-[#061b31]"><div className="mx-auto max-w-xl rounded-xl border border-slate-200 bg-white p-10 shadow-[0_30px_45px_-30px_rgba(50,50,93,0.35)]"><p className="text-xs font-semibold tracking-[0.14em] text-[#0256ff]">QR MASTER · VERIFIED MILESTONE</p><h1 className="mt-5 text-5xl font-semibold tracking-[-0.05em] tabular-nums">{(card?.totalUniqueScans || 0).toLocaleString(share.language === 'de' ? 'de-DE' : 'en-US')}</h1><p className="mt-2 text-slate-500">{share.language === 'de' ? 'eindeutige Scans' : 'unique scans'}</p><p className="mt-8 text-lg font-medium">{card?.qrTitle}</p></div></main>;
|
||||
const imageUrl = `${getWwwOrigin()}/s/m/${params.token}/og?v=${share.publicShareApprovedAt?.getTime() || 0}`;
|
||||
const alt = share.language === 'de'
|
||||
? `${card?.qrTitle || 'QR-Code'}: ${(card?.totalUniqueScans || 0).toLocaleString('de-DE')} eindeutige Scans`
|
||||
: `${card?.qrTitle || 'QR code'}: ${(card?.totalUniqueScans || 0).toLocaleString('en-US')} unique scans`;
|
||||
return <main className="min-h-screen bg-[#f8f7f4] px-4 py-12 text-center text-[#061b31] sm:px-6 sm:py-20"><div className="mx-auto max-w-5xl"><img src={imageUrl} alt={alt} width={1200} height={630} className="h-auto w-full shadow-[0_30px_45px_-30px_rgba(50,50,93,0.35)]" /><p className="mt-6 text-sm text-slate-500">{share.language === 'de' ? 'Verifizierter Scan-Meilenstein von QR Master' : 'Verified scan milestone from QR Master'}</p></div></main>;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ export async function GET(request: NextRequest) {
|
||||
if (!isAuthorized(request)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const dryRun = request.nextUrl.searchParams.get('dryRun') === 'true';
|
||||
const now = Date.now();
|
||||
// A worker can be interrupted after claiming a row. Surface that state as a
|
||||
// retryable failure instead of leaving the dashboard in "processing" forever.
|
||||
await db.socialMilestone.updateMany({
|
||||
where: { brandStatus: 'processing', claimedAt: { lt: new Date(now - 5 * 60 * 1000) } },
|
||||
data: { brandStatus: 'failed', brandPostError: 'The publisher was interrupted before it confirmed the post. Please retry.' },
|
||||
});
|
||||
const approvalNotBefore = new Date(now - approvalDelayHours() * 60 * 60 * 1000);
|
||||
const milestone = await db.socialMilestone.findFirst({
|
||||
where: { brandStatus: 'approved', brandApprovedAt: { lte: approvalNotBefore } },
|
||||
|
||||
@@ -86,7 +86,7 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st
|
||||
where: { id: milestone.id },
|
||||
data: { selfSharedAt: now, publicShareApprovedAt: now, shareToken: token, cardData: card, language },
|
||||
});
|
||||
return NextResponse.json({ ok: true, shareToken: token, milestone: clientState(updated) });
|
||||
return NextResponse.json({ ok: true, shareToken: token, shareVersion: now.getTime(), milestone: clientState(updated) });
|
||||
}
|
||||
|
||||
if (!['pending', 'failed', 'revoked'].includes(milestone.brandStatus)) {
|
||||
|
||||
57
src/app/(main)/api/social-milestones/preferences/route.ts
Normal file
57
src/app/(main)/api/social-milestones/preferences/route.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSessionUserId } from '@/lib/session';
|
||||
import { getSocialMilestoneThresholds } from '@/lib/social-milestones';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function testResetAvailable() {
|
||||
return getSocialMilestoneThresholds().some(threshold => threshold < 100);
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const userId = getSessionUserId();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const user = await db.user.findUnique({ where: { id: userId }, select: { socialPromptOptOut: true } });
|
||||
if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
return NextResponse.json({ promptsEnabled: !user.socialPromptOptOut, testResetAvailable: testResetAvailable() });
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const csrf = csrfProtection(request);
|
||||
if (!csrf.valid) return NextResponse.json({ error: csrf.error }, { status: 403 });
|
||||
const userId = getSessionUserId();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const body = await request.json().catch(() => null) as { action?: 'enable' | 'disable' | 'reset_test' } | null;
|
||||
if (!body?.action || !['enable', 'disable', 'reset_test'].includes(body.action)) {
|
||||
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (body.action === 'reset_test') {
|
||||
if (!testResetAvailable()) return NextResponse.json({ error: 'Test reset is not available in this environment' }, { status: 403 });
|
||||
const latest = await db.socialMilestone.findFirst({
|
||||
where: { userId },
|
||||
orderBy: { detectedAt: 'desc' },
|
||||
select: { id: true },
|
||||
});
|
||||
await db.$transaction([
|
||||
db.user.update({ where: { id: userId }, data: { socialPromptOptOut: false } }),
|
||||
...(latest ? [db.socialMilestone.update({
|
||||
where: { id: latest.id },
|
||||
data: {
|
||||
status: 'detected', shownAt: null, respondedAt: null,
|
||||
brandStatus: 'pending', brandApprovedAt: null, claimedAt: null,
|
||||
brandPostedAt: null, brandPostUrl: null, brandPostError: null,
|
||||
consentText: null, withName: false,
|
||||
selfSharedAt: null, publicShareApprovedAt: null, shareToken: null,
|
||||
},
|
||||
})] : []),
|
||||
]);
|
||||
return NextResponse.json({ ok: true, promptsEnabled: true, resetMilestone: Boolean(latest) });
|
||||
}
|
||||
|
||||
const promptsEnabled = body.action === 'enable';
|
||||
await db.user.update({ where: { id: userId }, data: { socialPromptOptOut: !promptsEnabled } });
|
||||
return NextResponse.json({ ok: true, promptsEnabled });
|
||||
}
|
||||
@@ -70,10 +70,11 @@ export function SocialMilestoneDialog() {
|
||||
|
||||
const copy = milestone?.language === 'de'
|
||||
? { heading: 'Ein echter Erfolg', subtitle: 'hat einen Scan-Meilenstein erreicht.', consent: 'Darf QR Master diesen Erfolg auf dem eigenen X-Account veröffentlichen?', name: 'Meinen X-Handle nennen', decline: 'Nicht jetzt', self: 'Selbst teilen', approve: 'Auf QR Master posten', queued: 'Wird auf X veröffentlicht …', posted: 'Auf X veröffentlicht', failed: 'Veröffentlichung fehlgeschlagen' }
|
||||
: { heading: 'A real milestone', subtitle: 'just reached a scan milestone.', consent: 'May QR Master publish this success from our X account?', name: 'Mention my X handle', decline: 'Not now', self: 'Share myself', approve: 'Post from QR Master', queued: 'Publishing to X …', posted: 'Published on X', failed: 'Publishing failed' };
|
||||
: { heading: 'A real milestone', subtitle: 'just reached a scan milestone.', consent: 'May QR Master publish this success from our X account?', name: 'Mention my X handle', decline: 'Not now', self: 'Share myself', approve: 'Post from QR Master', queued: 'Waiting for the X publisher …', posted: 'Published on X', failed: 'Publishing failed' };
|
||||
const preview = useMemo(() => {
|
||||
if (!milestone || !withName || !xHandle.trim()) return milestone?.preview || '';
|
||||
return `${milestone.preview} By @${xHandle.trim().replace(/^@/, '')}.`;
|
||||
const mention = milestone.language === 'de' ? 'Glückwunsch' : 'Congratulations';
|
||||
return `${milestone.preview}\n\n${mention} @${xHandle.trim().replace(/^@/, '')}.`;
|
||||
}, [milestone, withName, xHandle]);
|
||||
const update = async (action: 'approve_brand' | 'self_share' | 'decline' | 'opt_out') => {
|
||||
if (!milestone) return null;
|
||||
@@ -91,22 +92,15 @@ export function SocialMilestoneDialog() {
|
||||
setSaving('self');
|
||||
try {
|
||||
const result = await update('self_share');
|
||||
const shareUrl = `${window.location.origin}/s/m/${result.shareToken}`;
|
||||
const text = `${preview} ${shareUrl}`;
|
||||
const shareUrl = `${window.location.origin}/s/m/${result.shareToken}?v=${result.shareVersion}`;
|
||||
const text = `${preview}\n\n${shareUrl}`;
|
||||
const targetUrl = network === 'x'
|
||||
? `https://x.com/intent/post?text=${encodeURIComponent(text)}`
|
||||
: `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`;
|
||||
if (network === 'x') {
|
||||
if (shareWindow) shareWindow.location.href = targetUrl;
|
||||
else window.location.assign(targetUrl);
|
||||
}
|
||||
else {
|
||||
await navigator.clipboard?.writeText(text);
|
||||
if (shareWindow) shareWindow.location.href = targetUrl;
|
||||
else window.location.assign(targetUrl);
|
||||
}
|
||||
if (shareWindow) shareWindow.location.href = targetUrl;
|
||||
else window.location.assign(targetUrl);
|
||||
setBrand(result.milestone);
|
||||
showToast(network === 'linkedin' ? 'Share text copied and LinkedIn opened.' : 'X share composer opened.', 'success');
|
||||
showToast(network === 'linkedin' ? 'LinkedIn share window opened.' : 'X share composer opened.', 'success');
|
||||
} catch (error) { shareWindow?.close(); showToast(error instanceof Error ? error.message : 'Could not prepare this share', 'error'); }
|
||||
finally { setSaving(null); }
|
||||
};
|
||||
@@ -122,11 +116,18 @@ export function SocialMilestoneDialog() {
|
||||
const dismiss = async (action: 'decline' | 'opt_out') => {
|
||||
try { await update(action); setMilestone(null); } catch (error) { showToast(error instanceof Error ? error.message : 'Could not save your choice', 'error'); }
|
||||
};
|
||||
const optOut = () => {
|
||||
const message = milestone?.language === 'de'
|
||||
? 'Meilenstein-Hinweise dauerhaft ausblenden? Du kannst sie später in den Einstellungen wieder aktivieren.'
|
||||
: 'Turn off milestone prompts? You can enable them again later in Settings.';
|
||||
if (window.confirm(message)) void dismiss('opt_out');
|
||||
};
|
||||
|
||||
if (!milestone) return null;
|
||||
const card = milestone.card;
|
||||
const count = card.totalUniqueScans || milestone.threshold;
|
||||
const status = brand?.brandStatus || milestone.brandStatus || 'pending';
|
||||
const canApprove = ['pending', 'failed', 'revoked'].includes(status);
|
||||
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)]">
|
||||
<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>
|
||||
@@ -137,13 +138,13 @@ export function SocialMilestoneDialog() {
|
||||
{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>
|
||||
</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>
|
||||
<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={status !== 'pending'} 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={status !== 'pending'} 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><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={!canApprove} 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={!canApprove} 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="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={() => shareSelf('linkedin')} disabled={saving !== null}><Linkedin className="mr-1.5 h-3.5 w-3.5" />LinkedIn</Button></div>
|
||||
{status !== 'pending' && <div className={`flex items-center justify-between 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 : copy.queued}</span>{brand?.brandPostUrl && <a href={brand.brandPostUrl} target="_blank" rel="noreferrer" className="inline-flex 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>
|
||||
<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={() => dismiss('decline')} disabled={saving !== null}>{copy.decline}</Button><Button variant="primary" onClick={approveBrand} disabled={saving !== null || status !== 'pending'}><Send className="mr-1.5 h-4 w-4" />{status === 'approved' || status === 'processing' ? copy.queued : copy.approve}</Button><button type="button" className="w-full pt-1 text-xs text-slate-500 underline underline-offset-2 hover:text-slate-700" onClick={() => dismiss('opt_out')} disabled={saving !== null}>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={() => dismiss('decline')} 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><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>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
|
||||
@@ -84,11 +84,17 @@ export function buildMilestoneCard(primaryUseCase: string | null, threshold: num
|
||||
|
||||
export function buildMilestonePostForQr(primaryUseCase: string | null, totalUniqueScans: number, xHandle: string | null | undefined, locale: SocialLocale, qrTitle: string): string {
|
||||
const count = totalUniqueScans.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US');
|
||||
const subject = qrTitle.trim() || usageLabel(primaryUseCase, locale);
|
||||
const rawSubject = qrTitle.trim() || usageLabel(primaryUseCase, locale);
|
||||
const subject = rawSubject.length > 72 ? `${rawSubject.slice(0, 69).trimEnd()}…` : rawSubject;
|
||||
const scanLabel = locale === 'de'
|
||||
? `${count} ${totalUniqueScans === 1 ? 'verifizierten eindeutigen Scan' : 'verifizierte eindeutige Scans'}`
|
||||
: `${count} verified unique ${totalUniqueScans === 1 ? 'scan' : 'scans'}`;
|
||||
const base = locale === 'de'
|
||||
? `„${subject}“ hat ${count} eindeutige Scans erreicht.`
|
||||
: `“${subject}” reached ${count} unique scans.`;
|
||||
return xHandle ? `${base} By @${xHandle.replace(/^@/, '')}.` : base;
|
||||
? `QR-Meilenstein erreicht.\n\n„${subject}“ hat ${scanLabel} erzielt.\n\nErstellt und gemessen mit QR Master.`
|
||||
: `QR milestone unlocked.\n\n“${subject}” has reached ${scanLabel}.\n\nCreated and measured with QR Master.`;
|
||||
if (!xHandle) return base;
|
||||
const mention = locale === 'de' ? 'Glückwunsch' : 'Congratulations';
|
||||
return `${base}\n\n${mention} @${xHandle.replace(/^@/, '')}.`;
|
||||
}
|
||||
|
||||
export function buildMilestoneCardSnapshot(input: {
|
||||
|
||||
Reference in New Issue
Block a user