export const DEFAULT_SOCIAL_MILESTONE_THRESHOLDS = [1000, 10000] as const; export type SocialMilestoneKind = `unique_scans_${number}`; /** * Staging can set SOCIAL_MILESTONE_THRESHOLDS=1 (or e.g. 1,2) so the complete * flow is testable without fabricating thousands of scans. Production keeps * the conservative defaults unless its environment explicitly changes them. */ export function getSocialMilestoneThresholds(): number[] { const configured = process.env.SOCIAL_MILESTONE_THRESHOLDS; if (!configured) return [...DEFAULT_SOCIAL_MILESTONE_THRESHOLDS]; const thresholds = Array.from(new Set( configured.split(',') .map(value => Number(value.trim())) .filter(value => Number.isInteger(value) && value > 0 && value <= 1_000_000) )).sort((a, b) => a - b); return thresholds.length ? thresholds : [...DEFAULT_SOCIAL_MILESTONE_THRESHOLDS]; } /** * Channels the consent dialog may offer. A channel is only offered where a * publisher is actually configured - asking for consent for a post that nobody * can publish would be dishonest. Server-side read; the dialog receives the * resulting list in its payload. */ export function getEnabledSocialChannels(): SocialChannel[] { const configured = (process.env.SOCIAL_MILESTONE_CHANNELS || 'x') .split(',').map(value => value.trim().toLowerCase()).filter(isSocialChannel); return configured.length ? Array.from(new Set(configured)) : ['x']; } const useCaseLabels: Record = { menu_pdf: 'menu QR code', marketing_campaign: 'campaign QR code', vcard: 'digital business-card QR code', event: 'event QR code', feedback: 'feedback QR code', }; export function milestoneKind(threshold: number): SocialMilestoneKind { return `unique_scans_${threshold}` as SocialMilestoneKind; } export function milestoneThreshold(kind: string): number | null { const result = /^unique_scans_(\d+)$/.exec(kind); return result ? Number(result[1]) : null; } export type SocialLocale = 'en' | 'de'; export type SocialMilestoneCard = { version: 'milestone-card-v2'; language: SocialLocale; qrTitle: string; label: string; title: string; totalScans: number; totalUniqueScans: number; milestoneThreshold: number; reachedAt: string; trend: { points: Array<{ at: string; total: number }>; startLabel: string; endLabel: string; target: number; ceiling: number; } | null; }; export function isCompleteSocialMilestoneCard(value: unknown): value is SocialMilestoneCard { if (!value || typeof value !== 'object') return false; const card = value as Partial; const trend = card.trend; return card.version === 'milestone-card-v2' && typeof card.qrTitle === 'string' && typeof card.totalScans === 'number' && Number.isFinite(card.totalScans) && typeof card.totalUniqueScans === 'number' && Number.isFinite(card.totalUniqueScans) && Boolean(trend) && Array.isArray(trend?.points) && trend.points.length >= 2 && trend.points.every(point => typeof point?.at === 'string' && typeof point?.total === 'number'); } export function socialLocale(value?: string | null): SocialLocale { return value === 'de' ? 'de' : 'en'; } export function usageLabel(primaryUseCase: string | null, locale: SocialLocale = 'en'): string { if (locale === 'de') { const german: Record = { menu_pdf: 'Speisekarten-QR-Code', marketing_campaign: 'Kampagnen-QR-Code', vcard: 'Visitenkarten-QR-Code', event: 'Event-QR-Code', feedback: 'Feedback-QR-Code' }; return (primaryUseCase && german[primaryUseCase]) || 'QR-Code'; } return (primaryUseCase && useCaseLabels[primaryUseCase]) || 'QR code'; } export function buildMilestonePost(primaryUseCase: string | null, threshold: number, xHandle?: string | null, locale: SocialLocale = 'en'): string { const count = threshold.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US'); const base = locale === 'de' ? `Ein ${usageLabel(primaryUseCase, locale)} hat gerade ${count} eindeutige Scans erreicht. 🎉` : `A ${usageLabel(primaryUseCase, locale)} just reached ${count} unique scans. 🎉`; return xHandle ? `${base} By @${xHandle.replace(/^@/, '')}.` : base; } export function buildMilestoneCard(primaryUseCase: string | null, threshold: number, locale: SocialLocale) { return { version: 'milestone-card-v1', language: locale, threshold, label: usageLabel(primaryUseCase, locale), title: locale === 'de' ? 'Erfolgsmeilenstein' : 'Success milestone' }; } 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 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' ? `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: { primaryUseCase: string | null; qrTitle: string; totalScans: number; totalUniqueScans: number; milestoneThreshold: number; reachedAt: Date; trend: SocialMilestoneCard['trend']; locale: SocialLocale; }): SocialMilestoneCard { return { version: 'milestone-card-v2', language: input.locale, 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(), trend: input.trend, }; } export function normalizeXHandle(value: string): string | null { const handle = value.trim().replace(/^@/, ''); return /^[A-Za-z0-9_]{1,15}$/.test(handle) ? handle : null; } export function normalizeInstagramHandle(value: string): string | null { const handle = value.trim().replace(/^@/, ''); return /^[A-Za-z0-9._]{1,30}$/.test(handle) ? handle : null; } /** * Publishing channels of the QR Master brand accounts. * * Consent is bound to a channel: agreeing to a post on X says nothing about * Instagram. Every channel therefore carries its own approval, its own text and * its own handle. */ export const SOCIAL_CHANNELS = ['x', 'instagram'] as const; export type SocialChannel = typeof SOCIAL_CHANNELS[number]; export function isSocialChannel(value: unknown): value is SocialChannel { return typeof value === 'string' && (SOCIAL_CHANNELS as readonly string[]).includes(value); } export function normalizeChannelHandle(channel: SocialChannel, value: string): string | null { return channel === 'instagram' ? normalizeInstagramHandle(value) : normalizeXHandle(value); } function instagramHashtags(locale: SocialLocale): string { return locale === 'de' ? '#qrcode #qrcodes #marketing #kleinunternehmen #analytics #digitalisierung' : '#qrcode #qrcodes #qrcodemarketing #smallbusiness #marketing #analytics'; } /** * The post split into the parts the consent dialog recombines while the * customer types a handle. Server and client must never build this text * differently - what stands in the preview is what gets published. */ export function milestonePostParts(input: { channel: SocialChannel; primaryUseCase: string | null; totalUniqueScans: number; locale: SocialLocale; qrTitle: string; shareUrl: string; }) { return { head: buildMilestonePostForQr(input.primaryUseCase, input.totalUniqueScans, null, input.locale, input.qrTitle), // A link in an Instagram caption is not clickable, so the share URL would // be dead weight there. Hashtags do the reach work instead. tail: input.channel === 'instagram' ? `\n\n${instagramHashtags(input.locale)}` : `\n\n${input.shareUrl}`, mentionWord: input.locale === 'de' ? 'Glückwunsch' : 'Congratulations', }; } export function buildChannelPost(input: Parameters[0] & { handle: string | null }): string { const { head, tail, mentionWord } = milestonePostParts(input); const mention = input.handle ? `\n\n${mentionWord} @${input.handle.replace(/^@/, '')}.` : ''; return `${head}${mention}${tail}`; }