diff --git a/docker-compose.yml b/docker-compose.yml index 6580a69..5304638 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,7 +39,7 @@ services: - qrmaster-network # Next.js Application - web: + web: build: context: . dockerfile: Dockerfile @@ -62,6 +62,9 @@ services: COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-} INTERNAL_API_SECRET: ${INTERNAL_API_SECRET} + CRON_SECRET: ${CRON_SECRET:-} + SOCIAL_MILESTONE_THRESHOLDS: ${SOCIAL_MILESTONE_THRESHOLDS:-} + SOCIAL_MILESTONE_POST_DELAY_HOURS: ${SOCIAL_MILESTONE_POST_DELAY_HOURS:-} TIKTOK_CLIENT_KEY: ${TIKTOK_CLIENT_KEY:-} TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-} TIKTOK_REDIRECT_URI: ${TIKTOK_REDIRECT_URI:-https://qrmaster.net/api/tiktok/callback} @@ -111,8 +114,27 @@ services: interval: 10s timeout: 3s retries: 10 - networks: - - qrmaster-network + networks: + - qrmaster-network + + social-worker: + build: + context: ./scripts/social-worker + restart: unless-stopped + environment: + QRMASTER_API_BASE: http://web:3000 + INTERNAL_API_SECRET: ${INTERNAL_API_SECRET} + SOCIAL_MILESTONE_POSTING_ENABLED: ${SOCIAL_MILESTONE_POSTING_ENABLED:-false} + SOCIAL_WORKER_INTERVAL_SECONDS: ${SOCIAL_WORKER_INTERVAL_SECONDS:-10} + X_API_KEY: ${X_API_KEY:-} + X_API_SECRET: ${X_API_SECRET:-} + X_ACCESS_TOKEN: ${X_ACCESS_TOKEN:-} + X_ACCESS_TOKEN_SECRET: ${X_ACCESS_TOKEN_SECRET:-} + depends_on: + web: + condition: service_started + networks: + - qrmaster-network # Adminer - Database Management UI (Optional) diff --git a/docs/automations/social-milestone-worker.md b/docs/automations/social-milestone-worker.md new file mode 100644 index 0000000..36315bb --- /dev/null +++ b/docs/automations/social-milestone-worker.md @@ -0,0 +1,49 @@ +# Social milestone worker + +The app detects QR-code scan milestones and stores customer consent. It does not +hold X or LinkedIn credentials. An external X worker can use the internal queue +after the test rollout is approved. + +## Test setup (manual SQL only) + +1. Apply [`sql/2026-08-13_social_milestones.sql`](../../sql/2026-08-13_social_milestones.sql) + to `qrmaster_test`. +2. Set distinct `CRON_SECRET` and `INTERNAL_API_SECRET` values in `.env.test`. + For an end-to-end test without 1,000 scans, also set + `SOCIAL_MILESTONE_THRESHOLDS=1` (or `1,2`). Do not set this on production. + Publishing is immediate after consent by default. Set + `SOCIAL_MILESTONE_POST_DELAY_HOURS=24` only if a revocation window is desired. +3. Deploy using the documented test compose command. `CRON_SECRET` is forwarded + to the web service by `docker-compose.yml`. +4. Trigger detection manually: + +```bash +curl -H "Authorization: Bearer $CRON_SECRET" \ + https://testmodul.qrmaster.net/api/cron/social-milestones +``` + +The detector creates records at 1,000 and 10,000 unique scans only. It is safe +to call repeatedly because `(qrId, kind)` is unique. + +## X worker contract + +After an explicit rollout approval, the existing QRMaster X worker may poll: + +```bash +curl -H "Authorization: Bearer $INTERNAL_API_SECRET" \ + https://qrmaster.net/api/internal/social-milestones +``` + +It receives at most one approved item per 24 hours. The worker must post `milestone.text` +without altering it, then report its result: + +```bash +curl -X PATCH -H "Authorization: Bearer $INTERNAL_API_SECRET" \ + -H "Content-Type: application/json" \ + -d '{"id":"","result":"posted"}' \ + https://qrmaster.net/api/internal/social-milestones +``` + +Do not configure this worker against `testmodul`. LinkedIn has no approved +brand-posting integration in this project. Customers can self-share: X opens a +prefilled intent; the same text is copied for a LinkedIn post. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 36406e2..cb6cbbe 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -89,6 +89,12 @@ model User { accounts Account[] sessions Session[] lifecycleLogs UserLifecycleLog[] + socialMilestones SocialMilestone[] + + // Social-success sharing preferences. A post is still never published + // without a per-milestone approval stored below. + xHandle String? + socialPromptOptOut Boolean @default(false) } enum Plan { @@ -148,11 +154,37 @@ model QRCode { user User @relation(fields: [userId], references: [id], onDelete: Cascade) scans QRScan[] + socialMilestones SocialMilestone[] @@index([userId, createdAt]) @@index([userId, type, status]) } +model SocialMilestone { + id String @id @default(cuid()) + qrId String + userId String + kind String + status String @default("detected") + detectedAt DateTime @default(now()) + shownAt DateTime? + respondedAt DateTime? + claimedAt DateTime? + postedAt DateTime? + withName Boolean @default(false) + consentText String? + language String @default("en") + cardData Json? + + qr QRCode @relation(fields: [qrId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([qrId, kind]) + @@index([status, respondedAt]) + @@index([status, claimedAt]) + @@index([userId, status]) +} + enum QRType { STATIC DYNAMIC diff --git a/scripts/social-worker/Dockerfile b/scripts/social-worker/Dockerfile new file mode 100644 index 0000000..d3d3458 --- /dev/null +++ b/scripts/social-worker/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.12-slim +WORKDIR /worker +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY worker.py . +CMD ["python", "worker.py"] diff --git a/scripts/social-worker/requirements.txt b/scripts/social-worker/requirements.txt new file mode 100644 index 0000000..8d2307a --- /dev/null +++ b/scripts/social-worker/requirements.txt @@ -0,0 +1,3 @@ +Pillow>=10 +requests>=2.31 +requests-oauthlib>=2.0 diff --git a/scripts/social-worker/worker.py b/scripts/social-worker/worker.py new file mode 100644 index 0000000..7561ddc --- /dev/null +++ b/scripts/social-worker/worker.py @@ -0,0 +1,86 @@ +"""Always-on QRMaster X milestone worker. The web app never receives X keys.""" +import json +import os +import tempfile +import time +from pathlib import Path + +import requests +from PIL import Image, ImageDraw, ImageFont +from requests_oauthlib import OAuth1Session + + +def required(name): + value = os.getenv(name, "").strip() + if not value: + raise RuntimeError(f"Missing {name}") + return value + + +def api(method, url, payload=None): + response = requests.request(method, url, json=payload, headers={"Authorization": f"Bearer {required('INTERNAL_API_SECRET')}"}, timeout=30) + response.raise_for_status() + return response.json() + + +def render_card(card): + image = Image.new("RGB", (1200, 675), "#061b31") + draw = ImageDraw.Draw(image) + fonts = Path("/usr/share/fonts/truetype/dejavu") + bold = ImageFont.truetype(str(fonts / "DejaVuSans-Bold.ttf"), 112) + regular = ImageFont.truetype(str(fonts / "DejaVuSans.ttf"), 34) + image_draw = draw + image_draw.rounded_rectangle((55, 55, 1145, 620), radius=28, outline="#304866", width=2) + image_draw.text((100, 105), "QR MASTER", font=regular, fill="#dce8f7") + image_draw.text((100, 210), f"{card['threshold']:,}", font=bold, fill="#ffffff") + scans = "eindeutige Scans" if card.get("language") == "de" else "unique scans" + image_draw.text((105, 350), scans, font=regular, fill="#b8c7da") + image_draw.line((100, 500, 1100, 500), fill="#304866", width=2) + image_draw.text((100, 535), f"{card['label']} ยท {card['title']}", font=regular, fill="#dce8f7") + path = Path(tempfile.mkstemp(suffix=".png")[1]) + image.save(path, "PNG", optimize=True) + return path + + +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 + try: + media_id = None + if path: + with path.open("rb") as image: + upload = oauth.post("https://upload.x.com/1.1/media/upload.json", files={"media": image}, timeout=60) + upload.raise_for_status() + media_id = upload.json()["media_id_string"] + payload = {"text": text} + if media_id: + payload["media"] = {"media_ids": [media_id]} + result = oauth.post("https://api.x.com/2/tweets", json=payload, timeout=30) + result.raise_for_status() + return result.json() + finally: + if path: + path.unlink(missing_ok=True) + + +def run_once(): + base = required("QRMASTER_API_BASE").rstrip("/") + "/api/internal/social-milestones" + milestone = api("GET", base).get("milestone") + if not milestone: + return + try: + result = post_x(milestone["text"], milestone.get("card")) + api("PATCH", base, {"id": milestone["id"], "result": "posted"}) + print(json.dumps({"posted": milestone["id"], "x": result}), flush=True) + except Exception as error: + api("PATCH", base, {"id": milestone["id"], "result": "failed"}) + print(f"Milestone post failed: {error}", flush=True) + + +if __name__ == "__main__": + interval = max(5, int(os.getenv("SOCIAL_WORKER_INTERVAL_SECONDS", "10"))) + if os.getenv("SOCIAL_MILESTONE_POSTING_ENABLED", "").lower() not in {"true", "1", "yes"}: + raise RuntimeError("Set SOCIAL_MILESTONE_POSTING_ENABLED=true to run this worker") + while True: + run_once() + time.sleep(interval) diff --git a/sql/2026-08-13_social_milestones.sql b/sql/2026-08-13_social_milestones.sql new file mode 100644 index 0000000..3d56422 --- /dev/null +++ b/sql/2026-08-13_social_milestones.sql @@ -0,0 +1,30 @@ +-- Success-sharing milestones. Run once against the target database before +-- deploying the application version that uses this feature. +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "xHandle" TEXT; +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "socialPromptOptOut" BOOLEAN NOT NULL DEFAULT false; + +CREATE TABLE IF NOT EXISTS "SocialMilestone" ( + "id" TEXT PRIMARY KEY, + "qrId" TEXT NOT NULL REFERENCES "QRCode"("id") ON DELETE CASCADE, + "userId" TEXT NOT NULL REFERENCES "User"("id") ON DELETE CASCADE, + "kind" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'detected', + "detectedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "shownAt" TIMESTAMP(3), + "respondedAt" TIMESTAMP(3), + "postedAt" TIMESTAMP(3), + "withName" BOOLEAN NOT NULL DEFAULT false, + "consentText" TEXT, + CONSTRAINT "SocialMilestone_qr_kind_key" UNIQUE ("qrId", "kind") +); + +CREATE INDEX IF NOT EXISTS "SocialMilestone_status_respondedAt_idx" + ON "SocialMilestone" ("status", "respondedAt"); +CREATE INDEX IF NOT EXISTS "SocialMilestone_userId_status_idx" + ON "SocialMilestone" ("userId", "status"); + +ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "claimedAt" TIMESTAMP(3); +ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "language" TEXT NOT NULL DEFAULT 'en'; +ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "cardData" JSONB; +CREATE INDEX IF NOT EXISTS "SocialMilestone_status_claimedAt_idx" + ON "SocialMilestone" ("status", "claimedAt"); diff --git a/src/app/(main)/(app)/dashboard/page.tsx b/src/app/(main)/(app)/dashboard/page.tsx index 2aa229e..4820b89 100644 --- a/src/app/(main)/(app)/dashboard/page.tsx +++ b/src/app/(main)/(app)/dashboard/page.tsx @@ -16,6 +16,7 @@ import { QrCode } from 'lucide-react'; import { trackEvent, identifyUser } from '@/components/PostHogProvider'; import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans'; import { OnboardingChecklist } from '@/components/dashboard/OnboardingChecklist'; +import { SocialMilestoneDialog } from '@/components/dashboard/SocialMilestoneDialog'; interface QRCodeData { id: string; @@ -322,6 +323,7 @@ export default function DashboardPage() { return (
+ {/* Header with Plan Badge */}
diff --git a/src/app/(main)/api/cron/social-milestones/route.ts b/src/app/(main)/api/cron/social-milestones/route.ts new file mode 100644 index 0000000..a546a9d --- /dev/null +++ b/src/app/(main)/api/cron/social-milestones/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSocialMilestoneThresholds, milestoneKind } from '@/lib/social-milestones'; + +export const dynamic = 'force-dynamic'; + +function isAuthorized(request: NextRequest) { + const secret = process.env.CRON_SECRET; + return Boolean(secret) && request.headers.get('authorization') === `Bearer ${secret}`; +} + +function excludedEmails() { + return (process.env.SOCIAL_MILESTONE_EXCLUDED_EMAILS || '') + .split(',').map(email => email.trim().toLowerCase()).filter(Boolean); +} + +// Detection only: this route never contacts customers or an external network. +export async function GET(request: NextRequest) { + if (!isAuthorized(request)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const excluded = excludedEmails(); + const thresholds = getSocialMilestoneThresholds(); + const candidates = await db.qRScan.groupBy({ + by: ['qrId'], + where: { + isUnique: true, + qr: { user: excluded.length ? { email: { notIn: excluded, mode: 'insensitive' } } : undefined }, + }, + _count: { _all: true }, + }); + + const records = candidates.flatMap(({ qrId, _count }) => + thresholds + .filter(threshold => _count._all >= threshold) + .map(threshold => ({ qrId, kind: milestoneKind(threshold) })) + ); + + if (records.length) { + const qrs = await db.qRCode.findMany({ + where: { id: { in: Array.from(new Set(records.map(record => record.qrId))) } }, + select: { id: true, userId: true }, + }); + const userIdByQr = new Map(qrs.map(qr => [qr.id, qr.userId])); + await db.socialMilestone.createMany({ + data: records + .filter(record => userIdByQr.has(record.qrId)) + .map(record => ({ ...record, userId: userIdByQr.get(record.qrId)! })), + skipDuplicates: true, + }); + } + + return NextResponse.json({ ok: true, detected: records.length, thresholds }); +} diff --git a/src/app/(main)/api/internal/social-milestones/route.ts b/src/app/(main)/api/internal/social-milestones/route.ts new file mode 100644 index 0000000..52d3a57 --- /dev/null +++ b/src/app/(main)/api/internal/social-milestones/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +export const dynamic = 'force-dynamic'; + +function isAuthorized(request: NextRequest) { + const secret = process.env.INTERNAL_API_SECRET; + return Boolean(secret) && request.headers.get('authorization') === `Bearer ${secret}`; +} + +function approvalDelayHours() { + const configured = Number(process.env.SOCIAL_MILESTONE_POST_DELAY_HOURS); + return Number.isFinite(configured) && configured >= 0 && configured <= 168 ? configured : 0; +} + +// This endpoint is intentionally a queue, not a social-media client. The +// external X worker fetches an approved payload and marks it complete only +// after its own post succeeded. The app never receives X credentials. +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(); + const dayAgo = new Date(now - 24 * 60 * 60 * 1000); + const approvalNotBefore = new Date(now - approvalDelayHours() * 60 * 60 * 1000); + const postedToday = await db.socialMilestone.count({ where: { OR: [{ status: 'posted', postedAt: { gte: dayAgo } }, { status: 'processing', claimedAt: { gte: dayAgo } }] } }); + if (postedToday > 0) return NextResponse.json({ milestone: null, reason: 'daily_limit' }); + + const milestone = await db.socialMilestone.findFirst({ + where: { status: 'approved', respondedAt: { lte: approvalNotBefore } }, + orderBy: { respondedAt: 'asc' }, + include: { user: { select: { id: true } }, qr: { select: { id: true, status: true } } }, + }); + // Relations are required by the schema. This guard makes the intended + // revalidation explicit if retention policies are changed later. + if (!milestone || !milestone.user || !milestone.qr || milestone.qr.status !== 'ACTIVE') { + return NextResponse.json({ milestone: null }); + } + if (dryRun) return NextResponse.json({ milestone: { id: milestone.id, text: milestone.consentText }, dryRun: true }); + + // A claimed item also occupies the daily slot. This prevents two workers + // from each claiming a different milestone before either one posts. + const claimed = await db.$transaction(async (tx) => { + await tx.$queryRawUnsafe('SELECT pg_advisory_xact_lock(920241)'); + const occupied = await tx.socialMilestone.count({ where: { OR: [{ status: 'posted', postedAt: { gte: dayAgo } }, { status: 'processing', claimedAt: { gte: dayAgo } }] } }); + if (occupied) return 0; + const result = await tx.socialMilestone.updateMany({ + where: { id: milestone.id, status: 'approved' }, data: { status: 'processing', claimedAt: new Date() }, + }); + return result.count; + }); + if (!claimed) return NextResponse.json({ milestone: null, reason: 'claimed' }); + return NextResponse.json({ milestone: { id: milestone.id, text: milestone.consentText, card: milestone.cardData } }); +} + +export async function PATCH(request: NextRequest) { + if (!isAuthorized(request)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const body = await request.json().catch(() => null) as { id?: string; result?: 'posted' | 'failed' } | null; + if (!body?.id || !['posted', 'failed'].includes(body.result || '')) return NextResponse.json({ error: 'Invalid payload' }, { status: 400 }); + const updated = await db.socialMilestone.updateMany({ + where: { id: body.id, status: 'processing' }, + data: { status: body.result!, postedAt: body.result === 'posted' ? new Date() : null }, + }); + if (!updated.count) return NextResponse.json({ error: 'Milestone is no longer available' }, { status: 409 }); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/(main)/api/social-milestones/[id]/route.ts b/src/app/(main)/api/social-milestones/[id]/route.ts new file mode 100644 index 0000000..117db86 --- /dev/null +++ b/src/app/(main)/api/social-milestones/[id]/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { csrfProtection } from '@/lib/csrf'; +import { getSessionUserId } from '@/lib/session'; +import { buildMilestoneCard, buildMilestonePost, milestoneThreshold, normalizeXHandle, socialLocale } from '@/lib/social-milestones'; + +type Action = 'approve_brand' | 'self_share' | 'decline' | 'opt_out' | 'revoke'; + +export async function PATCH(request: NextRequest, { params }: { params: { id: string } }) { + 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?: Action; withName?: boolean; xHandle?: string; language?: string } | null; + if (!body || !['approve_brand', 'self_share', 'decline', 'opt_out', 'revoke'].includes(body.action || '')) { + return NextResponse.json({ error: 'Invalid action' }, { status: 400 }); + } + const milestone = await db.socialMilestone.findFirst({ + where: { id: params.id, userId }, include: { user: { select: { primaryUseCase: true } } }, + }); + if (!milestone) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + const threshold = milestoneThreshold(milestone.kind); + if (!threshold) return NextResponse.json({ error: 'Invalid milestone' }, { status: 400 }); + + if (body.action === 'revoke') { + if (milestone.status !== 'approved') return NextResponse.json({ error: 'Only queued approvals can be revoked' }, { status: 409 }); + await db.socialMilestone.update({ where: { id: milestone.id }, data: { status: 'revoked', respondedAt: new Date() } }); + return NextResponse.json({ ok: true }); + } + if (!['detected', 'shown'].includes(milestone.status)) return NextResponse.json({ error: 'This milestone has already been answered' }, { status: 409 }); + + const withName = body.action === 'approve_brand' && body.withName === true; + const language = socialLocale(body.language); + const xHandle = withName ? normalizeXHandle(body.xHandle || '') : null; + if (withName && !xHandle) return NextResponse.json({ error: 'Enter a valid X handle' }, { status: 400 }); + const status = body.action === 'approve_brand' ? 'approved' : body.action === 'self_share' ? 'self_shared' : 'declined'; + const consentText = body.action === 'approve_brand' + ? buildMilestonePost(milestone.user.primaryUseCase, threshold, xHandle, language) + : null; + const now = new Date(); + await db.$transaction([ + db.socialMilestone.update({ where: { id: milestone.id }, data: { status, withName, consentText, language, cardData: body.action === 'approve_brand' ? buildMilestoneCard(milestone.user.primaryUseCase, threshold, language) : undefined, respondedAt: now } }), + ...(body.action === 'opt_out' ? [db.user.update({ where: { id: userId }, data: { socialPromptOptOut: true } })] : []), + ...(withName ? [db.user.update({ where: { id: userId }, data: { xHandle } })] : []), + ]); + return NextResponse.json({ ok: true, consentText }); +} diff --git a/src/app/(main)/api/social-milestones/route.ts b/src/app/(main)/api/social-milestones/route.ts new file mode 100644 index 0000000..40daa60 --- /dev/null +++ b/src/app/(main)/api/social-milestones/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSessionUserId } from '@/lib/session'; +import { buildMilestoneCard, buildMilestonePost, milestoneThreshold, socialLocale } from '@/lib/social-milestones'; + +export const dynamic = 'force-dynamic'; + +// Returns at most one item. A missing response is treated as no consent, never as approval. +export async function GET(request: NextRequest) { + const userId = getSessionUserId(); + if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const user = await db.user.findUnique({ + where: { id: userId }, select: { socialPromptOptOut: true, xHandle: true, primaryUseCase: true }, + }); + if (!user || user.socialPromptOptOut) return NextResponse.json({ milestone: null }); + + const milestone = await db.socialMilestone.findFirst({ + where: { userId, status: { in: ['detected', 'shown'] } }, + orderBy: { detectedAt: 'asc' }, + include: { qr: { select: { title: true } } }, + }); + if (!milestone) return NextResponse.json({ milestone: null }); + + if (milestone.status === 'detected') { + await db.socialMilestone.update({ where: { id: milestone.id }, data: { status: 'shown', shownAt: new Date() } }); + } + const threshold = milestoneThreshold(milestone.kind); + if (!threshold) return NextResponse.json({ milestone: null }); + const locale = socialLocale(request.nextUrl.searchParams.get('locale')); + + return NextResponse.json({ + milestone: { + id: milestone.id, qrTitle: milestone.qr.title, threshold, + defaultXHandle: user.xHandle, + language: locale, + preview: buildMilestonePost(user.primaryUseCase, threshold, null, locale), + card: buildMilestoneCard(user.primaryUseCase, threshold, locale), + }, + }); +} diff --git a/src/components/dashboard/SocialMilestoneDialog.tsx b/src/components/dashboard/SocialMilestoneDialog.tsx new file mode 100644 index 0000000..c640fee --- /dev/null +++ b/src/components/dashboard/SocialMilestoneDialog.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { QrCode, Sparkles } from 'lucide-react'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/Dialog'; +import { Button } from '@/components/ui/Button'; +import { useCsrf } from '@/hooks/useCsrf'; +import { useTranslation } from '@/hooks/useTranslation'; +import { showToast } from '@/components/ui/Toast'; + +type Milestone = { + id: string; + qrTitle: string; + threshold: number; + defaultXHandle: string | null; + preview: string; + language: 'en' | 'de'; + card: { title: string; label: string }; +}; + +export function SocialMilestoneDialog() { + const { fetchWithCsrf } = useCsrf(); + const { locale } = useTranslation(); + const [milestone, setMilestone] = useState(null); + const [withName, setWithName] = useState(false); + const [xHandle, setXHandle] = useState(''); + const [saving, setSaving] = useState(false); + + useEffect(() => { + fetch(`/api/social-milestones?locale=${locale}`) + .then(async (response) => response.ok && setMilestone((await response.json()).milestone)) + .catch(() => undefined); + }, [locale]); + + useEffect(() => setXHandle(milestone?.defaultXHandle || ''), [milestone]); + + const copy = milestone?.language === 'de' + ? { heading: 'Ein echter Erfolg', subtitle: 'hat gerade einen Scan-Meilenstein erreicht.', consent: 'Darf QR Master diesen Erfolg mit der unten stehenden Karte auf dem eigenen X-Account teilen?', name: 'Meinen X-Handle nennen', decline: 'Nein, danke', optOut: 'Nicht mehr anzeigen', self: 'Selbst teilen', approve: 'Auf QR Master posten', published: 'Der Beitrag wird jetzt auf dem QR Master X-Account veroeffentlicht.' } + : { heading: 'A real milestone', subtitle: 'just reached a scan milestone.', consent: 'May QR Master share this success, including the card below, from our X account?', name: 'Mention my X handle', decline: 'No thanks', optOut: 'Do not show again', self: 'Share myself', approve: 'Post from QR Master', published: 'This will now be published from the QR Master X account.' }; + + const preview = useMemo(() => { + if (!milestone || !withName || !xHandle.trim()) return milestone?.preview || ''; + return `${milestone.preview} By @${xHandle.trim().replace(/^@/, '')}.`; + }, [milestone, withName, xHandle]); + + const respond = async (action: 'approve_brand' | 'self_share' | 'decline' | 'opt_out') => { + if (!milestone) return; + setSaving(true); + try { + const response = await fetchWithCsrf(`/api/social-milestones/${milestone.id}`, { + method: 'PATCH', body: JSON.stringify({ action, withName, xHandle, language: milestone.language }), + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || 'Could not save your choice'); + if (action === 'self_share') { + await navigator.clipboard?.writeText(preview); + window.open(`https://x.com/intent/post?text=${encodeURIComponent(preview)}`, '_blank', 'noopener,noreferrer'); + window.open('https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fwww.qrmaster.net', '_blank', 'noopener,noreferrer'); + showToast('Post text copied for LinkedIn.', 'success'); + } else if (action === 'approve_brand') { + showToast(copy.published, 'success'); + } + setMilestone(null); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Could not save your choice', 'error'); + } finally { + setSaving(false); + } + }; + + if (!milestone) return null; + + return !open && setMilestone(null)}> + +
+ +
+ {copy.heading} + {milestone.qrTitle} {copy.subtitle} +
+
+
+
+
QR MASTERVerified
+
{milestone.threshold.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}
+
{milestone.language === 'de' ? 'eindeutige Scans' : 'unique scans'}
+
{milestone.card.label} ยท {milestone.card.title}
+
+

{copy.consent}

+
{preview}
+ + {withName && setXHandle(event.target.value)} placeholder="@yourhandle" className="w-full rounded-md border border-slate-200 px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-2 focus:ring-violet-100" />} +
+ +
+ + + + +
+
+
+
; +} diff --git a/src/lib/social-milestones.ts b/src/lib/social-milestones.ts new file mode 100644 index 0000000..c2b91a2 --- /dev/null +++ b/src/lib/social-milestones.ts @@ -0,0 +1,69 @@ +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]; +} + +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 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 normalizeXHandle(value: string): string | null { + const handle = value.trim().replace(/^@/, ''); + return /^[A-Za-z0-9_]{1,15}$/.test(handle) ? handle : null; +}