Compare commits
22 Commits
d623f39c54
...
test
| Author | SHA1 | Date | |
|---|---|---|---|
| b278d275bb | |||
| eb932ebdaa | |||
| 55c04761ce | |||
| 13879e3d3a | |||
| 4ec70ed30f | |||
| d8f7202bf6 | |||
| e7581e488d | |||
| 8ef5221f71 | |||
| 8e34f97afb | |||
| aa3b4d02ab | |||
| 925540f3c6 | |||
| e0c32542f9 | |||
| 6081b9e6ae | |||
| 9d1d3a2062 | |||
| 72392e8cec | |||
| f7d82aa5bd | |||
| 14c429ff30 | |||
| d2c5f2848a | |||
| 31cba6d883 | |||
| 45f6c4d83b | |||
| a9057b25dd | |||
| 769d06e04e |
30
.env.example
30
.env.example
@@ -16,15 +16,43 @@ REDIS_URL=redis://redis:6379
|
||||
IP_SALT=CHANGE_ME_SALT
|
||||
ENABLE_DEMO=true
|
||||
|
||||
# SMTP (for welcome + retention emails via nodemailer)
|
||||
# SMTP & Email Senders (for welcome + retention emails via nodemailer / resend)
|
||||
SMTP_HOST=smtp.qrmaster.net
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=timo@qrmaster.net
|
||||
SMTP_PASS=
|
||||
EMAIL_FROM="Timo from QR Master <timo@qrmaster.net>"
|
||||
EMAIL_FROM_SECURITY="QR Master Security <noreply@qrmaster.net>"
|
||||
EMAIL_REPLY_TO="support@qrmaster.net"
|
||||
|
||||
# Cron job protection — generate with: openssl rand -base64 32
|
||||
CRON_SECRET=
|
||||
|
||||
# Leave empty in production for 1,000 / 10,000 unique scans. Test only, e.g. 1,2.
|
||||
SOCIAL_MILESTONE_THRESHOLDS=
|
||||
# Leave empty for immediate publishing after consent. Set 24 to enable a revocation window.
|
||||
SOCIAL_MILESTONE_POST_DELAY_HOURS=
|
||||
# Hours between two brand posts (default 24). Set 0 on test to publish back to back.
|
||||
SOCIAL_MILESTONE_MIN_GAP_HOURS=
|
||||
SOCIAL_MILESTONE_POSTING_ENABLED=false
|
||||
SOCIAL_WORKER_INTERVAL_SECONDS=10
|
||||
X_API_KEY=
|
||||
X_API_SECRET=
|
||||
X_ACCESS_TOKEN=
|
||||
X_ACCESS_TOKEN_SECRET=
|
||||
|
||||
# Channels the consent dialog offers (app) and the worker publishes (worker).
|
||||
# Keep both in sync: x / x,instagram
|
||||
SOCIAL_MILESTONE_CHANNELS=x
|
||||
SOCIAL_WORKER_CHANNELS=x
|
||||
# Instagram Business account for QRMaster.net, see docs/automations/social-accounts-and-jobs.md
|
||||
INSTAGRAM_USER_ID=
|
||||
INSTAGRAM_ACCESS_TOKEN=
|
||||
GRAPH_API_VERSION=v22.0
|
||||
# Guards POST/DELETE on /api/social-assets, the public image host Instagram
|
||||
# pulls from. Unrelated to TikTok posting; falls back to TIKTOK_ADMIN_KEY.
|
||||
SOCIAL_ASSET_ADMIN_KEY=
|
||||
|
||||
# TikTok OAuth / posting (server-side only)
|
||||
# Source of truth for cron posting: QRMaster server .env
|
||||
# Production example: https://qrmaster.net/api/tiktok/callback
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -94,3 +94,6 @@ src/lib/blog-data.snapshot-*.ts
|
||||
/public/Real Estate/
|
||||
/public/restaurant/
|
||||
/.qr-master-api-health-state
|
||||
|
||||
# Python worker bytecode
|
||||
__pycache__/
|
||||
|
||||
20
CLAUDE.md
20
CLAUDE.md
@@ -289,7 +289,25 @@ Pattern observed: QR Master gets cited by AI models only where first-party compa
|
||||
|
||||
Tested outside the original 9-theme set. QR Master is cited as the "Best overall option" for this query — credited for trackable/editable dynamic barcodes, bulk generation, unified analytics, and UTM tracking, plus static EAN-13/UPC-A/Code 128 support when editability isn't needed. The AI answer also included a "requirement → recommended format" decision table (dynamic QR vs. EAN-13/UPC-A vs. Code 128 vs. GS1 Digital Link) that maps closely to existing on-site content.
|
||||
|
||||
This maps directly to the live `/dynamic-barcode-generator` page and `/tools/barcode-generator` tool, reinforcing the pattern above: dedicated first-party pages targeting a query cluster get cited, gaps without dedicated pages don't. Counts as a 4th confirmed positive theme alongside the original 3 from the baseline audit.
|
||||
This maps to the `/tools/barcode-generator` tool, reinforcing the pattern above: dedicated first-party pages targeting a query cluster get cited, gaps without dedicated pages don't. Counts as a 4th confirmed positive theme alongside the original 3 from the baseline audit.
|
||||
|
||||
Note: `/dynamic-barcode-generator` and `/barcode-generator` are **not** live pages — both 301 to `/tools/barcode-generator` (see `redirects()` in `next.config.mjs`). Cite the canonical `/tools/barcode-generator` URL in any AEO work.
|
||||
|
||||
### Sitemap / IndexNow drift (fixed 2026-08-13)
|
||||
|
||||
`src/app/sitemap.ts` and `getAllIndexableUrls()` in `src/lib/indexnow.ts` are two hand-maintained URL lists that had silently diverged: the whole `/alternatives/*` + `/vs/*` cluster (1,942 GSC impressions in the 3 months to 2026-08-13, avg pos ~29-47) was submitted to IndexNow and linked from the footer but missing from the sitemap, while `/dynamic-barcode-generator` was listed in both despite 301-ing.
|
||||
|
||||
**When adding or redirecting a marketing page, update both lists.** A redirected URL must appear in neither.
|
||||
|
||||
### Google Review cluster — biggest non-brand opportunity (2026-08-13)
|
||||
|
||||
`/tools/google-review-qr-code` is the **#2 page on the site by impressions** (2,201 in 3 months) and converts almost none of it: 2 clicks, avg position 33. The query cluster is ~1,667 impressions across 50+ queries, all at position 20-50, zero clicks. Head term `google review qr code generator` = 268 impr at pos 35.
|
||||
|
||||
**On-page is not the constraint — do not "improve the content".** Audited 2026-08-13: it is already the deepest tool page on the site (1,352 rendered words, 8 h2 / 18 h3 vs 549 for crypto, 741 for wifi), with SoftwareApplication + HowTo + FAQPage schema, a correctly matched title, and canonical set. It also does not meaningfully cannibalize `/use-cases/qr-codes-for-review-collection` (36 impr, pos 11), which targets the tracking angle.
|
||||
|
||||
What was done: added `google-review-qr-code` to `toolsMap` in `qr-code-for/[industry]/page.tsx` and to the `tools[]` array of the 32 local-business industries in `src/lib/industry-pages.ts`, creating contextual internal links from crawled, well-ranking pages (barbershops pos 9.7, hotels, cafes, bars). The 20 institutional industries (airports, schools, stadiums, libraries, churches, universities, museums, theaters, cinemas, art-galleries, events, trade-shows, retail, and the non-local stores) were deliberately excluded — review collection is not their job, and blanket-linking all 52 would be boilerplate.
|
||||
|
||||
Remaining gap is off-site authority, not anything in this repo. Note the fallback in that template silently rewrites an unknown tool slug to `/tools/url-qr-code`, so any new slug must be added to `toolsMap` or the link disappears without erroring.
|
||||
|
||||
## Deployment Notes
|
||||
|
||||
|
||||
314
DEPLOY_TESTUMGEBUNG_ANLEITUNG.md
Normal file
314
DEPLOY_TESTUMGEBUNG_ANLEITUNG.md
Normal file
@@ -0,0 +1,314 @@
|
||||
# Anleitung: Testumgebung testmodul.qrmaster.net aufsetzen
|
||||
|
||||
Diese Anleitung richtet auf dem Produktionsserver eine **zweite, getrennte Instanz** von
|
||||
QR Master ein, erreichbar unter `testmodul.qrmaster.net`. Sie läuft auf dem Branch `test`
|
||||
mit einer eigenen, leeren Datenbank.
|
||||
|
||||
Die laufende Produktion wird dabei **nicht angefasst**. Alle Schritte hier legen neue
|
||||
Container, ein neues Volume und ein neues Verzeichnis an.
|
||||
|
||||
## Kurzfassung zum Abhaken
|
||||
|
||||
Wer die Begründungen nicht braucht, arbeitet diese Liste ab. Die ausführlichen Abschnitte
|
||||
darunter erklären jeden Schritt und was schiefgehen kann.
|
||||
|
||||
- [ ] **1.** Repo klonen, Branch `test`, **eigenes Verzeichnis** neben der Produktion
|
||||
- [ ] **2.** `.env.test` von Timo dort ablegen und die vier Kernwerte prüfen
|
||||
- [ ] **3.** Stack bauen und starten (`-p qrmaster-test`)
|
||||
- [ ] **4.** Schema aus Prod dumpen und einspielen - **kein** `prisma migrate`
|
||||
- [ ] **5.** Testaccount per SQL anlegen (Registrierungsformular funktioniert nicht)
|
||||
- [ ] **6.** Caddy-Block ergänzen und neu laden
|
||||
- [ ] **7.** Abnahme: DB-Trennung, keine Migrationen, robots.txt, Browser-Test
|
||||
|
||||
### Alle Befehle am Stück
|
||||
|
||||
```bash
|
||||
# 1 - Checkout (NICHT im Produktionsverzeichnis)
|
||||
git clone -b test https://git.bizmatch.net/tknuth/QR-master.git qrmaster-test
|
||||
cd qrmaster-test
|
||||
git branch --show-current # muss "test" zeigen
|
||||
|
||||
# 2 - .env.test hier ablegen, dann pruefen
|
||||
grep -E "NEXT_PUBLIC_WWW_URL|NEXT_PUBLIC_APP_URL|AUTH_COOKIE_NAME|POSTGRES_DB" .env.test
|
||||
# Erwartet: beide URLs auf testmodul, AUTH_COOKIE_NAME=userId_test,
|
||||
# POSTGRES_DB=qrmaster_test. Steht dort app./www. -> STOPP, siehe Schritt 2.
|
||||
|
||||
# 3 - Stack starten
|
||||
docker compose -p qrmaster-test --env-file .env.test \
|
||||
-f docker-compose.yml -f docker-compose.test.yml up -d --build
|
||||
docker ps --filter "name=qrmaster-test" --format "table {{.Names}}\t{{.Status}}"
|
||||
|
||||
# 4 - Schema (nur Struktur, keine Kundendaten)
|
||||
docker exec qrmaster-db pg_dump -U postgres --schema-only qrmaster > schema.sql
|
||||
docker exec -i qrmaster-test-db psql -U postgres -d qrmaster_test < schema.sql
|
||||
docker exec qrmaster-test-db psql -U postgres -d qrmaster_test -c "\dt" | head -20
|
||||
|
||||
# 5 - Testaccount: erst Hash erzeugen, dann in den INSERT einsetzen
|
||||
docker exec qrmaster-test-web node -e "console.log(require('bcryptjs').hashSync('DEIN_TESTPASSWORT',12))"
|
||||
docker exec -i qrmaster-test-db psql -U postgres -d qrmaster_test -c "INSERT INTO \"User\" (id,email,name,password,\"emailVerified\",\"updatedAt\") VALUES ('testuser1','test@qrmaster.net','Test','HIER_DER_HASH',now(),now());"
|
||||
|
||||
# 6 - Caddy: Block ergaenzen (siehe Schritt 6), dann
|
||||
caddy reload --config /etc/caddy/Caddyfile
|
||||
|
||||
# 7 - Abnahme
|
||||
docker exec qrmaster-test-db psql -U postgres -d qrmaster_test -c 'SELECT count(*) FROM "User";'
|
||||
docker exec qrmaster-db psql -U postgres -d qrmaster -c 'SELECT count(*) FROM "User";'
|
||||
docker logs qrmaster-test-web 2>&1 | head -20
|
||||
curl -s https://testmodul.qrmaster.net/robots.txt
|
||||
```
|
||||
|
||||
Die beiden `count(*)` müssen sich unterscheiden, in den Logs darf kein "Applying Prisma
|
||||
migrations" stehen, und `robots.txt` muss `Disallow: /` liefern.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- SSH-Zugang zum Server, auf dem QR Master läuft
|
||||
- Docker und Docker Compose (mindestens v2.24 - wird für `!override` und `!reset` gebraucht;
|
||||
prüfen mit `docker compose version`)
|
||||
- Schreibrechte auf die Caddy-Konfiguration
|
||||
- Die Datei **`.env.test`** - die kommt von Timo und ist nicht im Repository, weil sie
|
||||
Passwörter enthält
|
||||
- Der DNS-Eintrag `testmodul.qrmaster.net` existiert bereits (CNAME)
|
||||
|
||||
## Was NICHT angefasst wird
|
||||
|
||||
- Das bestehende Produktionsverzeichnis: dort **nicht** den Branch wechseln. Ein späterer
|
||||
Prod-Rebuild würde sonst Testcode bauen.
|
||||
- Die Produktions-`.env`
|
||||
- Die bestehenden Caddy-Blöcke für `www.qrmaster.net`, `app.qrmaster.net` und `qrmaster.net`
|
||||
- Die Produktionsdatenbank. Der einzige Zugriff darauf ist ein `pg_dump --schema-only`,
|
||||
das ausschließlich liest.
|
||||
|
||||
---
|
||||
|
||||
## 1. Zweites Checkout anlegen
|
||||
|
||||
**Nicht** im Produktionsverzeichnis arbeiten. Ein eigenes Verzeichnis daneben, z.B. im
|
||||
selben übergeordneten Ordner:
|
||||
|
||||
```bash
|
||||
git clone -b test https://git.bizmatch.net/tknuth/QR-master.git qrmaster-test
|
||||
```
|
||||
|
||||
Danach in dieses Verzeichnis wechseln. **Alle weiteren Befehle laufen von dort**, sofern
|
||||
nicht anders angegeben.
|
||||
|
||||
```bash
|
||||
cd qrmaster-test
|
||||
```
|
||||
|
||||
Prüfen, dass der richtige Branch ausgecheckt ist - es muss `test` erscheinen:
|
||||
|
||||
```bash
|
||||
git branch --show-current
|
||||
```
|
||||
|
||||
## 2. `.env.test` ablegen
|
||||
|
||||
Die von Timo erhaltene Datei als `.env.test` in dieses Verzeichnis legen (also
|
||||
`qrmaster-test/.env.test`).
|
||||
|
||||
Kurz gegenprüfen, dass die vier wichtigsten Werte stimmen:
|
||||
|
||||
```bash
|
||||
grep -E "NEXT_PUBLIC_WWW_URL|NEXT_PUBLIC_APP_URL|AUTH_COOKIE_NAME|POSTGRES_DB" .env.test
|
||||
```
|
||||
|
||||
Erwartet:
|
||||
|
||||
```
|
||||
NEXT_PUBLIC_WWW_URL=https://testmodul.qrmaster.net
|
||||
NEXT_PUBLIC_APP_URL=https://testmodul.qrmaster.net
|
||||
AUTH_COOKIE_NAME=userId_test
|
||||
POSTGRES_DB=qrmaster_test
|
||||
```
|
||||
|
||||
Steht bei einer der URLs `app.qrmaster.net` oder `www.qrmaster.net`, **nicht starten** -
|
||||
dann würden Klicks in der Testumgebung in die Produktion umleiten.
|
||||
|
||||
## 3. Stack bauen und starten
|
||||
|
||||
```bash
|
||||
docker compose -p qrmaster-test --env-file .env.test -f docker-compose.yml -f docker-compose.test.yml up -d --build
|
||||
```
|
||||
|
||||
Der erste Build dauert einige Minuten. Der Projektname `-p qrmaster-test` ist wichtig: er
|
||||
sorgt dafür, dass eigene Container und ein eigenes Volume entstehen und nichts aus der
|
||||
Produktion überschrieben wird.
|
||||
|
||||
Läuft alles, sollten drei neue Container existieren:
|
||||
|
||||
```bash
|
||||
docker ps --filter "name=qrmaster-test" --format "table {{.Names}}\t{{.Status}}"
|
||||
```
|
||||
|
||||
Erwartet: `qrmaster-test-db`, `qrmaster-test-redis`, `qrmaster-test-web`.
|
||||
|
||||
Die Anwendung kann zu diesem Zeitpunkt noch nichts anzeigen - die Datenbank ist leer. Das
|
||||
ist normal und wird im nächsten Schritt behoben.
|
||||
|
||||
## 4. Datenbankschema einspielen
|
||||
|
||||
Die Testdatenbank bekommt **nur die Struktur** aus der Produktion, keine Daten. Es werden
|
||||
also keine Kundendaten kopiert.
|
||||
|
||||
Struktur aus der Produktionsdatenbank exportieren (reiner Lesezugriff):
|
||||
|
||||
```bash
|
||||
docker exec qrmaster-db pg_dump -U postgres --schema-only qrmaster > schema.sql
|
||||
```
|
||||
|
||||
In die Testdatenbank einspielen:
|
||||
|
||||
```bash
|
||||
docker exec -i qrmaster-test-db psql -U postgres -d qrmaster_test < schema.sql
|
||||
```
|
||||
|
||||
> **Wichtig:** Nicht `prisma migrate` verwenden. Die Migrationsdateien im Repository sind
|
||||
> seit April 2026 nicht mehr gepflegt - alle Schemaänderungen seitdem wurden per SQL
|
||||
> gemacht. Ein `migrate deploy` würde ein veraltetes Schema erzeugen, mit dem die
|
||||
> Anwendung nicht läuft. Der Container startet deshalb bewusst ohne Migrationsschritt.
|
||||
|
||||
Prüfen, dass Tabellen angekommen sind:
|
||||
|
||||
```bash
|
||||
docker exec qrmaster-test-db psql -U postgres -d qrmaster_test -c "\dt" | head -20
|
||||
```
|
||||
|
||||
## 5. Testaccount anlegen
|
||||
|
||||
Die Registrierung über das Formular funktioniert hier **nicht**: die Testumgebung
|
||||
verschickt bewusst keine E-Mails, und ohne Bestätigungsmail wird der Account vom System
|
||||
wieder gelöscht. Der Account wird deshalb direkt in der Datenbank angelegt.
|
||||
|
||||
Zuerst einen Passwort-Hash erzeugen (`DEIN_TESTPASSWORT` durch ein selbst gewähltes
|
||||
Passwort ersetzen):
|
||||
|
||||
```bash
|
||||
docker exec qrmaster-test-web node -e "console.log(require('bcryptjs').hashSync('DEIN_TESTPASSWORT',12))"
|
||||
```
|
||||
|
||||
Die Ausgabe ist eine Zeichenkette, die mit `$2a$12$` oder `$2b$12$` beginnt. Diese im
|
||||
folgenden Befehl anstelle von `HIER_DER_HASH` einsetzen:
|
||||
|
||||
```bash
|
||||
docker exec -i qrmaster-test-db psql -U postgres -d qrmaster_test -c "INSERT INTO \"User\" (id,email,name,password,\"emailVerified\",\"updatedAt\") VALUES ('testuser1','test@qrmaster.net','Test','HIER_DER_HASH',now(),now());"
|
||||
```
|
||||
|
||||
Anmeldung erfolgt danach ganz normal über `/login` mit `test@qrmaster.net` und dem
|
||||
gewählten Passwort.
|
||||
|
||||
## 6. Caddy konfigurieren
|
||||
|
||||
Einen neuen Block in die Caddy-Konfiguration aufnehmen (Pfad ggf. anpassen). Die
|
||||
bestehenden Blöcke bleiben unverändert:
|
||||
|
||||
```caddyfile
|
||||
testmodul.qrmaster.net {
|
||||
reverse_proxy qrmaster-test-web:3000
|
||||
}
|
||||
```
|
||||
|
||||
Konfiguration neu laden:
|
||||
|
||||
```bash
|
||||
caddy reload --config /etc/caddy/Caddyfile
|
||||
```
|
||||
|
||||
Caddy holt das TLS-Zertifikat automatisch. Das kann eine Minute dauern.
|
||||
|
||||
## 7. Abnahme
|
||||
|
||||
**a) Datenbanken sind getrennt.** Die beiden Zahlen müssen sich unterscheiden - die
|
||||
Testdatenbank enthält nur den eben angelegten Account:
|
||||
|
||||
```bash
|
||||
docker exec qrmaster-test-db psql -U postgres -d qrmaster_test -c 'SELECT count(*) FROM "User";'
|
||||
```
|
||||
|
||||
```bash
|
||||
docker exec qrmaster-db psql -U postgres -d qrmaster -c 'SELECT count(*) FROM "User";'
|
||||
```
|
||||
|
||||
**b) Keine Migrationen gelaufen.** In der Ausgabe darf **nicht** "Applying Prisma
|
||||
migrations" stehen:
|
||||
|
||||
```bash
|
||||
docker logs qrmaster-test-web 2>&1 | head -20
|
||||
```
|
||||
|
||||
**c) Suchmaschinen ausgesperrt.** Muss `Disallow: /` liefern:
|
||||
|
||||
```bash
|
||||
curl -s https://testmodul.qrmaster.net/robots.txt
|
||||
```
|
||||
|
||||
**d) Im Browser:**
|
||||
|
||||
- `https://testmodul.qrmaster.net` lädt mit gültigem Zertifikat
|
||||
- Anmeldung mit dem Testaccount funktioniert
|
||||
- `https://testmodul.qrmaster.net/dashboard` **bleibt auf testmodul** und springt nicht auf
|
||||
`app.qrmaster.net`. Passiert das doch, sind die URLs in der `.env.test` falsch.
|
||||
- In den Entwicklertools unter Application → Cookies liegen zwei getrennte Cookies:
|
||||
`userId` mit Domain `.qrmaster.net` (Produktion) und `userId_test` mit Domain
|
||||
`testmodul.qrmaster.net`
|
||||
- Die Produktion ist weiterhin erreichbar und man ist dort weiterhin angemeldet
|
||||
|
||||
---
|
||||
|
||||
## Laufender Betrieb
|
||||
|
||||
Neuen Stand deployen, nachdem auf dem Branch `test` etwas gepusht wurde - aus dem
|
||||
Verzeichnis `qrmaster-test`:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose -p qrmaster-test --env-file .env.test -f docker-compose.yml -f docker-compose.test.yml up -d --build
|
||||
```
|
||||
|
||||
Ein Rebuild ist **immer** nötig, ein bloßer Neustart genügt nicht: die Host-URLs und der
|
||||
Cookie-Name werden beim Bauen fest in die Anwendung kompiliert.
|
||||
|
||||
Schemaänderungen werden weiterhin **von Hand per SQL** ausgeführt - erst auf Test, nach
|
||||
erfolgreicher Prüfung dasselbe Statement auf Produktion. Es gibt keinen automatischen Weg
|
||||
dazwischen.
|
||||
|
||||
## Testumgebung stoppen oder entfernen
|
||||
|
||||
Stoppen, Daten bleiben erhalten:
|
||||
|
||||
```bash
|
||||
docker compose -p qrmaster-test --env-file .env.test -f docker-compose.yml -f docker-compose.test.yml down
|
||||
```
|
||||
|
||||
Vollständig entfernen inklusive Testdatenbank - der Projektname `-p qrmaster-test` sorgt
|
||||
dafür, dass ausschließlich die Test-Volumes gelöscht werden:
|
||||
|
||||
```bash
|
||||
docker compose -p qrmaster-test --env-file .env.test -f docker-compose.yml -f docker-compose.test.yml down -v
|
||||
```
|
||||
|
||||
## Wenn etwas nicht funktioniert
|
||||
|
||||
| Symptom | Ursache |
|
||||
|---|---|
|
||||
| Build bricht ab mit `set AUTH_COOKIE_NAME in .env.test` | `.env.test` fehlt oder liegt im falschen Verzeichnis |
|
||||
| `qrmaster-test-db` bleibt `unhealthy`, `web` startet nicht | In der `.env.test` steht nicht `POSTGRES_DB=qrmaster_test` |
|
||||
| Caddy liefert 502 | Containername im Caddy-Block stimmt nicht, oder der Container läuft nicht - mit `docker ps` prüfen |
|
||||
| Anwendung meldet `column ... does not exist` | Schema-Import aus Schritt 4 war unvollständig - erneut einspielen |
|
||||
| `/dashboard` springt auf `app.qrmaster.net` | Die URLs in der `.env.test` zeigen nicht auf testmodul. Korrigieren und **neu bauen**, nicht nur neu starten. |
|
||||
| Anmeldung wirkt zufällig abgelaufen | `AUTH_COOKIE_NAME` ist nicht gesetzt oder steht auf `userId` - dann kollidiert es mit dem Produktions-Cookie |
|
||||
| Registrierung über das Formular schlägt fehl | Erwartet - die Testumgebung verschickt keine E-Mails. Account per SQL anlegen, Schritt 5. |
|
||||
|
||||
## Bekannte Einschränkungen der Testumgebung
|
||||
|
||||
Bewusst deaktiviert, weil die Umgebung nach außen nichts auslösen soll:
|
||||
|
||||
- **Kein E-Mail-Versand** - Registrierung, Passwort-Reset und Benachrichtigungen funktionieren nicht
|
||||
- **Kein Google-Login** - Zugangsdaten sind nicht hinterlegt
|
||||
- **Keine Datei-Uploads** - der Objektspeicher (R2) ist nicht konfiguriert
|
||||
- **Kein Stripe** - Checkout und Abo-Verwaltung funktionieren nicht, solange keine Testschlüssel eingetragen sind
|
||||
- **Keine Analytics** - damit die Produktionszahlen nicht verfälscht werden
|
||||
@@ -49,6 +49,8 @@ ARG NEXT_PUBLIC_UMAMI_SRC=""
|
||||
ARG NEXT_PUBLIC_UMAMI_ID=""
|
||||
ENV NEXT_PUBLIC_UMAMI_SRC=$NEXT_PUBLIC_UMAMI_SRC
|
||||
ENV NEXT_PUBLIC_UMAMI_ID=$NEXT_PUBLIC_UMAMI_ID
|
||||
ARG SMTP_USER=""
|
||||
ENV SMTP_USER=$SMTP_USER
|
||||
# Shared session cookie across www.* and app.*. Needed at build time too: process.env is
|
||||
# inlined into the Edge middleware bundle, so a runtime-only value would leave the
|
||||
# middleware and the route handlers disagreeing about the cookie scope.
|
||||
|
||||
@@ -38,6 +38,17 @@ services:
|
||||
# April 2026 and the schema has moved on through manual SQL since, so running them
|
||||
# against a fresh database would build a stale schema the app cannot work with.
|
||||
# Bring the schema in with `pg_dump --schema-only` from production instead.
|
||||
environment:
|
||||
# Docker sets HOSTNAME=<container-id>, and the Next.js standalone server binds to
|
||||
# that single interface. With two networks Caddy then cannot reach the container.
|
||||
HOSTNAME: "0.0.0.0"
|
||||
# `db` and `redis` are taken in BOTH networks - by this stack in test-internal and
|
||||
# by production in qrmaster-network. Production wins the lookup every time, so the
|
||||
# base file's hostnames point the staging app at the production instances. Container
|
||||
# names are unique per daemon and cannot be shadowed.
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@qrmaster-test-db:5432/${POSTGRES_DB}?schema=public
|
||||
DIRECT_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@qrmaster-test-db:5432/${POSTGRES_DB}?schema=public
|
||||
REDIS_URL: redis://qrmaster-test-redis:6379
|
||||
entrypoint: ["node", "server.js"]
|
||||
build:
|
||||
args:
|
||||
@@ -54,6 +65,15 @@ services:
|
||||
- test-internal
|
||||
- qrmaster-network
|
||||
|
||||
social-worker:
|
||||
container_name: qrmaster-test-social-worker
|
||||
environment:
|
||||
# Never resolve the ambiguous `web` alias on the shared production
|
||||
# network. The test container name is unique on this Docker daemon.
|
||||
QRMASTER_API_BASE: http://qrmaster-test-web:3000
|
||||
networks: !override
|
||||
- test-internal
|
||||
|
||||
adminer:
|
||||
container_name: qrmaster-test-adminer
|
||||
ports: !reset []
|
||||
|
||||
@@ -62,10 +62,20 @@ 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:-}
|
||||
SOCIAL_MILESTONE_MIN_GAP_HOURS: ${SOCIAL_MILESTONE_MIN_GAP_HOURS:-}
|
||||
# Channels the consent dialog may ask for. Only extend this once the
|
||||
# worker actually publishes that channel.
|
||||
SOCIAL_MILESTONE_CHANNELS: ${SOCIAL_MILESTONE_CHANNELS:-x}
|
||||
TIKTOK_CLIENT_KEY: ${TIKTOK_CLIENT_KEY:-}
|
||||
TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-}
|
||||
TIKTOK_REDIRECT_URI: ${TIKTOK_REDIRECT_URI:-https://qrmaster.net/api/tiktok/callback}
|
||||
TIKTOK_ADMIN_KEY: ${TIKTOK_ADMIN_KEY:-}
|
||||
# Guards the asset upload route that Instagram (and TikTok) pull media
|
||||
# from. Falls back to the TikTok key so existing setups keep working.
|
||||
SOCIAL_ASSET_ADMIN_KEY: ${SOCIAL_ASSET_ADMIN_KEY:-}
|
||||
TIKTOK_EXPECTED_OPEN_ID: ${TIKTOK_EXPECTED_OPEN_ID:-}
|
||||
IP_SALT: ${IP_SALT:-your-salt-change-in-production}
|
||||
ENABLE_DEMO: ${ENABLE_DEMO:-false}
|
||||
@@ -85,7 +95,7 @@ services:
|
||||
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||
SMTP_HOST: ${SMTP_HOST:-smtp.qrmaster.net}
|
||||
SMTP_PORT: ${SMTP_PORT:-465}
|
||||
SMTP_USER: ${SMTP_USER:-timo@qrmaster.net}
|
||||
SMTP_USER: ${SMTP_USER:-info@qrmaster.net}
|
||||
SMTP_PASS: ${SMTP_PASS:-}
|
||||
NEWSLETTER_ADMIN_EMAIL: ${NEWSLETTER_ADMIN_EMAIL:-}
|
||||
NEWSLETTER_ADMIN_PASSWORD: ${NEWSLETTER_ADMIN_PASSWORD:-}
|
||||
@@ -114,6 +124,35 @@ services:
|
||||
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:-}
|
||||
# Channels this worker publishes. Must stay a subset of the app's
|
||||
# SOCIAL_MILESTONE_CHANNELS - a channel the dialog offers but nobody
|
||||
# publishes would leave approvals sitting in the queue.
|
||||
SOCIAL_WORKER_CHANNELS: ${SOCIAL_WORKER_CHANNELS:-x}
|
||||
INSTAGRAM_USER_ID: ${INSTAGRAM_USER_ID:-}
|
||||
INSTAGRAM_ACCESS_TOKEN: ${INSTAGRAM_ACCESS_TOKEN:-}
|
||||
GRAPH_API_VERSION: ${GRAPH_API_VERSION:-v22.0}
|
||||
# Instagram downloads the image itself, so the worker hosts it through
|
||||
# /api/social-assets on the verified domain. Same key as the web service.
|
||||
SOCIAL_ASSET_ADMIN_KEY: ${SOCIAL_ASSET_ADMIN_KEY:-${TIKTOK_ADMIN_KEY:-}}
|
||||
depends_on:
|
||||
web:
|
||||
condition: service_started
|
||||
networks:
|
||||
- qrmaster-network
|
||||
|
||||
|
||||
# Adminer - Database Management UI (Optional)
|
||||
adminer:
|
||||
|
||||
16
docker/init-db.sh
Normal file → Executable file
16
docker/init-db.sh
Normal file → Executable file
@@ -3,24 +3,26 @@ set -e
|
||||
|
||||
# This script runs when the PostgreSQL container is first created
|
||||
# It ensures the database is properly initialized
|
||||
#
|
||||
# Keep this database-name agnostic: the staging stack (docker-compose.test.yml)
|
||||
# runs the same script with POSTGRES_DB=qrmaster_test. A hardcoded name aborts
|
||||
# the init, and the container never becomes healthy.
|
||||
# Must stay LF-only and executable - Postgres sources non-executable init
|
||||
# scripts, and CRLF breaks them on the first line.
|
||||
|
||||
echo "🚀 Initializing QR Master database..."
|
||||
|
||||
# Create the database if it doesn't exist (already created by POSTGRES_DB)
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
||||
# The database itself is already created by POSTGRES_DB
|
||||
psql -v ON_ERROR_STOP=1 -v dbname="$POSTGRES_DB" --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
||||
-- Enable required extensions
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
||||
|
||||
-- Grant privileges
|
||||
GRANT ALL PRIVILEGES ON DATABASE qrmaster TO postgres;
|
||||
|
||||
-- Set timezone
|
||||
ALTER DATABASE qrmaster SET timezone TO 'UTC';
|
||||
ALTER DATABASE :"dbname" SET timezone TO 'UTC';
|
||||
EOSQL
|
||||
|
||||
echo "✅ Database initialization complete!"
|
||||
echo "📊 Database: $POSTGRES_DB"
|
||||
echo "👤 User: $POSTGRES_USER"
|
||||
echo "🌐 Ready to accept connections on port 5432"
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
| `C:\Users\timo\Documents\meta_instagram_tokens.env` | Meta Facebook + Instagram long-lived/page tokens |
|
||||
| `C:\Users\timo\Documents\r2_social_media.env` | R2 upload credentials for IG assets |
|
||||
| `C:\Users\timo\Documents\instagram_r2_carousel_post.py` | uses both env files above |
|
||||
| `C:\Users\timo\Documents\XApiAutopost\.env` | X/Twitter QRMaster creds |
|
||||
| `C:\Users\timo\x-api-autopost\.env` | X/Twitter QRMaster creds (`X_API_KEY`, `X_API_SECRET`, `X_ACCESS_TOKEN`, `X_ACCESS_TOKEN_SECRET`) |
|
||||
| `C:\Users\timo\Documents\greenlens-x-autopost\.env` | X/Twitter GreenLens creds |
|
||||
| `C:\Users\timo\Documents\greenlens\Greenlens\.env` | GreenLens TikTok + plant import admin creds |
|
||||
|
||||
|
||||
113
docs/automations/social-milestone-worker.md
Normal file
113
docs/automations/social-milestone-worker.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Social milestone worker
|
||||
|
||||
The app detects QR-code scan milestones and stores customer consent. It does not
|
||||
hold X or Meta credentials. The external worker publishes what was approved —
|
||||
per channel, never more.
|
||||
|
||||
## Consent is bound to a channel
|
||||
|
||||
Approving a post on X says nothing about Instagram: different audience,
|
||||
different disclosure about the customer's business. Every channel therefore has
|
||||
its own checkbox in the dialog, its own text, its own handle and its own row in
|
||||
`SocialMilestonePost`. **No row means no consent.** A channel is only offered
|
||||
where a publisher is configured — `SOCIAL_MILESTONE_CHANNELS` (app) must stay in
|
||||
sync with `SOCIAL_WORKER_CHANNELS` (worker), otherwise approvals pile up in the
|
||||
queue with nobody to publish them.
|
||||
|
||||
## Test setup (manual SQL only)
|
||||
|
||||
1. Apply, in this order, to `qrmaster_test`:
|
||||
[`sql/2026-08-13_social_milestones.sql`](../../sql/2026-08-13_social_milestones.sql),
|
||||
[`sql/2026-08-16_social_milestone_retries.sql`](../../sql/2026-08-16_social_milestone_retries.sql),
|
||||
[`sql/2026-08-16_social_milestone_channels.sql`](../../sql/2026-08-16_social_milestone_channels.sql).
|
||||
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.
|
||||
Set `SOCIAL_MILESTONE_MIN_GAP_HOURS=0` on test, otherwise the second
|
||||
milestone waits a full day behind the first one.
|
||||
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. A QR code that is already
|
||||
past several thresholds on first detection only produces the highest one.
|
||||
|
||||
## Queue contract
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $INTERNAL_API_SECRET" \
|
||||
"https://qrmaster.net/api/internal/social-milestones?channel=instagram"
|
||||
```
|
||||
|
||||
Returns at most one approved post for that channel, claims it, and expects a
|
||||
result report for the returned `id`:
|
||||
|
||||
```bash
|
||||
curl -X PATCH -H "Authorization: Bearer $INTERNAL_API_SECRET" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"id":"<post-id>","result":"posted","postUrl":"https://..."}' \
|
||||
https://qrmaster.net/api/internal/social-milestones
|
||||
```
|
||||
|
||||
First attempts are spaced by `SOCIAL_MILESTONE_MIN_GAP_HOURS` (default 24) per
|
||||
channel, so the brand timeline cannot be flooded when several customers consent
|
||||
on the same day. A blocked poll answers
|
||||
`{"milestone": null, "reason": "spacing", "nextPostAt": "..."}`.
|
||||
|
||||
`milestone.text` is published **verbatim** — it is the text the customer read
|
||||
before consenting. The image is not part of the payload: the worker downloads it
|
||||
from `<QRMASTER_API_BASE>/s/m/<shareToken>/og`, the same renderer that serves the
|
||||
popup and the link preview. `?format=` takes `landscape` (1200×630, default),
|
||||
`square` (1080×1080) or `portrait` (1080×1350).
|
||||
|
||||
## Instagram
|
||||
|
||||
Publishing runs against the QRMaster.net Business account (see
|
||||
[social-accounts-and-jobs.md](social-accounts-and-jobs.md)) in three steps:
|
||||
`POST /{ig-user-id}/media` → poll `status_code` until `FINISHED` →
|
||||
`POST /{ig-user-id}/media_publish`.
|
||||
|
||||
Worth knowing before enabling it:
|
||||
|
||||
- **JPEG only.** The worker flattens the rendered PNG onto white and uploads it
|
||||
through `POST /api/social-assets`; Meta downloads `image_url` itself, so it has
|
||||
to be publicly readable on the verified domain. `SOCIAL_ASSET_ADMIN_KEY` is the
|
||||
existing `TIKTOK_ADMIN_KEY`.
|
||||
- **No clickable links in captions.** The Instagram text therefore ends in
|
||||
hashtags instead of the share URL, and mentions use the customer's Instagram
|
||||
handle, not their X handle.
|
||||
- **50 posts / 24 h**, verifiable via `GET /{ig-user-id}/content_publishing_limit`.
|
||||
- **Reconciliation is caption-based.** Before a repeated attempt the worker
|
||||
compares the caption against the last 25 media items. Two milestones with an
|
||||
identical caption — same QR title, same scan count — would be treated as the
|
||||
same post; the 24 h spacing makes that combination unlikely but not impossible.
|
||||
- Required environment: `SOCIAL_WORKER_CHANNELS=x,instagram`, `INSTAGRAM_USER_ID`,
|
||||
`INSTAGRAM_ACCESS_TOKEN`, plus `SOCIAL_MILESTONE_CHANNELS=x,instagram` on the
|
||||
web service so the dialog asks for it in the first place.
|
||||
|
||||
Do not configure this worker against `testmodul`. LinkedIn has no approved
|
||||
brand-posting integration in this project. Customers can self-share on all three:
|
||||
X opens a prefilled intent, LinkedIn gets the text copied, Instagram opens the
|
||||
system share sheet on a phone and falls back to an image download.
|
||||
|
||||
## Failed posts
|
||||
|
||||
A reported failure — and a claim the worker never confirmed — counts as one
|
||||
attempt. The queue re-schedules the post itself after 5, then 10 minutes and only
|
||||
parks it in `failed` once three attempts are used up. Before posting again the
|
||||
worker reconciles against the account (share token on X, caption on Instagram),
|
||||
so a failure reported after a successful post cannot duplicate it.
|
||||
|
||||
The consent dialog opens once per milestone, so a customer who has closed it can
|
||||
no longer see or restart a failed post from there. Settings → Milestone sharing
|
||||
lists every milestone with one line per channel and offers "Try again" (re-queues
|
||||
the approved text unchanged) and "Cancel" (revokes that channel before anything
|
||||
is published).
|
||||
@@ -95,6 +95,11 @@ const nextConfig = {
|
||||
destination: '/restaurants',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: '/blog/qr-code-print-size-guide',
|
||||
destination: '/qr-code-print-size-guide',
|
||||
permanent: true,
|
||||
},
|
||||
|
||||
{
|
||||
source: '/create-qr',
|
||||
|
||||
@@ -89,6 +89,13 @@ 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?
|
||||
instagramHandle String?
|
||||
socialPromptOptOut Boolean @default(false)
|
||||
}
|
||||
|
||||
enum Plan {
|
||||
@@ -148,11 +155,73 @@ 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?
|
||||
brandStatus String @default("pending")
|
||||
brandApprovedAt DateTime?
|
||||
brandPostedAt DateTime?
|
||||
brandPostUrl String?
|
||||
brandPostError String?
|
||||
selfSharedAt DateTime?
|
||||
shareToken String? @unique
|
||||
publicShareApprovedAt DateTime?
|
||||
attempts Int @default(0)
|
||||
nextAttemptAt DateTime?
|
||||
|
||||
qr QRCode @relation(fields: [qrId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
posts SocialMilestonePost[]
|
||||
|
||||
@@unique([qrId, kind])
|
||||
@@index([status, respondedAt])
|
||||
@@index([status, claimedAt])
|
||||
@@index([userId, status])
|
||||
@@index([brandStatus, brandApprovedAt])
|
||||
}
|
||||
|
||||
model SocialMilestonePost {
|
||||
id String @id @default(cuid())
|
||||
milestoneId String
|
||||
/// "x" | "instagram". Consent is bound to the channel it was given for.
|
||||
channel String
|
||||
/// approved | processing | posted | failed | revoked
|
||||
status String @default("approved")
|
||||
/// The exact text the customer read before consenting.
|
||||
consentText String
|
||||
handle String?
|
||||
approvedAt DateTime @default(now())
|
||||
claimedAt DateTime?
|
||||
postedAt DateTime?
|
||||
postUrl String?
|
||||
error String?
|
||||
attempts Int @default(0)
|
||||
nextAttemptAt DateTime?
|
||||
|
||||
milestone SocialMilestone @relation(fields: [milestoneId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([milestoneId, channel])
|
||||
@@index([channel, status, approvedAt])
|
||||
}
|
||||
|
||||
enum QRType {
|
||||
STATIC
|
||||
DYNAMIC
|
||||
|
||||
7
scripts/social-worker/Dockerfile
Normal file
7
scripts/social-worker/Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
||||
FROM python:3.12-slim
|
||||
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"]
|
||||
3
scripts/social-worker/requirements.txt
Normal file
3
scripts/social-worker/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
Pillow>=10
|
||||
requests>=2.31
|
||||
requests-oauthlib>=2.0
|
||||
279
scripts/social-worker/worker.py
Normal file
279
scripts/social-worker/worker.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""Always-on QR Master milestone publisher.
|
||||
|
||||
Polls the app's internal queue per channel and publishes what a customer has
|
||||
explicitly approved for that channel. The web app never receives X or Meta
|
||||
credentials; this worker never composes text of its own.
|
||||
"""
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
from requests_oauthlib import OAuth1Session
|
||||
|
||||
CHANNELS = ("x", "instagram")
|
||||
|
||||
|
||||
def required(name):
|
||||
value = os.getenv(name, "").strip()
|
||||
if not value:
|
||||
raise RuntimeError(f"Missing {name}")
|
||||
return value
|
||||
|
||||
|
||||
def enabled_channels():
|
||||
configured = [value.strip().lower() for value in os.getenv("SOCIAL_WORKER_CHANNELS", "x").split(",")]
|
||||
return [channel for channel in configured if channel in CHANNELS] or ["x"]
|
||||
|
||||
|
||||
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 graph(path):
|
||||
return f"https://graph.facebook.com/{os.getenv('GRAPH_API_VERSION', 'v22.0')}/{path}"
|
||||
|
||||
|
||||
def graph_error(response):
|
||||
"""Meta answers with a JSON error body that says far more than the status."""
|
||||
try:
|
||||
error = response.json().get("error") or {}
|
||||
detail = error.get("error_user_msg") or error.get("message")
|
||||
if detail:
|
||||
return f"{detail} (code {error.get('code')})"
|
||||
except ValueError:
|
||||
pass
|
||||
return f"HTTP {response.status_code}"
|
||||
|
||||
|
||||
def milestone_image(milestone, image_format="landscape"):
|
||||
"""Download the card the app renders at /s/m/<token>/og.
|
||||
|
||||
The popup, the link preview and the published post therefore show the exact
|
||||
same image: one renderer, one source of truth, nothing to keep in sync here.
|
||||
The internal base is used on purpose - the public host is not necessarily
|
||||
reachable from inside the worker network.
|
||||
"""
|
||||
token = str(milestone.get("shareToken") or "").strip()
|
||||
if not token:
|
||||
return None
|
||||
base = required("QRMASTER_API_BASE").rstrip("/")
|
||||
response = requests.get(f"{base}/s/m/{token}/og", params={"format": image_format}, timeout=60)
|
||||
response.raise_for_status()
|
||||
path = Path(tempfile.mkstemp(suffix=".png")[1])
|
||||
path.write_bytes(response.content)
|
||||
return path
|
||||
|
||||
|
||||
# --- X ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def oauth_client():
|
||||
return 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"))
|
||||
|
||||
|
||||
def find_existing_x_post(oauth, milestone):
|
||||
"""Reconcile an uncertain prior attempt before creating another X post."""
|
||||
token = str(milestone.get("shareToken") or "").strip()
|
||||
if not token:
|
||||
raise RuntimeError("Milestone has no share token for duplicate-safe publishing")
|
||||
identity = oauth.get("https://api.x.com/2/users/me", timeout=30)
|
||||
identity.raise_for_status()
|
||||
user_id = identity.json().get("data", {}).get("id")
|
||||
if not user_id:
|
||||
raise RuntimeError("X did not return the authenticated user id")
|
||||
timeline = oauth.get(
|
||||
f"https://api.x.com/2/users/{user_id}/tweets",
|
||||
params={"max_results": 100, "tweet.fields": "created_at,entities", "exclude": "retweets,replies"},
|
||||
timeout=30,
|
||||
)
|
||||
timeline.raise_for_status()
|
||||
for post in timeline.json().get("data") or []:
|
||||
urls = (post.get("entities") or {}).get("urls") or []
|
||||
expanded = " ".join(str(url.get("expanded_url") or url.get("unwound_url") or "") for url in urls)
|
||||
if token in expanded:
|
||||
return f"https://x.com/i/web/status/{post.get('id')}"
|
||||
return None
|
||||
|
||||
|
||||
def publish_x(milestone):
|
||||
oauth = oauth_client()
|
||||
# Reading the timeline costs far more X quota than writing a post, so
|
||||
# reconcile only when this post was already attempted before.
|
||||
if milestone.get("attempts"):
|
||||
existing = find_existing_x_post(oauth, milestone)
|
||||
if existing:
|
||||
return existing, True
|
||||
|
||||
path = milestone_image(milestone)
|
||||
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": milestone["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()
|
||||
tweet_id = result.json().get("data", {}).get("id")
|
||||
return (f"https://x.com/i/web/status/{tweet_id}" if tweet_id else None), False
|
||||
finally:
|
||||
if path:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
# --- Instagram -------------------------------------------------------------
|
||||
|
||||
|
||||
def instagram_jpeg(path):
|
||||
"""Instagram accepts JPEG only, so the rendered PNG is flattened onto white.
|
||||
|
||||
The card is drawn at 1080x1350 (4:5), the tallest ratio Instagram allows.
|
||||
"""
|
||||
with Image.open(path) as image:
|
||||
rgba = image.convert("RGBA")
|
||||
canvas = Image.new("RGB", rgba.size, "white")
|
||||
canvas.paste(rgba, mask=rgba.split()[3])
|
||||
buffer = io.BytesIO()
|
||||
canvas.save(buffer, "JPEG", quality=92, optimize=True)
|
||||
target = Path(tempfile.mkstemp(suffix=".jpg")[1])
|
||||
target.write_bytes(buffer.getvalue())
|
||||
return target
|
||||
|
||||
|
||||
def host_asset(path):
|
||||
"""Meta downloads `image_url` itself, so the file must be publicly readable.
|
||||
|
||||
/api/social-assets stores it in Postgres and serves it from the verified
|
||||
qrmaster.net domain - no object storage and no deploy needed per post.
|
||||
"""
|
||||
base = required("QRMASTER_API_BASE").rstrip("/")
|
||||
response = requests.post(
|
||||
f"{base}/api/social-assets",
|
||||
headers={"x-admin-key": required("SOCIAL_ASSET_ADMIN_KEY")},
|
||||
json={"files": [{"filename": path.name, "mimeType": "image/jpeg", "dataBase64": base64.b64encode(path.read_bytes()).decode()}]},
|
||||
timeout=60,
|
||||
)
|
||||
if not response.ok:
|
||||
raise RuntimeError(f"Asset upload failed: {response.text[:200]}")
|
||||
url = ((response.json().get("assets") or [{}])[0]).get("url")
|
||||
if not url:
|
||||
raise RuntimeError("Asset upload returned no URL")
|
||||
return url
|
||||
|
||||
|
||||
def find_existing_instagram_post(caption):
|
||||
"""Reconcile by caption: a repeated attempt must not post twice.
|
||||
|
||||
Instagram has nothing like a client-side idempotency key, and a share token
|
||||
in the caption would only be visible clutter - the caption itself is the
|
||||
identifying detail.
|
||||
"""
|
||||
response = requests.get(
|
||||
graph(f"{required('INSTAGRAM_USER_ID')}/media"),
|
||||
params={"fields": "id,caption,permalink", "limit": 25, "access_token": required("INSTAGRAM_ACCESS_TOKEN")},
|
||||
timeout=30,
|
||||
)
|
||||
if not response.ok:
|
||||
raise RuntimeError(graph_error(response))
|
||||
for media in response.json().get("data") or []:
|
||||
if (media.get("caption") or "").strip() == caption.strip():
|
||||
return media.get("permalink")
|
||||
return None
|
||||
|
||||
|
||||
def publish_instagram(milestone):
|
||||
caption = milestone["text"]
|
||||
if milestone.get("attempts"):
|
||||
existing = find_existing_instagram_post(caption)
|
||||
if existing:
|
||||
return existing, True
|
||||
|
||||
user_id = required("INSTAGRAM_USER_ID")
|
||||
token = required("INSTAGRAM_ACCESS_TOKEN")
|
||||
png = milestone_image(milestone, "portrait")
|
||||
if not png:
|
||||
raise RuntimeError("Milestone has no share token, so no image can be published")
|
||||
jpeg = None
|
||||
try:
|
||||
jpeg = instagram_jpeg(png)
|
||||
image_url = host_asset(jpeg)
|
||||
container = requests.post(graph(f"{user_id}/media"), data={"image_url": image_url, "caption": caption, "access_token": token}, timeout=60)
|
||||
if not container.ok:
|
||||
raise RuntimeError(graph_error(container))
|
||||
creation_id = container.json().get("id")
|
||||
if not creation_id:
|
||||
raise RuntimeError("Instagram did not return a container id")
|
||||
|
||||
# Meta fetches and processes the image asynchronously.
|
||||
deadline = time.time() + 120
|
||||
while True:
|
||||
status = requests.get(graph(creation_id), params={"fields": "status_code,status", "access_token": token}, timeout=30)
|
||||
if not status.ok:
|
||||
raise RuntimeError(graph_error(status))
|
||||
code = status.json().get("status_code")
|
||||
if code == "FINISHED":
|
||||
break
|
||||
if code in {"ERROR", "EXPIRED"}:
|
||||
raise RuntimeError(f"Instagram rejected the media container: {status.json().get('status') or code}")
|
||||
if time.time() > deadline:
|
||||
raise RuntimeError("Instagram did not finish processing the image within 120s")
|
||||
time.sleep(5)
|
||||
|
||||
published = requests.post(graph(f"{user_id}/media_publish"), data={"creation_id": creation_id, "access_token": token}, timeout=60)
|
||||
if not published.ok:
|
||||
raise RuntimeError(graph_error(published))
|
||||
media_id = published.json().get("id")
|
||||
permalink = None
|
||||
if media_id:
|
||||
link = requests.get(graph(media_id), params={"fields": "permalink", "access_token": token}, timeout=30)
|
||||
permalink = link.json().get("permalink") if link.ok else None
|
||||
return permalink, False
|
||||
finally:
|
||||
png.unlink(missing_ok=True)
|
||||
if jpeg:
|
||||
jpeg.unlink(missing_ok=True)
|
||||
|
||||
|
||||
PUBLISHERS = {"x": publish_x, "instagram": publish_instagram}
|
||||
|
||||
|
||||
def run_once(channel):
|
||||
base = required("QRMASTER_API_BASE").rstrip("/") + "/api/internal/social-milestones"
|
||||
milestone = api("GET", f"{base}?channel={channel}").get("milestone")
|
||||
if not milestone:
|
||||
return
|
||||
try:
|
||||
post_url, reconciled = PUBLISHERS[channel](milestone)
|
||||
api("PATCH", base, {"id": milestone["id"], "result": "posted", "postUrl": post_url})
|
||||
print(json.dumps({"posted": milestone["id"], "channel": channel, "reconciled": reconciled, "url": post_url}), flush=True)
|
||||
except Exception as error:
|
||||
api("PATCH", base, {"id": milestone["id"], "result": "failed", "error": str(error)[:500]})
|
||||
print(f"Milestone post failed ({channel}): {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")
|
||||
channels = enabled_channels()
|
||||
print(json.dumps({"worker": "social-milestones", "status": "started", "intervalSeconds": interval, "channels": channels}), flush=True)
|
||||
while True:
|
||||
for channel in channels:
|
||||
try:
|
||||
run_once(channel)
|
||||
except Exception as error:
|
||||
# Stay alive and make configuration/network errors visible in the
|
||||
# container logs instead of entering a silent restart loop.
|
||||
print(f"Worker cycle failed ({channel}): {error}", flush=True)
|
||||
time.sleep(interval)
|
||||
43
sql/2026-08-13_social_milestones.sql
Normal file
43
sql/2026-08-13_social_milestones.sql
Normal file
@@ -0,0 +1,43 @@
|
||||
-- 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");
|
||||
|
||||
-- Version 2: independent brand and self-share state plus a consent-gated
|
||||
-- unguessable URL for Open Graph previews. Execute manually once.
|
||||
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandStatus" TEXT NOT NULL DEFAULT 'pending';
|
||||
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandApprovedAt" TIMESTAMP(3);
|
||||
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandPostedAt" TIMESTAMP(3);
|
||||
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandPostUrl" TEXT;
|
||||
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandPostError" TEXT;
|
||||
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "selfSharedAt" TIMESTAMP(3);
|
||||
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "shareToken" TEXT;
|
||||
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "publicShareApprovedAt" TIMESTAMP(3);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "SocialMilestone_shareToken_key"
|
||||
ON "SocialMilestone" ("shareToken") WHERE "shareToken" IS NOT NULL;
|
||||
42
sql/2026-08-16_social_milestone_channels.sql
Normal file
42
sql/2026-08-16_social_milestone_channels.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
-- Per-channel publishing for milestone posts. Run once against the target
|
||||
-- database BEFORE deploying the application version that uses this table.
|
||||
--
|
||||
-- Consent is channel-bound: agreeing to a post on X says nothing about
|
||||
-- Instagram - different audience, different disclosure. Publishing state
|
||||
-- therefore moves out of the SocialMilestone row into one row per channel.
|
||||
-- The old "brand*" columns stay in place as a fallback and are backfilled
|
||||
-- below; nothing reads them any more.
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "instagramHandle" TEXT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "SocialMilestonePost" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"milestoneId" TEXT NOT NULL REFERENCES "SocialMilestone"("id") ON DELETE CASCADE,
|
||||
"channel" TEXT NOT NULL, -- 'x' | 'instagram'
|
||||
"status" TEXT NOT NULL DEFAULT 'approved', -- approved | processing | posted | failed | revoked
|
||||
-- The text the customer read before consenting. Published verbatim.
|
||||
"consentText" TEXT NOT NULL,
|
||||
"handle" TEXT,
|
||||
"approvedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"claimedAt" TIMESTAMP(3),
|
||||
"postedAt" TIMESTAMP(3),
|
||||
"postUrl" TEXT,
|
||||
"error" TEXT,
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"nextAttemptAt" TIMESTAMP(3),
|
||||
-- A row exists only where consent exists. No row means: not approved.
|
||||
CONSTRAINT "SocialMilestonePost_milestone_channel_key" UNIQUE ("milestoneId", "channel")
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "SocialMilestonePost_channel_status_approvedAt_idx"
|
||||
ON "SocialMilestonePost" ("channel", "status", "approvedAt");
|
||||
|
||||
-- Existing X consents keep working. `processing` becomes `approved` again: the
|
||||
-- publisher reconciles against the timeline before it posts, so a re-claim
|
||||
-- cannot duplicate a post that already went out.
|
||||
INSERT INTO "SocialMilestonePost" ("id", "milestoneId", "channel", "status", "consentText", "approvedAt", "postedAt", "postUrl", "error", "attempts")
|
||||
SELECT gen_random_uuid()::text, "id", 'x',
|
||||
CASE WHEN "brandStatus" = 'processing' THEN 'approved' ELSE "brandStatus" END,
|
||||
"consentText", COALESCE("brandApprovedAt", "detectedAt"), "brandPostedAt", "brandPostUrl", "brandPostError", "attempts"
|
||||
FROM "SocialMilestone"
|
||||
WHERE "consentText" IS NOT NULL AND "brandStatus" IN ('approved', 'processing', 'posted', 'failed', 'revoked')
|
||||
ON CONFLICT ("milestoneId", "channel") DO NOTHING;
|
||||
15
sql/2026-08-16_social_milestone_retries.sql
Normal file
15
sql/2026-08-16_social_milestone_retries.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- Milestone publishing retries. Run once against the target database before
|
||||
-- deploying the application version that uses these columns.
|
||||
--
|
||||
-- docker-compose exec db psql -U postgres -d qrmaster -f - < sql/2026-08-16_social_milestone_retries.sql
|
||||
--
|
||||
-- A failed X post used to stay failed forever: the consent dialog only opens
|
||||
-- for freshly detected milestones, so nobody ever saw the retry button again.
|
||||
-- The publisher now re-queues a failed attempt on its own until it runs out of
|
||||
-- attempts, and the customer can retry a permanently failed post from Settings.
|
||||
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "attempts" INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "nextAttemptAt" TIMESTAMP(3);
|
||||
|
||||
-- The publisher polls for the oldest approved milestone that is due.
|
||||
CREATE INDEX IF NOT EXISTS "SocialMilestone_brandStatus_brandApprovedAt_idx"
|
||||
ON "SocialMilestone" ("brandStatus", "brandApprovedAt");
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<SocialMilestoneDialog />
|
||||
{/* Header with Plan Badge */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -10,11 +10,51 @@ import ChangePasswordModal from '@/components/settings/ChangePasswordModal';
|
||||
|
||||
type TabType = 'profile' | 'subscription';
|
||||
|
||||
type MilestonePost = {
|
||||
channel: string;
|
||||
status: string;
|
||||
postUrl: string | null;
|
||||
error: string | null;
|
||||
postedAt: string | null;
|
||||
};
|
||||
|
||||
type MilestoneHistoryItem = {
|
||||
id: string;
|
||||
qrTitle: string;
|
||||
uniqueScans: number;
|
||||
detectedAt: string;
|
||||
promptStatus: string;
|
||||
selfSharedAt: string | null;
|
||||
shareUrl: string | null;
|
||||
posts: MilestonePost[];
|
||||
};
|
||||
|
||||
const CHANNEL_LABELS: Record<string, string> = { x: 'X', instagram: 'Instagram' };
|
||||
|
||||
function milestoneStateLabel(milestone: MilestoneHistoryItem) {
|
||||
if (milestone.posts.some(post => post.status !== 'revoked')) return 'Approved for publishing';
|
||||
if (milestone.selfSharedAt) return 'Shared by you';
|
||||
if (milestone.promptStatus === 'declined') return 'Declined';
|
||||
return 'Waiting for your decision';
|
||||
}
|
||||
|
||||
function postStateLabel(post: MilestonePost) {
|
||||
if (post.status === 'posted') return 'published';
|
||||
if (post.status === 'failed') return 'publishing failed';
|
||||
if (post.status === 'revoked') return 'revoked before publishing';
|
||||
return 'queued';
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { fetchWithCsrf } = useCsrf();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('profile');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
const [socialPromptsEnabled, setSocialPromptsEnabled] = useState(true);
|
||||
const [socialTestResetAvailable, setSocialTestResetAvailable] = useState(false);
|
||||
const [socialSaving, setSocialSaving] = useState(false);
|
||||
const [milestones, setMilestones] = useState<MilestoneHistoryItem[]>([]);
|
||||
const [milestoneBusy, setMilestoneBusy] = useState<string | null>(null);
|
||||
|
||||
// Profile states
|
||||
const [name, setName] = useState('');
|
||||
@@ -53,6 +93,19 @@ export default function SettingsPage() {
|
||||
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);
|
||||
}
|
||||
|
||||
const historyResponse = await fetch('/api/social-milestones/history');
|
||||
if (historyResponse.ok) {
|
||||
const data = await historyResponse.json();
|
||||
setMilestones(Array.isArray(data.milestones) ? data.milestones : []);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load user data:', e);
|
||||
}
|
||||
@@ -94,6 +147,48 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
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 updateMilestone = async (id: string, action: 'retry' | 'revoke', channel: string) => {
|
||||
setMilestoneBusy(`${id}:${channel}`);
|
||||
try {
|
||||
const response = await fetchWithCsrf(`/api/social-milestones/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ action, channel }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || 'Could not update this milestone');
|
||||
setMilestones(current => current.map(milestone => milestone.id === id ? {
|
||||
...milestone,
|
||||
posts: milestone.posts.map(post => {
|
||||
const next = (data.milestone?.posts || []).find((entry: MilestonePost) => entry.channel === post.channel);
|
||||
return next ? { ...post, status: next.status, postUrl: next.postUrl, error: next.error } : post;
|
||||
}),
|
||||
} : milestone));
|
||||
showToast(action === 'retry' ? 'Post queued again.' : 'Post revoked. Nothing will be published.', 'success');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Could not update this milestone', 'error');
|
||||
} finally {
|
||||
setMilestoneBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleManageSubscription = async () => {
|
||||
setLoading(true);
|
||||
|
||||
@@ -247,6 +342,75 @@ export default function SettingsPage() {
|
||||
</CardContent>
|
||||
</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>
|
||||
{milestones.length > 0 && <div className="border-t border-gray-100 pt-4">
|
||||
<h3 className="text-sm font-medium text-gray-900">Your milestones</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Every scan milestone we detected and what happened to it.</p>
|
||||
<ul className="mt-3 divide-y divide-gray-100">
|
||||
{milestones.map(milestone => (
|
||||
<li key={milestone.id} className="py-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-gray-900">{milestone.qrTitle}</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
{milestone.uniqueScans.toLocaleString('en-US')} unique scans · {new Date(milestone.detectedAt).toLocaleDateString('en-US')} · {milestoneStateLabel(milestone)}
|
||||
</p>
|
||||
</div>
|
||||
{milestone.shareUrl && (
|
||||
<a href={milestone.shareUrl} target="_blank" rel="noreferrer" className="shrink-0 text-sm font-medium text-blue-600 hover:underline">Open card</a>
|
||||
)}
|
||||
</div>
|
||||
{/* One line per channel: consent, and everything that can be
|
||||
withdrawn or restarted, is per channel. */}
|
||||
{milestone.posts.map(post => (
|
||||
<div key={post.channel} className="mt-2 flex flex-col gap-2 rounded-md bg-gray-50 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-gray-600">
|
||||
<span className="font-medium text-gray-900">{CHANNEL_LABELS[post.channel] || post.channel}</span> — {postStateLabel(post)}
|
||||
</p>
|
||||
{post.status === 'failed' && post.error && (
|
||||
<p className="mt-0.5 text-xs text-rose-600">{post.error}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
{post.postUrl && (
|
||||
<a href={post.postUrl} target="_blank" rel="noreferrer" className="text-sm font-medium text-blue-600 hover:underline">View post</a>
|
||||
)}
|
||||
{post.status === 'failed' && (
|
||||
<Button variant="outline" disabled={milestoneBusy === `${milestone.id}:${post.channel}`} onClick={() => updateMilestone(milestone.id, 'retry', post.channel)}>Try again</Button>
|
||||
)}
|
||||
{['approved', 'failed'].includes(post.status) && (
|
||||
<Button variant="outline" disabled={milestoneBusy === `${milestone.id}:${post.channel}`} onClick={() => updateMilestone(milestone.id, 'revoke', post.channel)}>Cancel</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</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>
|
||||
|
||||
@@ -28,6 +28,7 @@ const toolsMap: Record<string, { href: string; title: string; description: strin
|
||||
"paypal-qr-code": { href: "/tools/paypal-qr-code", title: "PayPal QR Code", description: "Link directly to a PayPal payment, invoice, or donation page." },
|
||||
"pdf-qr-code": { href: "/tools/url-qr-code", title: "PDF QR Code", description: "Use a URL QR code to open a hosted PDF like a menu, brochure, or operating guide." },
|
||||
"whatsapp-qr-code": { href: "/tools/whatsapp-qr-code", title: "WhatsApp QR Code", description: "Open a WhatsApp chat with your number pre-loaded for faster support." },
|
||||
"google-review-qr-code": { href: "/tools/google-review-qr-code", title: "Google Review QR Code", description: "Send customers straight to your Google review form while they are still on site." },
|
||||
};
|
||||
|
||||
const industryPrimaryCtas: Record<string, Cta> = {
|
||||
|
||||
21
src/app/(main)/(marketing)/s/m/[token]/og/route.tsx
Normal file
21
src/app/(main)/(marketing)/s/m/[token]/og/route.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { db } from '@/lib/db';
|
||||
import { createSocialMilestoneImage, socialMilestoneImageFormat } from '@/lib/social-milestone-image';
|
||||
import type { SocialMilestoneImageCard } from '@/lib/social-milestone-image';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
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' } });
|
||||
|
||||
// `format` serves the aspect ratios the networks accept: the default 1.91:1
|
||||
// for link previews, 1:1 and 4:5 for an Instagram post.
|
||||
return createSocialMilestoneImage(
|
||||
(share.cardData || {}) as SocialMilestoneImageCard,
|
||||
share.language === 'de',
|
||||
socialMilestoneImageFormat(new URL(request.url).searchParams.get('format')),
|
||||
);
|
||||
}
|
||||
46
src/app/(main)/(marketing)/s/m/[token]/page.tsx
Normal file
46
src/app/(main)/(marketing)/s/m/[token]/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { db } from '@/lib/db';
|
||||
import { getWwwOrigin } from '@/lib/hosts';
|
||||
|
||||
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, publicShareApprovedAt: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const share = await getShare(params.token);
|
||||
if (!share) return { robots: { index: false, follow: false } };
|
||||
const card = share.cardData as { qrTitle?: string; totalUniqueScans?: number } | null;
|
||||
const count = card?.totalUniqueScans || 0;
|
||||
const title = share.language === 'de'
|
||||
? `${count.toLocaleString('de-DE')} ${count === 1 ? 'eindeutiger QR-Scan' : 'eindeutige QR-Scans'} erreicht`
|
||||
: `${count.toLocaleString('en-US')} unique QR ${count === 1 ? 'scan' : '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,
|
||||
robots: { index: false, follow: false },
|
||||
openGraph: { type: 'website', title, description, url, images: [{ url: imageUrl, width: 1200, height: 630, alt: title }] },
|
||||
twitter: { card: 'summary_large_image', title, description, images: [imageUrl] },
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
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>;
|
||||
}
|
||||
17
src/app/(main)/api/cron/social-milestones/route.ts
Normal file
17
src/app/(main)/api/cron/social-milestones/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { detectSocialMilestones } from '@/lib/social-milestones-server';
|
||||
import { getSocialMilestoneThresholds } 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}`;
|
||||
}
|
||||
|
||||
// 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 detected = await detectSocialMilestones();
|
||||
return NextResponse.json({ ok: true, detected, thresholds: getSocialMilestoneThresholds() });
|
||||
}
|
||||
161
src/app/(main)/api/internal/social-milestones/route.ts
Normal file
161
src/app/(main)/api/internal/social-milestones/route.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getWwwOrigin } from '@/lib/hosts';
|
||||
import { isSocialChannel, SOCIAL_CHANNELS, SocialChannel } from '@/lib/social-milestones';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Timeline spacing between two brand posts. Set to 0 to publish back to back. */
|
||||
function minGapHours() {
|
||||
const configured = Number(process.env.SOCIAL_MILESTONE_MIN_GAP_HOURS);
|
||||
return Number.isFinite(configured) && configured >= 0 && configured <= 168 ? configured : 24;
|
||||
}
|
||||
|
||||
// Route modules may only export request handlers, so these stay local.
|
||||
const MAX_PUBLISH_ATTEMPTS = 3;
|
||||
|
||||
/** One lock per channel, otherwise two publishers would block each other. */
|
||||
function claimLockId(channel: SocialChannel) {
|
||||
return 920241 + SOCIAL_CHANNELS.indexOf(channel);
|
||||
}
|
||||
|
||||
/** 5, 10, then 20 minutes. A transient outage resolves without a human. */
|
||||
function retryDelayMs(attempts: number) {
|
||||
return Math.min(60, 5 * 2 ** Math.max(0, attempts - 1)) * 60 * 1000;
|
||||
}
|
||||
|
||||
// This endpoint is intentionally a queue, not a social-media client. The
|
||||
// external worker fetches an approved payload and marks it complete only after
|
||||
// its own post succeeded. The app never receives X or Meta credentials.
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!isAuthorized(request)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const channelParam = request.nextUrl.searchParams.get('channel') || 'x';
|
||||
if (!isSocialChannel(channelParam)) return NextResponse.json({ error: 'Unknown channel' }, { status: 400 });
|
||||
const channel: SocialChannel = channelParam;
|
||||
const dryRun = request.nextUrl.searchParams.get('dryRun') === 'true';
|
||||
const now = Date.now();
|
||||
|
||||
// A worker can be interrupted after claiming a row. Count that as a spent
|
||||
// attempt and re-queue it instead of leaving the dashboard in "processing"
|
||||
// forever. The worker reconciles against the account before it posts again,
|
||||
// so an interruption after a successful post cannot duplicate it.
|
||||
await db.$executeRaw`
|
||||
UPDATE "SocialMilestonePost"
|
||||
SET "attempts" = "attempts" + 1,
|
||||
"status" = CASE WHEN "attempts" + 1 < ${MAX_PUBLISH_ATTEMPTS} THEN 'approved' ELSE 'failed' END,
|
||||
"nextAttemptAt" = CASE WHEN "attempts" + 1 < ${MAX_PUBLISH_ATTEMPTS} THEN ${new Date(now + retryDelayMs(1))} ELSE NULL END,
|
||||
"error" = 'The publisher was interrupted before it confirmed the post.',
|
||||
"claimedAt" = NULL
|
||||
WHERE "channel" = ${channel} AND "status" = 'processing' AND "claimedAt" < ${new Date(now - 5 * 60 * 1000)}
|
||||
`;
|
||||
|
||||
const approvalNotBefore = new Date(now - approvalDelayHours() * 60 * 60 * 1000);
|
||||
const post = await db.socialMilestonePost.findFirst({
|
||||
where: {
|
||||
channel,
|
||||
status: 'approved',
|
||||
approvedAt: { lte: approvalNotBefore },
|
||||
OR: [{ nextAttemptAt: null }, { nextAttemptAt: { lte: new Date(now) } }],
|
||||
// A paused or deleted QR code stops being advertised.
|
||||
milestone: { qr: { status: 'ACTIVE' } },
|
||||
},
|
||||
orderBy: { approvedAt: 'asc' },
|
||||
include: { milestone: { select: { id: true, shareToken: true } } },
|
||||
});
|
||||
if (!post) return NextResponse.json({ milestone: null });
|
||||
|
||||
// Several customers can consent on the same afternoon. Spacing keeps the
|
||||
// brand timeline readable, per channel. A retry is exempt: nothing of it went
|
||||
// out yet, and delaying recovery by a full day would strand the post.
|
||||
if (post.attempts === 0 && minGapHours() > 0) {
|
||||
const previous = await db.socialMilestonePost.findFirst({
|
||||
where: { channel, postedAt: { gt: new Date(now - minGapHours() * 60 * 60 * 1000) } },
|
||||
orderBy: { postedAt: 'desc' },
|
||||
select: { postedAt: true },
|
||||
});
|
||||
if (previous?.postedAt) {
|
||||
const nextPostAt = new Date(previous.postedAt.getTime() + minGapHours() * 60 * 60 * 1000);
|
||||
return NextResponse.json({ milestone: null, reason: 'spacing', nextPostAt: nextPostAt.toISOString() });
|
||||
}
|
||||
}
|
||||
|
||||
const shareUrl = post.milestone.shareToken ? `${getWwwOrigin()}/s/m/${post.milestone.shareToken}` : null;
|
||||
if (dryRun) return NextResponse.json({ milestone: { id: post.id, channel, text: post.consentText, shareUrl }, dryRun: true });
|
||||
|
||||
const claimed = await db.$transaction(async (tx) => {
|
||||
// The blocking advisory-lock function returns PostgreSQL `void`, which
|
||||
// Prisma cannot deserialize. The try variant returns a real boolean and
|
||||
// keeps the lock scoped to this transaction.
|
||||
const [lock] = await tx.$queryRaw<Array<{ acquired: boolean }>>`
|
||||
SELECT pg_try_advisory_xact_lock(${claimLockId(channel)}) AS acquired
|
||||
`;
|
||||
if (!lock?.acquired) return 0;
|
||||
const result = await tx.socialMilestonePost.updateMany({
|
||||
where: { id: post.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: post.id,
|
||||
milestoneId: post.milestone.id,
|
||||
channel,
|
||||
text: post.consentText,
|
||||
shareToken: post.milestone.shareToken,
|
||||
shareUrl,
|
||||
// Tells the worker whether an earlier attempt may already have published
|
||||
// this post, so it only spends read quota when reconciling.
|
||||
attempts: post.attempts,
|
||||
approvedAt: post.approvedAt.toISOString(),
|
||||
} });
|
||||
}
|
||||
|
||||
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'; postUrl?: string; error?: string } | null;
|
||||
if (!body?.id || !['posted', 'failed'].includes(body.result || '')) return NextResponse.json({ error: 'Invalid payload' }, { status: 400 });
|
||||
const claimed = await db.socialMilestonePost.findFirst({
|
||||
where: { id: body.id, status: 'processing' },
|
||||
select: { id: true, attempts: true },
|
||||
});
|
||||
if (!claimed) return NextResponse.json({ error: 'Post is no longer available' }, { status: 409 });
|
||||
|
||||
if (body.result === 'posted') {
|
||||
const updated = await db.socialMilestonePost.updateMany({
|
||||
where: { id: claimed.id, status: 'processing' },
|
||||
data: { status: 'posted', postedAt: new Date(), postUrl: body.postUrl || null, error: null, nextAttemptAt: null },
|
||||
});
|
||||
if (!updated.count) return NextResponse.json({ error: 'Post is no longer available' }, { status: 409 });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
// Re-queue on its own until the attempts are used up. Only then does the post
|
||||
// rest in `failed`, where the customer can restart it manually.
|
||||
const attempts = claimed.attempts + 1;
|
||||
const retry = attempts < MAX_PUBLISH_ATTEMPTS;
|
||||
const updated = await db.socialMilestonePost.updateMany({
|
||||
where: { id: claimed.id, status: 'processing' },
|
||||
data: {
|
||||
status: retry ? 'approved' : 'failed',
|
||||
attempts,
|
||||
nextAttemptAt: retry ? new Date(Date.now() + retryDelayMs(attempts)) : null,
|
||||
postedAt: null,
|
||||
postUrl: null,
|
||||
error: body.error || 'The post could not be published.',
|
||||
claimedAt: null,
|
||||
},
|
||||
});
|
||||
if (!updated.count) return NextResponse.json({ error: 'Post is no longer available' }, { status: 409 });
|
||||
return NextResponse.json({ ok: true, attempts, retryScheduled: retry });
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const adminKey = process.env.TIKTOK_ADMIN_KEY;
|
||||
const adminKey = process.env.SOCIAL_ASSET_ADMIN_KEY || process.env.TIKTOK_ADMIN_KEY;
|
||||
const provided =
|
||||
request.headers.get('x-admin-key') || request.nextUrl.searchParams.get('key');
|
||||
if (!adminKey || provided !== adminKey) {
|
||||
|
||||
@@ -7,7 +7,9 @@ import { db } from '@/lib/db';
|
||||
// are served from qrmaster.net via GET /api/social-assets/[id].
|
||||
|
||||
const isAdminRequest = (request: NextRequest) => {
|
||||
const adminKey = process.env.TIKTOK_ADMIN_KEY;
|
||||
// Asset hosting is not a TikTok feature - Instagram needs it too. The old
|
||||
// TikTok key stays valid so existing deployments keep working.
|
||||
const adminKey = process.env.SOCIAL_ASSET_ADMIN_KEY || process.env.TIKTOK_ADMIN_KEY;
|
||||
if (!adminKey) return false;
|
||||
const provided =
|
||||
request.headers.get('x-admin-key') || request.nextUrl.searchParams.get('key');
|
||||
|
||||
190
src/app/(main)/api/social-milestones/[id]/route.ts
Normal file
190
src/app/(main)/api/social-milestones/[id]/route.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { getWwwOrigin } from '@/lib/hosts';
|
||||
import { getSessionUserId } from '@/lib/session';
|
||||
import {
|
||||
buildChannelPost, getEnabledSocialChannels, isSocialChannel, milestoneThreshold,
|
||||
normalizeChannelHandle, SocialChannel, socialLocale,
|
||||
} from '@/lib/social-milestones';
|
||||
import { ensureSocialMilestoneCard } from '@/lib/social-milestones-server';
|
||||
|
||||
type Action = 'approve_brand' | 'self_share' | 'decline' | 'opt_out' | 'revoke' | 'retry';
|
||||
type Body = {
|
||||
action?: Action;
|
||||
withName?: boolean;
|
||||
channels?: string[];
|
||||
handles?: Record<string, string>;
|
||||
channel?: string;
|
||||
language?: string;
|
||||
};
|
||||
|
||||
async function ownedMilestone(id: string, userId: string) {
|
||||
return db.socialMilestone.findFirst({
|
||||
where: { id, userId },
|
||||
include: {
|
||||
user: { select: { primaryUseCase: true } },
|
||||
qr: { select: { id: true, title: true, createdAt: true } },
|
||||
posts: { select: { channel: true, status: true, postUrl: true, error: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type ClientMilestone = { status: string; selfSharedAt: Date | null; posts: Array<{ channel: string; status: string; postUrl: string | null; error: string | null }> };
|
||||
|
||||
function clientState(milestone: ClientMilestone) {
|
||||
return {
|
||||
promptStatus: milestone.status,
|
||||
selfSharedAt: milestone.selfSharedAt?.toISOString() || null,
|
||||
posts: milestone.posts.map(post => ({ channel: post.channel, status: post.status, postUrl: post.postUrl, error: post.error })),
|
||||
};
|
||||
}
|
||||
|
||||
async function stateOf(milestoneId: string) {
|
||||
const milestone = await db.socialMilestone.findUnique({
|
||||
where: { id: milestoneId },
|
||||
select: { status: true, selfSharedAt: true, posts: { select: { channel: true, status: true, postUrl: true, error: true } } },
|
||||
});
|
||||
return milestone ? clientState(milestone) : null;
|
||||
}
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: { params: { id: string } }) {
|
||||
const userId = getSessionUserId();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const milestone = await ownedMilestone(params.id, userId);
|
||||
if (!milestone) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
return NextResponse.json({ milestone: clientState(milestone) });
|
||||
}
|
||||
|
||||
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 Body | null;
|
||||
if (!body || !['approve_brand', 'self_share', 'decline', 'opt_out', 'revoke', 'retry'].includes(body.action || '')) {
|
||||
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
|
||||
}
|
||||
const milestone = await ownedMilestone(params.id, userId);
|
||||
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 });
|
||||
const isDismissible = ['detected', 'shown'].includes(milestone.status);
|
||||
|
||||
// Revoke and retry act on a single channel: consent for X is not consent for
|
||||
// Instagram, and withdrawing one must not touch the other.
|
||||
if (body.action === 'revoke' || body.action === 'retry') {
|
||||
if (!isSocialChannel(body.channel)) return NextResponse.json({ error: 'Unknown channel' }, { status: 400 });
|
||||
const post = await db.socialMilestonePost.findUnique({
|
||||
where: { milestoneId_channel: { milestoneId: milestone.id, channel: body.channel } },
|
||||
});
|
||||
if (!post) return NextResponse.json({ error: 'Nothing was approved for this channel' }, { status: 404 });
|
||||
if (body.action === 'revoke') {
|
||||
if (!['approved', 'failed'].includes(post.status)) return NextResponse.json({ error: 'Only queued approvals can be revoked' }, { status: 409 });
|
||||
await db.socialMilestonePost.update({ where: { id: post.id }, data: { status: 'revoked', error: null, nextAttemptAt: null } });
|
||||
} else {
|
||||
if (post.status !== 'failed') return NextResponse.json({ error: 'Only failed posts can be restarted' }, { status: 409 });
|
||||
// Restarts reuse the approved text unchanged - a retry must never
|
||||
// publish something the customer did not read before consenting.
|
||||
await db.socialMilestonePost.update({ where: { id: post.id }, data: { status: 'approved', attempts: 0, nextAttemptAt: null, error: null } });
|
||||
}
|
||||
return NextResponse.json({ ok: true, milestone: await stateOf(milestone.id) });
|
||||
}
|
||||
|
||||
if (body.action === 'decline' || body.action === 'opt_out') {
|
||||
if (!isDismissible) return NextResponse.json({ error: 'This milestone has already been dismissed' }, { status: 409 });
|
||||
const now = new Date();
|
||||
await db.$transaction([
|
||||
db.socialMilestone.update({ where: { id: milestone.id }, data: { status: 'declined', respondedAt: now } }),
|
||||
...(body.action === 'opt_out' ? [db.user.update({ where: { id: userId }, data: { socialPromptOptOut: true } })] : []),
|
||||
]);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const language = socialLocale(body.language);
|
||||
const withName = body.withName === true;
|
||||
const card = await ensureSocialMilestoneCard({
|
||||
milestoneId: milestone.id,
|
||||
cardData: milestone.cardData,
|
||||
kind: milestone.kind,
|
||||
detectedAt: milestone.detectedAt,
|
||||
language,
|
||||
qr: milestone.qr,
|
||||
primaryUseCase: milestone.user.primaryUseCase,
|
||||
});
|
||||
const now = new Date();
|
||||
// 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 shareUrl = `${getWwwOrigin()}/s/m/${token}`;
|
||||
|
||||
if (body.action === 'self_share') {
|
||||
const updated = await db.socialMilestone.update({
|
||||
where: { id: milestone.id },
|
||||
data: {
|
||||
status: 'self_shared', respondedAt: milestone.respondedAt || now,
|
||||
selfSharedAt: now, publicShareApprovedAt: now, shareToken: token, cardData: card, language,
|
||||
},
|
||||
select: { status: true, selfSharedAt: true, posts: { select: { channel: true, status: true, postUrl: true, error: true } } },
|
||||
});
|
||||
return NextResponse.json({ ok: true, shareToken: token, shareUrl, shareVersion: now.getTime(), milestone: clientState(updated) });
|
||||
}
|
||||
|
||||
const enabled = getEnabledSocialChannels();
|
||||
const channels = (body.channels || []).filter(isSocialChannel).filter(channel => enabled.includes(channel));
|
||||
if (!channels.length) return NextResponse.json({ error: 'Choose at least one channel' }, { status: 400 });
|
||||
|
||||
const handles = new Map<SocialChannel, string | null>();
|
||||
for (const channel of channels) {
|
||||
if (!withName) {
|
||||
handles.set(channel, null);
|
||||
continue;
|
||||
}
|
||||
const handle = normalizeChannelHandle(channel, body.handles?.[channel] || '');
|
||||
if (!handle) return NextResponse.json({ error: `Enter a valid ${channel === 'instagram' ? 'Instagram' : 'X'} handle` }, { status: 400 });
|
||||
handles.set(channel, handle);
|
||||
}
|
||||
|
||||
const locked = milestone.posts.filter(post => channels.includes(post.channel as SocialChannel) && ['processing', 'posted'].includes(post.status));
|
||||
if (locked.length) return NextResponse.json({ error: 'This post is already being processed' }, { status: 409 });
|
||||
|
||||
const updated = await db.$transaction(async tx => {
|
||||
if (withName) {
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
...(handles.has('x') ? { xHandle: handles.get('x') } : {}),
|
||||
...(handles.has('instagram') ? { instagramHandle: handles.get('instagram') } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const channel of channels) {
|
||||
const consentText = buildChannelPost({
|
||||
channel,
|
||||
primaryUseCase: milestone.user.primaryUseCase,
|
||||
totalUniqueScans: (card as { totalUniqueScans?: number }).totalUniqueScans || threshold,
|
||||
locale: language,
|
||||
qrTitle: milestone.qr.title,
|
||||
shareUrl,
|
||||
handle: handles.get(channel) || null,
|
||||
});
|
||||
await tx.socialMilestonePost.upsert({
|
||||
where: { milestoneId_channel: { milestoneId: milestone.id, channel } },
|
||||
create: { milestoneId: milestone.id, channel, consentText, handle: handles.get(channel) || null, approvedAt: now, status: 'approved' },
|
||||
// A re-approval after a correction or a withdrawal starts over.
|
||||
update: { consentText, handle: handles.get(channel) || null, status: 'approved', attempts: 0, nextAttemptAt: null, error: null },
|
||||
});
|
||||
}
|
||||
return tx.socialMilestone.update({
|
||||
where: { id: milestone.id },
|
||||
data: {
|
||||
status: 'approved', withName, language, cardData: card, respondedAt: now,
|
||||
shareToken: token, publicShareApprovedAt: now,
|
||||
},
|
||||
select: { status: true, selfSharedAt: true, posts: { select: { channel: true, status: true, postUrl: true, error: true } } },
|
||||
});
|
||||
});
|
||||
return NextResponse.json({ ok: true, milestone: clientState(updated) });
|
||||
}
|
||||
51
src/app/(main)/api/social-milestones/history/route.ts
Normal file
51
src/app/(main)/api/social-milestones/history/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getWwwOrigin } from '@/lib/hosts';
|
||||
import { getSessionUserId } from '@/lib/session';
|
||||
import { milestoneThreshold } from '@/lib/social-milestones';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// The consent dialog opens once per milestone. Everything that happened before
|
||||
// - a declined prompt, a queued post, a post that ran out of attempts - is only
|
||||
// visible here, which is also the only place a failed post can be restarted.
|
||||
export async function GET() {
|
||||
const userId = getSessionUserId();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const milestones = await db.socialMilestone.findMany({
|
||||
where: { userId },
|
||||
orderBy: { detectedAt: 'desc' },
|
||||
take: 50,
|
||||
select: {
|
||||
id: true, kind: true, status: true, detectedAt: true, cardData: true,
|
||||
selfSharedAt: true, shareToken: true, publicShareApprovedAt: true,
|
||||
qr: { select: { title: true } },
|
||||
posts: { select: { channel: true, status: true, postUrl: true, error: true, postedAt: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
milestones: milestones.map(milestone => {
|
||||
const card = milestone.cardData as { totalUniqueScans?: number } | null;
|
||||
return {
|
||||
id: milestone.id,
|
||||
qrTitle: milestone.qr.title,
|
||||
uniqueScans: card?.totalUniqueScans || milestoneThreshold(milestone.kind) || 0,
|
||||
detectedAt: milestone.detectedAt.toISOString(),
|
||||
promptStatus: milestone.status,
|
||||
selfSharedAt: milestone.selfSharedAt?.toISOString() || null,
|
||||
posts: milestone.posts.map(post => ({
|
||||
channel: post.channel,
|
||||
status: post.status,
|
||||
postUrl: post.postUrl,
|
||||
error: post.error,
|
||||
postedAt: post.postedAt?.toISOString() || null,
|
||||
})),
|
||||
shareUrl: milestone.shareToken && milestone.publicShareApprovedAt
|
||||
? `${getWwwOrigin()}/s/m/${milestone.shareToken}`
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
60
src/app/(main)/api/social-milestones/preferences/route.ts
Normal file
60
src/app/(main)/api/social-milestones/preferences/route.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
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 } }),
|
||||
// Dropping the per-channel approvals is what makes the reset complete:
|
||||
// no row means no consent, which is exactly the pre-prompt state.
|
||||
...(latest ? [
|
||||
db.socialMilestonePost.deleteMany({ where: { milestoneId: latest.id } }),
|
||||
db.socialMilestone.update({
|
||||
where: { id: latest.id },
|
||||
data: {
|
||||
status: 'detected', shownAt: null, respondedAt: 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 });
|
||||
}
|
||||
83
src/app/(main)/api/social-milestones/route.ts
Normal file
83
src/app/(main)/api/social-milestones/route.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getWwwOrigin } from '@/lib/hosts';
|
||||
import { getSessionUserId } from '@/lib/session';
|
||||
import { getEnabledSocialChannels, milestonePostParts, milestoneThreshold, SOCIAL_CHANNELS, socialLocale } from '@/lib/social-milestones';
|
||||
import { ensureSocialMilestoneCard } from '@/lib/social-milestones-server';
|
||||
|
||||
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, instagramHandle: true, primaryUseCase: true },
|
||||
});
|
||||
if (!user || user.socialPromptOptOut) return NextResponse.json({ milestone: null });
|
||||
|
||||
const milestone = await db.socialMilestone.findFirst({
|
||||
// `shown` is a durable delivery receipt: one milestone may auto-open only
|
||||
// once, even across refreshes, tabs and later dashboard visits.
|
||||
where: { userId, status: 'detected' },
|
||||
orderBy: { detectedAt: 'asc' },
|
||||
include: { qr: { select: { id: true, title: true, createdAt: true } } },
|
||||
});
|
||||
if (!milestone) return NextResponse.json({ milestone: null });
|
||||
|
||||
const threshold = milestoneThreshold(milestone.kind);
|
||||
if (!threshold) return NextResponse.json({ milestone: null });
|
||||
const locale = socialLocale(request.nextUrl.searchParams.get('locale'));
|
||||
const shareToken = milestone.shareToken || randomBytes(9).toString('base64url');
|
||||
const shareUrl = `${getWwwOrigin()}/s/m/${shareToken}`;
|
||||
const card = await ensureSocialMilestoneCard({
|
||||
milestoneId: milestone.id,
|
||||
cardData: milestone.cardData,
|
||||
kind: milestone.kind,
|
||||
detectedAt: milestone.detectedAt,
|
||||
language: locale,
|
||||
qr: milestone.qr,
|
||||
primaryUseCase: user.primaryUseCase,
|
||||
refresh: true,
|
||||
snapshotAt: new Date(),
|
||||
});
|
||||
const delivered = await db.socialMilestone.updateMany({
|
||||
where: { id: milestone.id, status: 'detected' },
|
||||
data: { status: 'shown', shownAt: new Date(), shareToken },
|
||||
});
|
||||
if (!delivered.count) return NextResponse.json({ milestone: null });
|
||||
|
||||
// One entry per channel: its own text, its own handle. The dialog recombines
|
||||
// head + mention + tail while the customer types, so what is on screen is
|
||||
// exactly what the server will store as the consent text. `available` marks
|
||||
// the channels a publisher is configured for - the others are still listed
|
||||
// because sharing them yourself works without any publisher.
|
||||
const enabled = getEnabledSocialChannels();
|
||||
const channels = SOCIAL_CHANNELS.map(channel => ({
|
||||
channel,
|
||||
available: enabled.includes(channel),
|
||||
defaultHandle: (channel === 'instagram' ? user.instagramHandle : user.xHandle) || '',
|
||||
...milestonePostParts({
|
||||
channel,
|
||||
primaryUseCase: user.primaryUseCase,
|
||||
totalUniqueScans: card.totalUniqueScans || threshold,
|
||||
locale,
|
||||
qrTitle: milestone.qr.title,
|
||||
shareUrl,
|
||||
}),
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
milestone: {
|
||||
id: milestone.id, qrTitle: milestone.qr.title, threshold,
|
||||
promptStatus: 'shown',
|
||||
language: locale,
|
||||
shareUrl,
|
||||
channels,
|
||||
posts: [],
|
||||
card,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { getWwwOrigin } from '@/lib/hosts';
|
||||
import { db } from '@/lib/db';
|
||||
import { hashIP } from '@/lib/hash';
|
||||
import { triggerLifecycleScoring } from '@/lib/revops-server';
|
||||
import { detectSocialMilestones } from '@/lib/social-milestones-server';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
@@ -260,6 +261,12 @@ async function trackScan(qrId: string, userId: string, request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
// The customer sees a newly crossed milestone on their next dashboard
|
||||
// visit; no separate cron invocation is required after a real scan.
|
||||
if (isUnique) {
|
||||
await detectSocialMilestones(qrId);
|
||||
}
|
||||
|
||||
const activatedUsers = await db.user.updateMany({
|
||||
where: {
|
||||
id: userId,
|
||||
|
||||
@@ -144,6 +144,25 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||
},
|
||||
];
|
||||
|
||||
// Alternatives & head-to-head comparison pages.
|
||||
// These are hardcoded route files (no data module), so keep this list in sync
|
||||
// with `alternativesPages` in src/lib/indexnow.ts and the "Compare" column in
|
||||
// src/components/ui/Footer.tsx.
|
||||
const comparisonPages = [
|
||||
{ path: '/alternatives', priority: 0.9 },
|
||||
{ path: '/alternatives/beaconstac', priority: 0.85 },
|
||||
{ path: '/alternatives/bitly', priority: 0.85 },
|
||||
{ path: '/alternatives/flowcode', priority: 0.85 },
|
||||
{ path: '/alternatives/qr-code-generator', priority: 0.85 },
|
||||
{ path: '/vs', priority: 0.9 },
|
||||
{ path: '/vs/beaconstac', priority: 0.85 },
|
||||
].map(({ path, priority }) => ({
|
||||
url: `${baseUrl}${path}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly' as const,
|
||||
priority,
|
||||
}));
|
||||
|
||||
const publishedPseoPages = [
|
||||
...publishedComparisonPages.map((page) => ({
|
||||
url: `${baseUrl}${page.canonicalPath}`,
|
||||
@@ -216,12 +235,8 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.95,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/dynamic-barcode-generator`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.9,
|
||||
},
|
||||
// NOTE: /dynamic-barcode-generator and /barcode-generator are 301'd to
|
||||
// /tools/barcode-generator in next.config.mjs and must not be listed here.
|
||||
{
|
||||
url: `${baseUrl}/bulk-qr-code-generator`,
|
||||
lastModified: new Date(),
|
||||
@@ -234,6 +249,24 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/manage-qr-codes`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/qr-code-print-size-guide`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/developers`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.8,
|
||||
},
|
||||
|
||||
{
|
||||
url: `${baseUrl}/pricing`,
|
||||
@@ -272,6 +305,18 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||
changeFrequency: 'yearly',
|
||||
priority: 0.4,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/terms`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'yearly',
|
||||
priority: 0.4,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/cookie-policy`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'yearly',
|
||||
priority: 0.4,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/contact`,
|
||||
lastModified: new Date(),
|
||||
@@ -301,6 +346,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||
...blogPages,
|
||||
...learnPages,
|
||||
...growthUseCasePages,
|
||||
...comparisonPages,
|
||||
...publishedPseoPages,
|
||||
...industryUrls,
|
||||
...authorPages,
|
||||
|
||||
333
src/components/dashboard/SocialMilestoneDialog.tsx
Normal file
333
src/components/dashboard/SocialMilestoneDialog.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Check, Copy, ExternalLink, Instagram, LineChart, Linkedin, QrCode, Send, X } 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';
|
||||
import { roundedChartPath } from '@/lib/rounded-chart-path';
|
||||
|
||||
type Channel = 'x' | 'instagram';
|
||||
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 ChannelOption = { channel: Channel; available: boolean; defaultHandle: string; head: string; tail: string; mentionWord: string };
|
||||
type PostState = { channel: string; status: string; postUrl: string | null; error: string | null };
|
||||
type Milestone = { id: string; qrTitle: string; threshold: number; shareUrl: string; language: 'en' | 'de'; card: Card; promptStatus: string; channels: ChannelOption[]; posts: PostState[] };
|
||||
type BrandState = { promptStatus: string; selfSharedAt: string | null; posts: PostState[] };
|
||||
|
||||
const CHANNEL_LABELS: Record<Channel, string> = { x: 'X', instagram: 'Instagram' };
|
||||
|
||||
function Trend({ trend, locale }: { trend: NonNullable<Card['trend']>; locale: 'en' | 'de' }) {
|
||||
const ceiling = trend.target <= 5 ? 5 : trend.target * 1.25;
|
||||
const ticks = trend.target <= 5
|
||||
? [1, 2, 3, 4, 5]
|
||||
: Array.from({ length: 5 }, (_, index) => trend.target * (index + 1) / 4);
|
||||
const first = new Date(trend.points[0].at).getTime();
|
||||
const last = Math.max(new Date(trend.points[trend.points.length - 1].at).getTime(), first + 1);
|
||||
const chartPoints = trend.points.map(point => {
|
||||
const x = 60 + ((new Date(point.at).getTime() - first) / (last - first)) * 410;
|
||||
const y = 142 - (point.total / ceiling) * 126;
|
||||
return { x, y };
|
||||
});
|
||||
const path = roundedChartPath(chartPoints, 18);
|
||||
const endPoint = chartPoints[chartPoints.length - 1] || { x: 470, y: 142 };
|
||||
const number = new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { maximumFractionDigits: 2 });
|
||||
return <svg viewBox="0 0 480 184" className="w-full" role="img" aria-label="Cumulative unique scan trend">
|
||||
{ticks.map(tick => {
|
||||
const y = 142 - (tick / ceiling) * 126;
|
||||
return <g key={tick}><text x="48" y={y + 4} textAnchor="end" fontSize="11" fontWeight={tick === trend.target ? 700 : 500} fill={tick === trend.target ? '#0256ff' : '#64748b'}>{number.format(tick)}</text><line x1="60" x2="470" y1={y} y2={y} stroke={tick === trend.target ? '#bfdbfe' : '#e2e8f0'} strokeWidth={tick === trend.target ? 1.6 : 1} /></g>;
|
||||
})}
|
||||
<path d={path} fill="none" stroke="#0256ff" strokeWidth="3.5" strokeLinejoin="round" strokeLinecap="round" />
|
||||
<circle cx={endPoint.x} cy={endPoint.y} r="4.5" fill="#ffffff" stroke="#0256ff" strokeWidth="3" />
|
||||
<text x="60" y="176" fontSize="11" fontWeight="600" fill="#45617f">{trend.startLabel}</text>
|
||||
<text x="470" y="176" textAnchor="end" fontSize="11" fontWeight="600" fill="#45617f">{trend.endLabel}</text>
|
||||
</svg>;
|
||||
}
|
||||
|
||||
async function copyShareText(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
} catch {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
const copied = document.execCommand('copy');
|
||||
textarea.remove();
|
||||
if (!copied) throw new Error('Copying is blocked by this browser');
|
||||
}
|
||||
}
|
||||
|
||||
export function SocialMilestoneDialog() {
|
||||
const { fetchWithCsrf } = useCsrf();
|
||||
const { locale } = useTranslation();
|
||||
const [milestone, setMilestone] = useState<Milestone | null>(null);
|
||||
const [withName, setWithName] = useState(false);
|
||||
const [handles, setHandles] = useState<Record<string, string>>({});
|
||||
const [selected, setSelected] = useState<Channel[]>([]);
|
||||
const [saving, setSaving] = useState<'brand' | 'self' | 'copy' | 'instagram' | null>(null);
|
||||
const [brand, setBrand] = useState<BrandState | null>(null);
|
||||
const loadedMilestone = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (loadedMilestone.current) return;
|
||||
loadedMilestone.current = true;
|
||||
fetch(`/api/social-milestones?locale=${locale}`).then(async response => {
|
||||
if (response.ok) {
|
||||
const next = (await response.json()).milestone as Milestone | null;
|
||||
setMilestone(next);
|
||||
if (next) setBrand({ promptStatus: next.promptStatus, selfSharedAt: null, posts: next.posts || [] });
|
||||
}
|
||||
}).catch(() => undefined);
|
||||
}, [locale]);
|
||||
useEffect(() => {
|
||||
if (!milestone) return;
|
||||
setHandles(Object.fromEntries(milestone.channels.map(option => [option.channel, option.defaultHandle])));
|
||||
// Instagram stays unticked on purpose: consent for one channel is not
|
||||
// consent for the next, so the second one has to be an actual decision.
|
||||
setSelected(milestone.channels.filter(option => option.available && option.channel === 'x').map(option => option.channel));
|
||||
}, [milestone]);
|
||||
|
||||
const posts = brand?.posts || [];
|
||||
const postFor = (channel: Channel) => posts.find(post => post.channel === channel);
|
||||
const pending = posts.some(post => ['approved', 'processing'].includes(post.status));
|
||||
useEffect(() => {
|
||||
if (!milestone || !pending) return;
|
||||
const poll = async () => {
|
||||
const response = await fetch(`/api/social-milestones/${milestone.id}`);
|
||||
if (response.ok) setBrand((await response.json()).milestone);
|
||||
};
|
||||
const timer = window.setInterval(poll, 3000);
|
||||
void poll();
|
||||
return () => window.clearInterval(timer);
|
||||
}, [milestone, pending]);
|
||||
|
||||
const german = milestone?.language === 'de';
|
||||
const copy = german
|
||||
? { heading: 'Ein echter Erfolg', subtitle: 'hat einen Scan-Meilenstein erreicht.', consent: 'Darf QR Master diesen Erfolg auf den eigenen Kanälen veröffentlichen?', name: 'Meinen Handle nennen', decline: 'Nicht jetzt', self: 'Selbst teilen', approve: 'Von QR Master posten', queued: 'Wird veröffentlicht …', posted: 'Veröffentlicht', failed: 'Veröffentlichung fehlgeschlagen' }
|
||||
: { heading: 'A real milestone', subtitle: 'just reached a scan milestone.', consent: 'May QR Master publish this success on its own channels?', name: 'Mention my handle', decline: 'Not now', self: 'Share myself', approve: 'Post from QR Master', queued: 'Publishing …', posted: 'Published', failed: 'Publishing failed' };
|
||||
|
||||
const optionFor = (channel: Channel) => milestone?.channels.find(option => option.channel === channel);
|
||||
const mentionOf = (channel: Channel) => {
|
||||
const option = optionFor(channel);
|
||||
const handle = (handles[channel] || '').trim().replace(/^@/, '');
|
||||
return option && withName && handle ? `\n\n${option.mentionWord} @${handle}.` : '';
|
||||
};
|
||||
/** Without the channel suffix - used where the share URL is added by hand. */
|
||||
const composeBody = (channel: Channel) => {
|
||||
const option = optionFor(channel);
|
||||
return option ? `${option.head}${mentionOf(channel)}` : '';
|
||||
};
|
||||
// Head + mention + tail is exactly how the server assembles the consent text.
|
||||
const compose = (channel: Channel) => {
|
||||
const option = optionFor(channel);
|
||||
return option ? `${composeBody(channel)}${option.tail}` : '';
|
||||
};
|
||||
const previews = useMemo(
|
||||
() => selected.map(channel => ({ channel, text: compose(channel) })),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[selected, handles, withName, milestone],
|
||||
);
|
||||
|
||||
const update = async (action: 'approve_brand' | 'self_share' | 'decline' | 'opt_out', extra?: Record<string, unknown>) => {
|
||||
if (!milestone) return null;
|
||||
const response = await fetchWithCsrf(`/api/social-milestones/${milestone.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ action, withName, handles, channels: selected, language: milestone.language, ...extra }),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.error || 'Could not save your choice');
|
||||
return result;
|
||||
};
|
||||
const prepareSelfShare = async () => {
|
||||
const result = await update('self_share');
|
||||
const shareUrl = `${result.shareUrl}?v=${result.shareVersion}`;
|
||||
setBrand(result.milestone);
|
||||
return {
|
||||
shareUrl,
|
||||
// 4:5 is the tallest ratio Instagram accepts and the one that keeps the
|
||||
// numbers readable in a phone feed.
|
||||
imageUrl: `${result.shareUrl}/og?format=portrait&v=${result.shareVersion}`,
|
||||
text: `${composeBody('x')}\n\n${shareUrl}`,
|
||||
};
|
||||
};
|
||||
const shareSelf = async (network: 'x' | 'linkedin') => {
|
||||
if (!milestone) return;
|
||||
// LinkedIn's public share dialog accepts only a URL. Start copying the
|
||||
// prepared commentary while this click still owns browser focus, then
|
||||
// open the LinkedIn share dialog after public-share consent is persisted.
|
||||
const commentary = composeBody('x');
|
||||
const linkedinCopy = network === 'linkedin'
|
||||
? copyShareText(commentary).then(() => true).catch(() => false)
|
||||
: Promise.resolve(true);
|
||||
// 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('about:blank', '_blank');
|
||||
if (shareWindow) shareWindow.opener = null;
|
||||
setSaving('self');
|
||||
try {
|
||||
const { shareUrl, text } = await prepareSelfShare();
|
||||
const copied = await linkedinCopy;
|
||||
const targetUrl = network === 'x'
|
||||
? `https://x.com/intent/post?text=${encodeURIComponent(text)}`
|
||||
: `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`;
|
||||
if (shareWindow) shareWindow.location.href = targetUrl;
|
||||
else window.location.assign(targetUrl);
|
||||
showToast(network === 'linkedin'
|
||||
? copied
|
||||
? (german ? 'LinkedIn-Text kopiert. Jetzt nur noch einfügen.' : 'LinkedIn text copied. Paste it into the composer.')
|
||||
: (german ? 'LinkedIn ist geöffnet. Bitte nutze „Text kopieren“ im QR-Master-Fenster.' : 'LinkedIn is open. Please use “Copy text” in the QR Master window.')
|
||||
: 'X share composer opened.', copied ? 'success' : 'error');
|
||||
} catch (error) { shareWindow?.close(); showToast(error instanceof Error ? error.message : 'Could not prepare this share', 'error'); }
|
||||
finally { setSaving(null); }
|
||||
};
|
||||
// Instagram has no composer a website can prefill: there is no intent URL,
|
||||
// and the story deep links need a native pasteboard the browser cannot
|
||||
// reach. What is left is the system share sheet on a phone, and a download
|
||||
// plus the caption in the clipboard everywhere else.
|
||||
const shareInstagram = async () => {
|
||||
if (!milestone) return;
|
||||
setSaving('instagram');
|
||||
try {
|
||||
const caption = compose('instagram');
|
||||
const { imageUrl } = await prepareSelfShare();
|
||||
const response = await fetch(imageUrl);
|
||||
if (!response.ok) throw new Error(german ? 'Das Meilenstein-Bild konnte nicht geladen werden.' : 'Could not load the milestone image');
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], 'qr-master-milestone.png', { type: blob.type || 'image/png' });
|
||||
const copied = await copyShareText(caption).then(() => true).catch(() => false);
|
||||
if (navigator.canShare?.({ files: [file] })) {
|
||||
try {
|
||||
await navigator.share({ files: [file], text: caption });
|
||||
return;
|
||||
} catch (error) {
|
||||
// Sheet dismissed on purpose - do not push a download nobody asked for.
|
||||
if (error instanceof Error && error.name === 'AbortError') return;
|
||||
}
|
||||
}
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = objectUrl;
|
||||
link.download = file.name;
|
||||
link.click();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
showToast(copied
|
||||
? (german ? 'Bild geladen, Text kopiert. Beides in Instagram einfügen.' : 'Image downloaded, caption copied. Add both in Instagram.')
|
||||
: (german ? 'Bild geladen. Bitte „Nur Text kopieren“ für die Bildunterschrift nutzen.' : 'Image downloaded. Use “Copy text only” for the caption.'),
|
||||
copied ? 'success' : 'error');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Could not prepare this share', 'error');
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
};
|
||||
const copyLinkedInText = async () => {
|
||||
setSaving('copy');
|
||||
try {
|
||||
const { text } = await prepareSelfShare();
|
||||
await copyShareText(text);
|
||||
showToast(german ? 'LinkedIn-Text kopiert.' : 'LinkedIn post text copied.', 'success');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Could not copy the LinkedIn text', 'error');
|
||||
} finally { setSaving(null); }
|
||||
};
|
||||
const approveBrand = async () => {
|
||||
setSaving('brand');
|
||||
try {
|
||||
const result = await update('approve_brand');
|
||||
setBrand(result.milestone);
|
||||
showToast(copy.queued, 'success');
|
||||
} catch (error) { showToast(error instanceof Error ? error.message : 'Could not save your choice', 'error'); }
|
||||
finally { setSaving(null); }
|
||||
};
|
||||
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 = german
|
||||
? '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');
|
||||
};
|
||||
const toggleChannel = (channel: Channel) => {
|
||||
setSelected(current => current.includes(channel) ? current.filter(entry => entry !== channel) : [...current, channel]);
|
||||
};
|
||||
|
||||
if (!milestone) return null;
|
||||
const card = milestone.card;
|
||||
const count = card.totalUniqueScans || milestone.threshold;
|
||||
const promptStatus = brand?.promptStatus || milestone.promptStatus;
|
||||
const brandChannels = milestone.channels.filter(option => option.available);
|
||||
// A channel that is already published or in flight cannot be re-approved.
|
||||
const canApprove = selected.length > 0 && !selected.some(channel => ['processing', 'posted'].includes(postFor(channel)?.status || ''));
|
||||
return <Dialog open onOpenChange={open => !open && setMilestone(null)} containerClassName="max-w-[960px]">
|
||||
<DialogContent className="flex max-h-[calc(100dvh-1rem)] w-full max-w-none flex-col overflow-hidden border-slate-200 p-0 shadow-[0_30px_45px_-30px_rgba(50,50,93,0.45)] sm:max-h-[calc(100dvh-1.5rem)]">
|
||||
<div className="shrink-0 px-4 py-4 sm:px-6">
|
||||
<DialogHeader className="flex-row items-center space-y-0 text-left">
|
||||
<div className="mr-3 flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-slate-200 bg-white text-[#0256ff] shadow-sm"><QrCode className="h-5 w-5" /></div>
|
||||
<div className="min-w-0 flex-1"><DialogTitle className="text-xl font-semibold tracking-[-0.03em] text-[#061b31] sm:text-[22px]">{copy.heading}</DialogTitle><DialogDescription className="truncate pt-1 text-sm text-[#4b5e76]"><strong className="font-medium text-[#061b31]">{milestone.qrTitle}</strong> {copy.subtitle}</DialogDescription></div>
|
||||
<button type="button" aria-label={german ? 'Schließen' : 'Close'} onClick={() => setMilestone(null)} className="ml-3 inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800 focus:outline-none focus:ring-2 focus:ring-[#0256ff]"><X className="h-5 w-5" /></button>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<div className="min-h-0 space-y-4 overflow-y-auto border-y border-slate-100 px-4 py-4 overscroll-contain sm:px-6 md:grid md:grid-cols-[minmax(0,1.12fr)_minmax(320px,0.88fr)] md:gap-6 md:space-y-0">
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-4 shadow-[0_14px_28px_-22px_rgba(50,50,93,0.4)]">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 pb-3 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-4 flex items-end justify-between gap-6"><div><div className="text-[10px] font-semibold tracking-[0.1em] text-slate-400">UNIQUE SCANS</div><div className="mt-1 text-5xl font-normal tracking-[-0.04em] tabular-nums text-[#061b31]">{count.toLocaleString(german ? 'de-DE' : 'en-US')}</div></div><div className="pb-1 text-right"><div className="text-[10px] font-semibold tracking-[0.1em] text-slate-400">{german ? 'SCANS INSGESAMT' : 'TOTAL SCANS'}</div><div className="mt-1 text-2xl font-normal tabular-nums text-[#061b31]">{(card.totalScans || count).toLocaleString(german ? 'de-DE' : 'en-US')}</div></div></div>
|
||||
<div className="mt-3 border-t border-slate-100 pt-3">{card.trend ? <Trend trend={card.trend} locale={milestone.language} /> : <div className="py-8 text-center text-xs text-[#45617f]">{german ? 'Noch keine Verlaufskurve verfügbar.' : 'No scan timeline available yet.'}</div>}</div>
|
||||
{card.trend && <div className="mt-1 flex items-center justify-end gap-2 text-[11px] text-[#45617f]"><LineChart className="h-3.5 w-3.5 text-[#0256ff]" />{german ? 'Kumulierte eindeutige Scans' : 'Cumulative unique scans'}</div>}
|
||||
<div className="mt-3 truncate border-t border-slate-100 pt-3 text-sm font-medium text-[#061b31]">{card.qrTitle}</div>
|
||||
</section>
|
||||
<div className="min-w-0 space-y-4 md:pt-1">
|
||||
<div>
|
||||
<p className="text-sm leading-6 text-[#4b5e76]">{copy.consent}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-3">
|
||||
{brandChannels.map(option => (
|
||||
<label key={option.channel} className="flex cursor-pointer items-center gap-2 rounded-md border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-700 hover:border-[#0256ff]">
|
||||
<input type="checkbox" checked={selected.includes(option.channel)} onChange={() => toggleChannel(option.channel)} disabled={saving !== null} className="h-4 w-4 rounded border-slate-300 text-[#0256ff] focus:ring-[#0256ff]" />
|
||||
{option.channel === 'instagram' ? <Instagram className="h-3.5 w-3.5" /> : <X className="h-3.5 w-3.5" />}
|
||||
{CHANNEL_LABELS[option.channel]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{previews.length === 0
|
||||
? <p className="mt-3 text-xs text-slate-500">{german ? 'Kein Kanal ausgewählt – QR Master veröffentlicht nichts.' : 'No channel selected – QR Master publishes nothing.'}</p>
|
||||
: previews.map(preview => (
|
||||
<div key={preview.channel} className="mt-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-slate-400">{CHANNEL_LABELS[preview.channel]}</div>
|
||||
<blockquote className="mt-1 max-h-40 overflow-y-auto whitespace-pre-line break-words border-l border-[#0256ff] pl-3 text-sm leading-6 text-[#273951]">{preview.text}</blockquote>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<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={saving !== null} className="h-4 w-4 rounded border-slate-300 text-[#0256ff] focus:ring-[#0256ff]" />{copy.name}</label>
|
||||
{withName && selected.map(channel => (
|
||||
<input
|
||||
key={channel}
|
||||
aria-label={`${CHANNEL_LABELS[channel]} handle`}
|
||||
value={handles[channel] || ''}
|
||||
onChange={event => setHandles(current => ({ ...current, [channel]: event.target.value }))}
|
||||
disabled={saving !== null}
|
||||
maxLength={channel === 'instagram' ? 31 : 16}
|
||||
placeholder={channel === 'instagram' ? '@your.instagram' : '@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>
|
||||
<div className="space-y-2"><div className="text-xs font-medium text-slate-500">{copy.self}</div><div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-1 lg:grid-cols-2"><Button variant="outline" size="sm" className="w-full" onClick={() => shareSelf('x')} disabled={saving !== null}><X className="mr-1.5 h-3.5 w-3.5" />X</Button><Button variant="outline" size="sm" className="w-full" onClick={() => shareSelf('linkedin')} disabled={saving !== null}><Linkedin className="mr-1.5 h-3.5 w-3.5" />{german ? 'Kopieren & LinkedIn' : 'Copy & open LinkedIn'}</Button><Button variant="outline" size="sm" className="w-full" onClick={shareInstagram} disabled={saving !== null}><Instagram className="mr-1.5 h-3.5 w-3.5" />Instagram</Button></div><button type="button" onClick={copyLinkedInText} disabled={saving !== null} className="inline-flex items-center gap-1.5 text-xs font-medium text-[#45617f] underline underline-offset-2 hover:text-[#0256ff] disabled:opacity-50"><Copy className="h-3.5 w-3.5" />{german ? 'Nur Text kopieren' : 'Copy text only'}</button><p className="text-[11px] leading-4 text-slate-500">{german ? 'LinkedIn öffnet den Editor und kopiert den fertigen Text automatisch. Instagram lässt sich nicht vorbefüllen: am Handy öffnet das Teilen-Menü, sonst wird das Bild geladen und der Text kopiert.' : 'LinkedIn opens the composer and copies the finished text automatically. Instagram cannot be prefilled: on a phone the share sheet opens, otherwise the image is downloaded and the caption copied.'}</p></div>
|
||||
{posts.filter(post => post.status !== 'revoked').map(post => (
|
||||
<div key={post.channel} className={`flex items-start justify-between gap-3 rounded-md px-3 py-2 text-sm ${post.status === 'posted' ? 'bg-emerald-50 text-emerald-800' : post.status === 'failed' ? 'bg-rose-50 text-rose-800' : 'bg-blue-50 text-blue-800'}`}>
|
||||
<span>{CHANNEL_LABELS[post.channel as Channel] || post.channel}: {post.status === 'posted' ? copy.posted : post.status === 'failed' ? `${copy.failed}${post.error ? ` – ${post.error}` : ''}` : copy.queued}</span>
|
||||
{post.postUrl && <a href={post.postUrl} 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>
|
||||
</div>
|
||||
<DialogFooter className="shrink-0 bg-slate-50 px-4 py-3 sm:px-6"><div className="grid w-full grid-cols-2 gap-2 sm:ml-auto sm:flex sm:w-auto sm:flex-wrap sm:justify-end"><Button variant="outline" onClick={() => promptStatus === 'shown' ? dismiss('decline') : setMilestone(null)} disabled={saving !== null}>{copy.decline}</Button><Button variant="primary" onClick={approveBrand} disabled={saving !== null || !canApprove}><Send className="mr-1.5 h-4 w-4" />{pending ? copy.queued : copy.approve}</Button>{promptStatus === 'shown' && <button type="button" className="col-span-2 pt-1 text-xs text-slate-500 underline underline-offset-2 hover:text-slate-700 sm:w-full" onClick={optOut} disabled={saving !== null}>{german ? 'Nicht mehr anzeigen' : 'Do not show again'}</button>}</div></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -5,9 +5,10 @@ interface DialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
export const Dialog: React.FC<DialogProps> = ({ open, onOpenChange, children }) => {
|
||||
export const Dialog: React.FC<DialogProps> = ({ open, onOpenChange, children, containerClassName }) => {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
@@ -16,7 +17,7 @@ export const Dialog: React.FC<DialogProps> = ({ open, onOpenChange, children })
|
||||
className="fixed inset-0 bg-black/50"
|
||||
onClick={() => onOpenChange(false)}
|
||||
/>
|
||||
<div className="relative z-50 w-full max-w-lg mx-4">
|
||||
<div className={cn('relative z-50 mx-4 w-full max-w-lg', containerClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,9 @@ import type { BlogPost, PillarKey, AuthorProfile } from "./types";
|
||||
export const REDIRECTED_BLOG_SLUGS = new Set<string>([
|
||||
"qr-code-analytics",
|
||||
"qr-code-restaurant-menu",
|
||||
// Duplicate of the standalone /qr-code-print-size-guide, which is the version
|
||||
// Google actually ranks. Both were live and self-canonical until 2026-08-13.
|
||||
"qr-code-print-size-guide",
|
||||
]);
|
||||
|
||||
export function isRedirectedSlug(slug: string): boolean {
|
||||
|
||||
@@ -48,6 +48,20 @@ async function waitForRateLimit() {
|
||||
lastEmailSent = Date.now();
|
||||
}
|
||||
|
||||
function getEmailFrom(name = 'Timo from QR Master'): string {
|
||||
const address = process.env.SMTP_USER || 'timo@qrmaster.net';
|
||||
return `${name} <${address}>`;
|
||||
}
|
||||
|
||||
function getEmailFromSecurity(): string {
|
||||
const address = process.env.SMTP_USER || 'noreply@qrmaster.net';
|
||||
return `QR Master Security <${address}>`;
|
||||
}
|
||||
|
||||
function getEmailReplyTo(): string {
|
||||
return process.env.SMTP_USER || 'support@qrmaster.net';
|
||||
}
|
||||
|
||||
/**
|
||||
* Password Reset Email - Security focused with clear urgency
|
||||
*/
|
||||
@@ -58,8 +72,8 @@ export async function sendPasswordResetEmail(email: string, resetToken: string)
|
||||
|
||||
try {
|
||||
await resend.emails.send({
|
||||
from: 'QR Master Security <noreply@qrmaster.net>',
|
||||
replyTo: 'support@qrmaster.net',
|
||||
from: getEmailFromSecurity(),
|
||||
replyTo: getEmailReplyTo(),
|
||||
to: email,
|
||||
subject: '🔐 Reset Your QR Master Password (Expires in 1 Hour)',
|
||||
html: `
|
||||
@@ -190,8 +204,8 @@ export async function sendNewsletterWelcomeEmail(email: string) {
|
||||
|
||||
try {
|
||||
await resend.emails.send({
|
||||
from: 'Timo from QR Master <timo@qrmaster.net>',
|
||||
replyTo: 'support@qrmaster.net',
|
||||
from: getEmailFrom(),
|
||||
replyTo: getEmailReplyTo(),
|
||||
to: email,
|
||||
subject: '🎉 You\'re In! Here\'s What Happens Next (AI QR Features)',
|
||||
html: `
|
||||
@@ -362,8 +376,8 @@ export async function sendAIFeatureLaunchEmail(email: string) {
|
||||
|
||||
try {
|
||||
await resend.emails.send({
|
||||
from: 'Timo from QR Master <timo@qrmaster.net>',
|
||||
replyTo: 'support@qrmaster.net',
|
||||
from: getEmailFrom(),
|
||||
replyTo: getEmailReplyTo(),
|
||||
to: email,
|
||||
subject: '🚀 They\'re Live! Your AI QR Features Are Ready',
|
||||
html: `
|
||||
@@ -568,8 +582,8 @@ export async function sendEmailVerificationEmail(email: string, name: string, ve
|
||||
const firstName = name.trim().split(/\s+/)[0] || 'there';
|
||||
|
||||
await transport.sendMail({
|
||||
from: 'Timo from QR Master <timo@qrmaster.net>',
|
||||
replyTo: 'support@qrmaster.net',
|
||||
from: getEmailFrom(),
|
||||
replyTo: getEmailReplyTo(),
|
||||
to: email,
|
||||
subject: 'Confirm your QR Master email address',
|
||||
html: `<!doctype html><html><body style="margin:0;background:#f5f4ef;color:#1b1c19;font-family:Arial,sans-serif;"><table role="presentation" width="100%" cellspacing="0" cellpadding="0"><tr><td align="center" style="padding:32px 12px;"><table role="presentation" width="600" cellspacing="0" cellpadding="0" style="width:100%;max-width:600px;background:#fff;"><tr><td style="padding:20px 32px;border-bottom:1px solid #e3e3de;font-size:11px;font-weight:bold;letter-spacing:2px;">QR MASTER</td></tr><tr><td style="padding:36px 32px;"><h1 style="margin:0 0 18px;font-family:Georgia,serif;font-size:30px;font-weight:normal;line-height:1.2;">Confirm your email address</h1><p style="margin:0;font-size:16px;line-height:1.65;">Hi ${escapeHtml(firstName)},</p><p style="font-size:16px;line-height:1.65;">Click the button below to finish creating your QR Master account.</p><a href="${verificationUrl}" style="display:inline-block;margin:10px 0 22px;background:#0047ff;color:#fff;padding:14px 22px;text-decoration:none;font-size:14px;font-weight:bold;">CONFIRM EMAIL</a><p style="margin:0;color:#747878;font-size:13px;line-height:1.6;">This link expires in 24 hours. If you did not create an account, you can ignore this email.</p></td></tr></table></td></tr></table></body></html>`,
|
||||
@@ -586,8 +600,8 @@ export async function sendDesignerAnnouncementEmail(email: string, unsubscribeUr
|
||||
const transport = createSmtpTransport();
|
||||
|
||||
await transport.sendMail({
|
||||
from: 'Timo from QR Master <timo@qrmaster.net>',
|
||||
replyTo: 'support@qrmaster.net',
|
||||
from: getEmailFrom(),
|
||||
replyTo: getEmailReplyTo(),
|
||||
to: email,
|
||||
subject: 'Your QR codes can now look like your brand',
|
||||
html: `
|
||||
@@ -652,8 +666,8 @@ export async function sendNewsletterEmail({
|
||||
const transport = createSmtpTransport();
|
||||
|
||||
await transport.sendMail({
|
||||
from: 'Timo from QR Master <timo@qrmaster.net>',
|
||||
replyTo: 'support@qrmaster.net',
|
||||
from: getEmailFrom(),
|
||||
replyTo: getEmailReplyTo(),
|
||||
to: email,
|
||||
subject,
|
||||
html: `<!doctype html><html><body style="margin:0;background:#f5f4ef;color:#1b1c19;font-family:Arial,sans-serif;"><table role="presentation" width="100%" cellspacing="0" cellpadding="0"><tr><td align="center" style="padding:32px 12px;"><table role="presentation" width="600" cellspacing="0" cellpadding="0" style="width:100%;max-width:600px;background:#fff;"><tr><td style="padding:20px 32px;border-bottom:1px solid #e3e3de;font-size:11px;font-weight:bold;letter-spacing:2px;">QR MASTER</td></tr><tr><td style="padding:36px 32px;">${body}</td></tr><tr><td style="padding:20px 32px;border-top:1px solid #e3e3de;color:#747878;font-size:11px;line-height:1.6;">You are receiving this email from QR Master.<br><a href="${unsubscribeUrl}" style="color:#747878;">Unsubscribe from product updates</a></td></tr></table></td></tr></table></body></html>`,
|
||||
@@ -929,8 +943,8 @@ export async function sendWelcomeEmail(email: string, name: string) {
|
||||
`);
|
||||
|
||||
await transport.sendMail({
|
||||
from: 'Timo from QR Master <timo@qrmaster.net>',
|
||||
replyTo: 'support@qrmaster.net',
|
||||
from: getEmailFrom(),
|
||||
replyTo: getEmailReplyTo(),
|
||||
to: email,
|
||||
subject: 'Your QR Master account is ready',
|
||||
html,
|
||||
@@ -1066,8 +1080,8 @@ export async function sendActivationNudgeEmail(email: string, name: string) {
|
||||
`);
|
||||
|
||||
await transport.sendMail({
|
||||
from: 'Timo from QR Master <timo@qrmaster.net>',
|
||||
replyTo: 'support@qrmaster.net',
|
||||
from: getEmailFrom(),
|
||||
replyTo: getEmailReplyTo(),
|
||||
to: email,
|
||||
subject: "Your 3 free codes are still sitting there",
|
||||
html,
|
||||
@@ -1237,8 +1251,8 @@ export async function sendUpgradeNudgeEmail(email: string, name: string, qrCount
|
||||
`);
|
||||
|
||||
await transport.sendMail({
|
||||
from: 'Timo from QR Master <timo@qrmaster.net>',
|
||||
replyTo: 'support@qrmaster.net',
|
||||
from: getEmailFrom(),
|
||||
replyTo: getEmailReplyTo(),
|
||||
to: email,
|
||||
subject: 'You just hit the free limit',
|
||||
html,
|
||||
@@ -1413,8 +1427,8 @@ export async function sendThirtyDayNudgeEmail(
|
||||
`);
|
||||
|
||||
await transport.sendMail({
|
||||
from: 'Timo from QR Master <timo@qrmaster.net>',
|
||||
replyTo: 'support@qrmaster.net',
|
||||
from: getEmailFrom(),
|
||||
replyTo: getEmailReplyTo(),
|
||||
to: email,
|
||||
subject: `${firstName}, your codes were scanned ${scanCount} time${scanCount !== 1 ? 's' : ''} this month`,
|
||||
html,
|
||||
@@ -1528,8 +1542,8 @@ export async function sendFirstScanEmail(
|
||||
`);
|
||||
|
||||
await transport.sendMail({
|
||||
from: 'Timo from QR Master <timo@qrmaster.net>',
|
||||
replyTo: 'support@qrmaster.net',
|
||||
from: getEmailFrom(),
|
||||
replyTo: getEmailReplyTo(),
|
||||
to: email,
|
||||
subject: 'Your QR code was just scanned for the first time',
|
||||
html,
|
||||
|
||||
@@ -89,7 +89,8 @@ export function getAllIndexableUrls(): string[] {
|
||||
`${baseUrl}/qr-code-tracking`,
|
||||
`${baseUrl}/reprint-calculator`,
|
||||
`${baseUrl}/dynamic-qr-code-generator`,
|
||||
`${baseUrl}/dynamic-barcode-generator`,
|
||||
// /dynamic-barcode-generator and /barcode-generator are 301'd to
|
||||
// /tools/barcode-generator in next.config.mjs - never submit them.
|
||||
`${baseUrl}/bulk-qr-code-generator`,
|
||||
`${baseUrl}/custom-qr-code-generator`,
|
||||
`${baseUrl}/manage-qr-codes`,
|
||||
@@ -105,6 +106,7 @@ export function getAllIndexableUrls(): string[] {
|
||||
`${baseUrl}/restaurants`,
|
||||
`${baseUrl}/qr-code-analytics`,
|
||||
`${baseUrl}/qr-code-print-size-guide`,
|
||||
`${baseUrl}/developers`,
|
||||
];
|
||||
|
||||
// Alternatives & comparison hub pages
|
||||
|
||||
@@ -58,7 +58,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Loyalty programs and promotional offers",
|
||||
"WiFi access for guest connectivity"
|
||||
],
|
||||
tools: ["url-qr-code", "wifi-qr-code", "pdf-qr-code"],
|
||||
tools: ["url-qr-code", "wifi-qr-code", "pdf-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "Do customers prefer QR code menus?", answer: "Yes, 78% of customers enjoy using QR codes for menus. The convenience of contactless access appeals especially to modern diners." },
|
||||
{ question: "Should I use static or dynamic QR codes?", answer: "Dynamic QR codes are essential. They allow you to update menu items and prices without replacing the physical codes on your tables." },
|
||||
@@ -97,7 +97,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Social media promotion to grow your following",
|
||||
"Customer feedback forms to improve service"
|
||||
],
|
||||
tools: ["url-qr-code", "wifi-qr-code", "sms-qr-code"],
|
||||
tools: ["url-qr-code", "wifi-qr-code", "sms-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "Are QR codes cost-effective for small cafes?", answer: "Yes! They eliminate menu reprinting costs, reduce paper waste, and many generators offer free basic plans. For a step-by-step menu setup, see the <a href=\"/blog/qr-code-restaurant-menu\" class=\"text-blue-600 underline\">Restaurant Menu QR Code Guide →</a>" },
|
||||
{ question: "Can I use one QR code for multiple purposes?", answer: "Yes, with dynamic QR codes you can easily update the destination URL to serve different purposes over time." },
|
||||
@@ -136,7 +136,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Amenity booking for spa and dining",
|
||||
"Guest feedback surveys and review collection"
|
||||
],
|
||||
tools: ["wifi-qr-code", "url-qr-code", "pdf-qr-code"],
|
||||
tools: ["wifi-qr-code", "url-qr-code", "pdf-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "How do QR codes improve hotel check-in?", answer: "They enable guests to access digital registration forms instantly upon arrival, reducing front desk queues and wait times." },
|
||||
{ question: "Can hotels update QR code information?", answer: "Yes, dynamic QR codes allow you to update linked content like restaurant hours or pool rules without replacing physical signs." },
|
||||
@@ -175,7 +175,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Agent vCard contact information",
|
||||
"Neighborhood information and local amenities"
|
||||
],
|
||||
tools: ["url-qr-code", "vcard-qr-code", "video-qr-code"],
|
||||
tools: ["url-qr-code", "vcard-qr-code", "video-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "Can I reuse a QR code when a property sells?", answer: "Yes, with dynamic QR codes you can simply update the destination URL to point to your next listing." },
|
||||
{ question: "How do I track buyer interest from yard signs?", answer: "QR code analytics show total scans, geographic locations, device types, and time of day to reveal buyer interest patterns." },
|
||||
@@ -214,7 +214,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Fitness app downloads at key touchpoints",
|
||||
"Loyalty programs with attendance tracking"
|
||||
],
|
||||
tools: ["url-qr-code", "app-store-qr-code", "video-qr-code"],
|
||||
tools: ["url-qr-code", "app-store-qr-code", "video-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "How do QR codes reduce injuries?", answer: "Codes on equipment link to tutorial videos showing proper setup and correct form, building confidence and reducing injury risks." },
|
||||
{ question: "Can QR codes improve member retention?", answer: "Yes, gyms using QR codes for personalized content report increased member retention due to a vastly improved, self-serve experience." },
|
||||
@@ -253,7 +253,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Aftercare instructions and prescription information",
|
||||
"Staff directory with provider specialties"
|
||||
],
|
||||
tools: ["wifi-qr-code", "url-qr-code", "pdf-qr-code"],
|
||||
tools: ["wifi-qr-code", "url-qr-code", "pdf-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "Are QR codes HIPAA compliant?", answer: "QR codes themselves are just links. As long as the destination page (like your patient portal) is HIPAA-compliant, you are entirely secure." },
|
||||
{ question: "How do QR codes reduce administrative burden?", answer: "They replace paper forms and allow patients to self-serve WiFi passwords and appointment booking, freeing up your front desk staff." },
|
||||
@@ -370,7 +370,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Table reservation and waitlist management",
|
||||
"Loyalty punch card replacement for regulars"
|
||||
],
|
||||
tools: ["url-qr-code", "wifi-qr-code", "instagram-qr-code"],
|
||||
tools: ["url-qr-code", "wifi-qr-code", "instagram-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "How do bars use QR codes for menus?", answer: "Dynamic QR codes on table tents or bar top stickers link to a digital menu page. When you update the page, the code stays the same. See the full setup guide: <a href=\"/blog/qr-code-restaurant-menu\" class=\"text-blue-600 underline\">Restaurant Menu QR Code Guide →</a>" },
|
||||
{ question: "Can I show different menus for happy hour vs. regular hours?", answer: "Yes, with a dynamic QR code you can schedule URL redirects so the same physical code shows a happy hour menu during those specific hours." },
|
||||
@@ -409,7 +409,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Pre-order or call-ahead link for lunch rush management",
|
||||
"Feedback form to collect reviews between stops"
|
||||
],
|
||||
tools: ["url-qr-code", "instagram-qr-code", "geolocation-qr-code"],
|
||||
tools: ["url-qr-code", "instagram-qr-code", "geolocation-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "How do food trucks update their location via QR code?", answer: "Use a dynamic QR code pointing to a simple webpage or Google Maps link. Update the URL each morning - your physical sticker never changes." },
|
||||
{ question: "Should a food truck QR code link to Instagram or a menu?", answer: "Both. Use a single link-in-bio style landing page that shows today's location, menu, and your Instagram handle from one scan." },
|
||||
@@ -448,7 +448,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Pre-order link for morning rush management",
|
||||
"Newsletter sign-up to notify customers of seasonal items"
|
||||
],
|
||||
tools: ["url-qr-code", "pdf-qr-code", "email-qr-code"],
|
||||
tools: ["url-qr-code", "pdf-qr-code", "email-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "How do bakeries handle allergen info with QR codes?", answer: "Each product card or display case label can include a QR code linking to a full ingredient and allergen breakdown page." },
|
||||
{ question: "Can a bakery replace paper loyalty cards with QR codes?", answer: "Yes, link a QR code to a simple stamp card app or loyalty platform. Customers scan at checkout instead of presenting a paper card." },
|
||||
@@ -487,7 +487,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Online shop or local delivery ordering",
|
||||
"Beer club membership sign-up at the bar"
|
||||
],
|
||||
tools: ["url-qr-code", "event-qr-code", "instagram-qr-code"],
|
||||
tools: ["url-qr-code", "event-qr-code", "instagram-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "How do tap rooms show tasting notes with QR codes?", answer: "Place a small QR placard at each tap handle linking to a product page with full tasting notes, ABV, and pairing suggestions." },
|
||||
{ question: "Can I put a QR code on a beer label?", answer: "Yes, use a small QR code on the back label. It can link to the brew story, vintage notes, or a food pairing guide." },
|
||||
@@ -526,7 +526,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Wristband QR linking to after-party details",
|
||||
"Guest list sign-up from social media flyers"
|
||||
],
|
||||
tools: ["url-qr-code", "event-qr-code", "instagram-qr-code"],
|
||||
tools: ["url-qr-code", "event-qr-code", "instagram-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "How do nightclubs use QR codes for entry?", answer: "Tickets are issued as QR codes. Door staff scan each guest's phone screen to verify and grant entry - faster than checking names on a list." },
|
||||
{ question: "Can QR codes replace printed event flyers?", answer: "QR codes on digital flyers or social media posts replace printed flyers while adding interactivity - linking directly to tickets or table booking." },
|
||||
@@ -565,7 +565,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"vCard contact details for event planners",
|
||||
"Video testimonials from past events"
|
||||
],
|
||||
tools: ["url-qr-code", "vcard-qr-code", "pdf-qr-code"],
|
||||
tools: ["url-qr-code", "vcard-qr-code", "pdf-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "How do caterers use QR codes to win more business?", answer: "A QR code on a business card links to a portfolio page with photos, menus, and a quote form - giving a full sales pitch long after you've left the room." },
|
||||
{ question: "Should catering QR codes be on business cards or event signage?", answer: "Both. Business cards capture leads from planners you meet directly. Event signage captures guests who taste your food and want to hire you." },
|
||||
@@ -604,7 +604,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Food pairing suggestions and recipe ideas",
|
||||
"Online shop for direct-to-consumer sales"
|
||||
],
|
||||
tools: ["url-qr-code", "event-qr-code", "instagram-qr-code"],
|
||||
tools: ["url-qr-code", "event-qr-code", "instagram-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "Can wineries put QR codes on bottle labels?", answer: "Yes, a small QR code on the back label links to tasting notes, food pairings, and booking pages without cluttering the front design." },
|
||||
{ question: "What should a winery QR code link to?", answer: "The most effective destination combines the vintage story, food pairing guide, cellar door booking link, and wine club sign-up in one page." },
|
||||
@@ -644,7 +644,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Workshop and retreat registration",
|
||||
"Member portal for existing students"
|
||||
],
|
||||
tools: ["url-qr-code", "instagram-qr-code", "event-qr-code"],
|
||||
tools: ["url-qr-code", "instagram-qr-code", "event-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "How do yoga studios attract new students with QR codes?", answer: "A QR on the studio window or door links to an intro offer - like a first-week free deal - that captures email and converts walk-by traffic to bookings." },
|
||||
{ question: "Can QR codes reduce class no-shows?", answer: "Yes, linking to a booking system that sends automated reminders significantly reduces no-show rates compared to informal reservations." },
|
||||
@@ -687,7 +687,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Post-treatment care instructions and product recommendations",
|
||||
"Google review link on checkout receipts"
|
||||
],
|
||||
tools: ["url-qr-code", "pdf-qr-code", "instagram-qr-code"],
|
||||
tools: ["url-qr-code", "pdf-qr-code", "instagram-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "How do spas use QR codes to sell more gift cards?", answer: "A QR code near the front desk or on a checkout card links directly to the gift card purchase page - converting satisfied clients into gift-givers on the spot." },
|
||||
{ question: "Can QR codes replace spa brochures?", answer: "Yes, a QR code linking to a digital treatment menu with photos is more current and far cheaper to maintain than printed brochures." },
|
||||
@@ -729,7 +729,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Loyalty program with visit tracking",
|
||||
"Instagram follow link at reception"
|
||||
],
|
||||
tools: ["url-qr-code", "instagram-qr-code", "vcard-qr-code"],
|
||||
tools: ["url-qr-code", "instagram-qr-code", "vcard-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "How do beauty salons use QR codes to get more bookings?", answer: "A QR code on the station mirror or checkout counter links directly to the booking platform. Clients rebook while still at the salon, capturing high-intent conversions." },
|
||||
{ question: "Can a QR code replace a printed price list at a salon?", answer: "Yes, link a QR to your service menu page. When prices change, update the page - the physical QR code never needs to be reprinted. The same principle applies across hospitality: <a href=\"/blog/qr-code-restaurant-menu\" class=\"text-blue-600 underline\">see how restaurants handle this →</a>" },
|
||||
@@ -769,7 +769,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Google review request on checkout card",
|
||||
"Loyalty card replacement for regular clients"
|
||||
],
|
||||
tools: ["url-qr-code", "instagram-qr-code", "call-qr-code-generator"],
|
||||
tools: ["url-qr-code", "instagram-qr-code", "call-qr-code-generator", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "How do barbershops reduce walk-in wait times with QR codes?", answer: "A QR on the shop window links to your booking system. Clients book a slot instead of waiting, spreading demand more evenly throughout the day." },
|
||||
{ question: "Should each barber have their own QR code?", answer: "For shops with distinct stylists, yes. Individual barber portfolio QR codes help clients pick a preferred barber and follow them on Instagram." },
|
||||
@@ -811,7 +811,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Aftercare instructions for gel and acrylic maintenance",
|
||||
"Instagram follow link to see new seasonal designs"
|
||||
],
|
||||
tools: ["url-qr-code", "instagram-qr-code", "event-qr-code"],
|
||||
tools: ["url-qr-code", "instagram-qr-code", "event-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "How do nail salons use QR codes to fill appointment slots?", answer: "A QR at the nail station while clients wait for polish to dry links directly to the booking page - capturing rebooking at the highest-intent moment." },
|
||||
{ question: "Can QR codes replace paper loyalty cards at nail salons?", answer: "Yes, a QR linked to a digital stamp card app tracks visits automatically and doesn't get lost at the bottom of a handbag." },
|
||||
@@ -850,7 +850,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Consent and health waiver form filled on the client's phone",
|
||||
"Booking page for consultation requests"
|
||||
],
|
||||
tools: ["url-qr-code", "instagram-qr-code", "pdf-qr-code"],
|
||||
tools: ["url-qr-code", "instagram-qr-code", "pdf-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "How do tattoo artists use QR codes for their portfolio?", answer: "A QR code on a small artist card or studio display links directly to their portfolio page or Instagram. Walk-in clients scan and browse before choosing an artist." },
|
||||
{ question: "Can QR codes replace printed aftercare sheets?", answer: "Yes, a QR code sticker on the care wrap or a card given at checkout links to a detailed aftercare page - easier to follow and always up to date." },
|
||||
@@ -889,7 +889,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Loyalty card sign-up at the counter",
|
||||
"Health guide resources for common conditions"
|
||||
],
|
||||
tools: ["url-qr-code", "pdf-qr-code", "email-qr-code"],
|
||||
tools: ["url-qr-code", "pdf-qr-code", "email-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "How do pharmacies use QR codes on prescription bags?", answer: "A printed QR code on the bag label links to the specific medication information page - dosing, side effects, and storage instructions in the patient's preferred language." },
|
||||
{ question: "Can QR codes increase flu shot bookings at pharmacies?", answer: "Yes, a QR code at the counter linking directly to the vaccination booking form removes friction and converts prescription pick-up visits into booked appointments." },
|
||||
@@ -967,7 +967,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Vehicle history and inspection report",
|
||||
"Salesperson vCard for after-hours follow-up"
|
||||
],
|
||||
tools: ["url-qr-code", "vcard-qr-code", "video-qr-code"],
|
||||
tools: ["url-qr-code", "vcard-qr-code", "video-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "How do car dealerships use QR codes on the lot?", answer: "A QR on the windshield visor card links to the full vehicle listing with specs, photos, and a booking form - giving buyers all the information without needing a salesperson present." },
|
||||
{ question: "Can QR codes increase test drive conversions?", answer: "Yes, removing friction from the booking process by linking directly from the physical car to the form significantly increases test drive sign-ups." },
|
||||
@@ -1006,7 +1006,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Wedding and event flower inquiry form",
|
||||
"Gift message and delivery scheduling"
|
||||
],
|
||||
tools: ["url-qr-code", "instagram-qr-code", "email-qr-code"],
|
||||
tools: ["url-qr-code", "instagram-qr-code", "email-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "How do florists use QR codes on delivery packaging?", answer: "A QR on the wrapping paper or box links to care instructions for the specific flowers included - reducing the most common post-purchase support question." },
|
||||
{ question: "Can QR codes increase florist repeat orders?", answer: "Yes, a reorder link in the care page or on a small card inside the bouquet makes it trivial for satisfied customers to order again." },
|
||||
@@ -1123,7 +1123,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Engagement ring guide for nervous buyers",
|
||||
"Gift wrapping and message scheduling for occasions"
|
||||
],
|
||||
tools: ["url-qr-code", "vcard-qr-code", "pdf-qr-code"],
|
||||
tools: ["url-qr-code", "vcard-qr-code", "pdf-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "How do jewelry stores use QR codes to build trust?", answer: "A QR on the price tag or display card links to the gemstone's provenance - origin country, ethical sourcing certification, and grading report - answering the buyer's biggest concern." },
|
||||
{ question: "Can QR codes help jewelry stores sell custom pieces?", answer: "Yes, a QR near display cases links to a custom design inquiry form. Browsers who are unsure of available options can explore customization without a sales conversation." },
|
||||
@@ -1591,7 +1591,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Preferred vendor list for referred couples",
|
||||
"Wedding planning guide and checklist download"
|
||||
],
|
||||
tools: ["url-qr-code", "vcard-qr-code", "instagram-qr-code"],
|
||||
tools: ["url-qr-code", "vcard-qr-code", "instagram-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "How do wedding planners use QR codes at bridal fairs?", answer: "A QR on stand signage and handed cards links directly to the portfolio and consultation booking page - converting fair visitors to booked clients without relying on follow-up emails." },
|
||||
{ question: "Should wedding planners use QR codes on business cards?", answer: "Yes, a QR on the reverse side of a business card linking to the portfolio page gives couples a full picture of your work that no card can show." },
|
||||
@@ -1630,7 +1630,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Client gallery access with QR for private delivery",
|
||||
"Google review request on delivery card"
|
||||
],
|
||||
tools: ["url-qr-code", "instagram-qr-code", "vcard-qr-code"],
|
||||
tools: ["url-qr-code", "instagram-qr-code", "vcard-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "How do photographers use QR codes to get more bookings?", answer: "A QR on every business card and photo delivery links to the portfolio and booking calendar - capturing the highest-intent moment: when someone has just seen or received your work." },
|
||||
{ question: "Can photographers use QR codes to sell print packages?", answer: "Yes, a QR inside the delivery packaging links to the print and album store. Clients who love their digital gallery are significantly more likely to order physical products." },
|
||||
@@ -1712,7 +1712,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Client portal access for case updates",
|
||||
"Firm overview and partner biographies for referrals"
|
||||
],
|
||||
tools: ["url-qr-code", "vcard-qr-code", "email-qr-code"],
|
||||
tools: ["url-qr-code", "vcard-qr-code", "email-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "How do law firms use QR codes on business cards?", answer: "A vCard QR on the back of each attorney's card lets clients save the full contact - name, title, direct line, and practice area - in one scan." },
|
||||
{ question: "Can law firm QR codes increase consultation bookings?", answer: "Yes, a QR on the firm brochure or waiting room poster linking to the scheduling page converts interested visitors without requiring a receptionist to manage every booking." },
|
||||
@@ -1751,7 +1751,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Client portal access for accounts and reports",
|
||||
"Referral program landing page for new client acquisition"
|
||||
],
|
||||
tools: ["url-qr-code", "vcard-qr-code", "email-qr-code"],
|
||||
tools: ["url-qr-code", "vcard-qr-code", "email-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "How do accountants use QR codes to collect client documents?", answer: "A QR in client correspondence links to the firm's secure document upload portal. Clients submit tax records and financial statements directly - avoiding insecure email attachments." },
|
||||
{ question: "Can QR codes reduce missed tax deadlines for accounting clients?", answer: "Yes, a QR in monthly emails or on the firm brochure linking to an upcoming deadlines calendar significantly improves client compliance." },
|
||||
@@ -1790,7 +1790,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Claims process guide and contact links",
|
||||
"Policy renewal reminder and self-service link"
|
||||
],
|
||||
tools: ["url-qr-code", "vcard-qr-code", "pdf-qr-code"],
|
||||
tools: ["url-qr-code", "vcard-qr-code", "pdf-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "How do insurance agencies use QR codes for lead generation?", answer: "A QR on every direct mail piece and business card links to the online quote form - converting cold mail recipients into warm leads at their moment of highest attention." },
|
||||
{ question: "Can QR codes help insurance clients understand their policies?", answer: "Yes, a QR in the policy document linking to a plain-English summary or video explainer reduces client confusion and claims-related call volume." },
|
||||
@@ -1829,7 +1829,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Booking confirmation and travel document portal",
|
||||
"Loyalty and returning customer discount landing page"
|
||||
],
|
||||
tools: ["url-qr-code", "pdf-qr-code", "instagram-qr-code"],
|
||||
tools: ["url-qr-code", "pdf-qr-code", "instagram-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "How do travel agencies use QR codes to get more bookings?", answer: "A QR on each destination brochure links directly to the package details and booking form - turning print material into an active booking channel." },
|
||||
{ question: "Can QR codes work for travel agency window displays?", answer: "Yes, window display QRs capture passerby interest after hours. Passersby scan the destination card and access the full package details immediately." },
|
||||
@@ -1989,7 +1989,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Treatment information and FAQ for nervous patients",
|
||||
"Google review request at checkout"
|
||||
],
|
||||
tools: ["url-qr-code", "wifi-qr-code", "pdf-qr-code"],
|
||||
tools: ["url-qr-code", "wifi-qr-code", "pdf-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "How do dental practices use QR codes for new patients?", answer: "A QR in the waiting room links to the digital new patient intake form. Patients complete it on their phone before being called in - speeding up the check-in process." },
|
||||
{ question: "Can QR codes help dentists get more Google reviews?", answer: "Yes, a QR on the checkout card linking directly to the Google review page captures reviews at the highest-intent moment - immediately after a positive visit." },
|
||||
@@ -2028,7 +2028,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Digital vCard for instant contact saving",
|
||||
"Special offer and referral program links"
|
||||
],
|
||||
tools: ["url-qr-code", "vcard-qr-code", "pdf-qr-code"],
|
||||
tools: ["url-qr-code", "vcard-qr-code", "pdf-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "How do pet groomers use QR codes for booking?", answer: "They place codes on business cards and their shop window that link directly to their booking software, reducing the need for phone tag." },
|
||||
{ question: "Can I show a portfolio using a QR code?", answer: "Yes, you can link to an Instagram page or a dedicated gallery showing the dogs and cats you've groomed." },
|
||||
@@ -2067,7 +2067,7 @@ export const allIndustries: IndustryPage[] = [
|
||||
"Post-operative care and medication guides",
|
||||
"Pet insurance information and quote links"
|
||||
],
|
||||
tools: ["url-qr-code", "call-qr-code-generator", "vcard-qr-code"],
|
||||
tools: ["url-qr-code", "call-qr-code-generator", "vcard-qr-code", "google-review-qr-code"],
|
||||
faq: [
|
||||
{ question: "How do vets use QR codes for emergencies?", answer: "They print codes that automatically dial the clinic or open a map to the nearest emergency animal hospital when scanned." },
|
||||
{ question: "Can QR codes hold a pet's medical history?", answer: "They link to a secure portal where pet owners and other care providers can see the pet's latest records and vaccines." },
|
||||
|
||||
50
src/lib/rounded-chart-path.ts
Normal file
50
src/lib/rounded-chart-path.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export type ChartPoint = { x: number; y: number };
|
||||
|
||||
function distance(a: ChartPoint, b: ChartPoint) {
|
||||
return Math.hypot(b.x - a.x, b.y - a.y);
|
||||
}
|
||||
|
||||
function pointTowards(from: ChartPoint, to: ChartPoint, amount: number): ChartPoint {
|
||||
const length = distance(from, to);
|
||||
if (length === 0) return from;
|
||||
|
||||
return {
|
||||
x: from.x + ((to.x - from.x) / length) * amount,
|
||||
y: from.y + ((to.y - from.y) / length) * amount,
|
||||
};
|
||||
}
|
||||
|
||||
function coordinate(value: number) {
|
||||
return Number(value.toFixed(2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the factual scan points into one continuous SVG path while rounding
|
||||
* only the visual corners. Source values and timestamps stay untouched; the
|
||||
* path merely eases into and out of each factual turning point.
|
||||
*/
|
||||
export function roundedChartPath(points: ChartPoint[], cornerRadius: number): string {
|
||||
if (points.length === 0) return '';
|
||||
if (points.length === 1) return `M ${coordinate(points[0].x)} ${coordinate(points[0].y)}`;
|
||||
|
||||
let path = `M ${coordinate(points[0].x)} ${coordinate(points[0].y)}`;
|
||||
|
||||
for (let index = 1; index < points.length - 1; index += 1) {
|
||||
const previous = points[index - 1];
|
||||
const current = points[index];
|
||||
const next = points[index + 1];
|
||||
const radius = Math.min(
|
||||
cornerRadius,
|
||||
distance(previous, current) / 2,
|
||||
distance(current, next) / 2,
|
||||
);
|
||||
const before = pointTowards(current, previous, radius);
|
||||
const after = pointTowards(current, next, radius);
|
||||
|
||||
path += ` L ${coordinate(before.x)} ${coordinate(before.y)}`;
|
||||
path += ` Q ${coordinate(current.x)} ${coordinate(current.y)} ${coordinate(after.x)} ${coordinate(after.y)}`;
|
||||
}
|
||||
|
||||
const last = points[points.length - 1];
|
||||
return `${path} L ${coordinate(last.x)} ${coordinate(last.y)}`;
|
||||
}
|
||||
122
src/lib/social-milestone-image.tsx
Normal file
122
src/lib/social-milestone-image.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import React from 'react';
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { roundedChartPath } from '@/lib/rounded-chart-path';
|
||||
|
||||
type Trend = {
|
||||
points: Array<{ at: string; total: number }>;
|
||||
startLabel: string;
|
||||
endLabel: string;
|
||||
target: number;
|
||||
};
|
||||
|
||||
export type SocialMilestoneImageCard = {
|
||||
qrTitle?: string;
|
||||
totalScans?: number;
|
||||
totalUniqueScans?: number;
|
||||
milestoneThreshold?: number;
|
||||
trend?: Trend | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Link previews are 1.91:1, Instagram wants 1:1 or 4:5 and rejects anything
|
||||
* outside that window. Same card, three canvases - never a cropped variant,
|
||||
* because the numbers must stay legible.
|
||||
*/
|
||||
export type SocialMilestoneImageFormat = 'landscape' | 'square' | 'portrait';
|
||||
|
||||
const FORMATS: Record<SocialMilestoneImageFormat, {
|
||||
width: number; height: number; stacked: boolean; chart: { width: number; height: number };
|
||||
}> = {
|
||||
landscape: { width: 1200, height: 630, stacked: false, chart: { width: 650, height: 285 } },
|
||||
square: { width: 1080, height: 1080, stacked: true, chart: { width: 956, height: 470 } },
|
||||
portrait: { width: 1080, height: 1350, stacked: true, chart: { width: 956, height: 720 } },
|
||||
};
|
||||
|
||||
export function socialMilestoneImageFormat(value?: string | null): SocialMilestoneImageFormat {
|
||||
return value === 'square' || value === 'portrait' ? value : 'landscape';
|
||||
}
|
||||
|
||||
function chart(card: SocialMilestoneImageCard, german: boolean, box: { width: number; height: number }) {
|
||||
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 = box.width - 30;
|
||||
const plotTop = 12;
|
||||
// Leaves room for the two date labels and the caption below the plot.
|
||||
const plotBottom = box.height - 67;
|
||||
const chartPoints = 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 };
|
||||
});
|
||||
const path = roundedChartPath(chartPoints, 30);
|
||||
const number = new Intl.NumberFormat(german ? 'de-DE' : 'en-US', { maximumFractionDigits: 2 });
|
||||
const endPoint = chartPoints[chartPoints.length - 1] || { x: plotRight, y: plotBottom };
|
||||
|
||||
// Satori cannot render SVG <text> nodes in the deployed Node runtime. SVG
|
||||
// draws geometry only; the aligned labels are ordinary positioned text.
|
||||
return <div style={{ display: 'flex', position: 'relative', width: box.width, height: box.height }}>
|
||||
{ticks.map(tick => {
|
||||
const y = plotBottom - tick / ceiling * (plotBottom - plotTop);
|
||||
const reached = tick === target;
|
||||
return <div key={tick} style={{ display: 'flex', position: 'absolute', left: 0, top: y - 10, width: plotRight, height: 22, alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', width: plotLeft, justifyContent: 'flex-end', paddingRight: 14, color: reached ? '#0256ff' : '#64748b', fontSize: 16, fontWeight: reached ? 700 : 500 }}>{number.format(tick)}</div>
|
||||
<div style={{ display: 'flex', width: plotRight - plotLeft, height: reached ? 2 : 1, background: reached ? '#bfdbfe' : '#e2e8f0' }} />
|
||||
</div>;
|
||||
})}
|
||||
<svg width={box.width} height={plotBottom + 12} viewBox={`0 0 ${box.width} ${plotBottom + 12}`} style={{ position: 'absolute', left: 0, top: 0 }}>
|
||||
<path d={path} fill="none" stroke="#0256ff" strokeWidth="5" strokeLinejoin="round" strokeLinecap="round" />
|
||||
{path && <circle cx={endPoint.x} cy={endPoint.y} r="7" fill="white" stroke="#0256ff" strokeWidth="4" />}
|
||||
</svg>
|
||||
<div style={{ display: 'flex', position: 'absolute', left: plotLeft, right: 30, top: box.height - 53, justifyContent: 'space-between', color: '#45617f', fontSize: 16, fontWeight: 600 }}>
|
||||
<span>{trend.startLabel || (german ? 'Erstellt' : 'Created')}</span>
|
||||
<span>{trend.endLabel || (german ? 'Erreicht' : 'Reached')}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', position: 'absolute', right: 30, bottom: 0, color: '#45617f', fontSize: 16 }}>{german ? 'Kumulierte eindeutige Scans' : 'Cumulative unique scans'}</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function createSocialMilestoneImage(
|
||||
card: SocialMilestoneImageCard,
|
||||
german: boolean,
|
||||
format: SocialMilestoneImageFormat = 'landscape',
|
||||
) {
|
||||
const canvas = FORMATS[format];
|
||||
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';
|
||||
const uniqueText = unique.toLocaleString(locale);
|
||||
const uniqueFontSize = uniqueText.length >= 9 ? 78 : uniqueText.length >= 6 ? 96 : uniqueText.length >= 4 ? 108 : 124;
|
||||
|
||||
return new ImageResponse(
|
||||
<div style={{ height: '100%', width: '100%', display: 'flex', background: '#edf3fa', padding: 28, color: '#061b31' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', width: '100%', background: 'white', padding: '30px 34px', borderRadius: 14, boxShadow: '0 24px 50px rgba(50,50,93,.16)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderBottom: '1px solid #e5edf5', paddingBottom: 16 }}>
|
||||
<span style={{ fontSize: 22, fontWeight: 700 }}>QR MASTER</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8, color: '#108c3d', background: '#eafaf0', padding: '8px 13px', borderRadius: 6, fontSize: 18 }}><span style={{ display: 'flex', width: 8, height: 8, borderRadius: 4, background: '#15be53' }} />{german ? 'Verifizierter Scan-Meilenstein' : 'Verified scan milestone'}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flex: 1, flexDirection: canvas.stacked ? 'column' : 'row', alignItems: canvas.stacked ? 'flex-start' : 'center', justifyContent: 'center', gap: canvas.stacked ? 10 : 28, paddingTop: 14 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', width: canvas.stacked ? '100%' : 340 }}>
|
||||
<span style={{ fontSize: 17, color: '#64748b', letterSpacing: 1.4 }}>UNIQUE SCANS</span>
|
||||
<span style={{ fontSize: uniqueFontSize, fontWeight: 400, letterSpacing: -4 }}>{uniqueText}</span>
|
||||
<span style={{ fontSize: 22, color: '#45617f' }}><b>{total.toLocaleString(locale)}</b> {german ? 'Scans insgesamt' : 'total scans'}</span>
|
||||
</div>
|
||||
{chart(card, german, canvas.chart)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', borderTop: '1px solid #e5edf5', paddingTop: 14, fontSize: 21, fontWeight: 600 }}>{card.qrTitle || (german ? 'QR-Code' : 'QR code')}</div>
|
||||
</div>
|
||||
</div>,
|
||||
{ width: canvas.width, height: canvas.height, headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
}
|
||||
145
src/lib/social-milestones-server.ts
Normal file
145
src/lib/social-milestones-server.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { db } from '@/lib/db';
|
||||
import { buildMilestoneCardSnapshot, getSocialMilestoneThresholds, isCompleteSocialMilestoneCard, milestoneKind, milestoneThreshold, SocialLocale, SocialMilestoneCard, socialLocale } from '@/lib/social-milestones';
|
||||
|
||||
function excludedEmails() {
|
||||
return (process.env.SOCIAL_MILESTONE_EXCLUDED_EMAILS || '')
|
||||
.split(',').map(email => email.trim().toLowerCase()).filter(Boolean);
|
||||
}
|
||||
|
||||
/** Creates any newly crossed milestones. Safe to call repeatedly. */
|
||||
export async function detectSocialMilestones(qrId?: string) {
|
||||
const excluded = excludedEmails();
|
||||
const thresholds = getSocialMilestoneThresholds();
|
||||
const candidates = await db.qRScan.groupBy({
|
||||
by: ['qrId'],
|
||||
where: {
|
||||
isUnique: true,
|
||||
...(qrId ? { qrId } : {}),
|
||||
qr: { user: excluded.length ? { email: { notIn: excluded, mode: 'insensitive' } } : undefined },
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
const crossedByQr = new Map<string, number[]>();
|
||||
candidates.forEach(({ qrId: candidateQrId, _count }) => {
|
||||
const crossed = thresholds.filter(threshold => _count._all >= threshold);
|
||||
if (crossed.length) crossedByQr.set(candidateQrId, crossed);
|
||||
});
|
||||
if (!crossedByQr.size) return 0;
|
||||
|
||||
// Card snapshots read a QR code's complete scan history, so the milestones
|
||||
// that already exist are filtered out before any of that work begins. This
|
||||
// runs after every unique scan; without the check, each scan past the
|
||||
// threshold would re-read every scan row only to hit `skipDuplicates`.
|
||||
const known = await db.socialMilestone.findMany({
|
||||
where: { qrId: { in: Array.from(crossedByQr.keys()) } },
|
||||
select: { qrId: true, kind: true },
|
||||
});
|
||||
const knownByQr = new Map<string, Set<string>>();
|
||||
known.forEach(milestone => {
|
||||
const kinds = knownByQr.get(milestone.qrId) || new Set<string>();
|
||||
kinds.add(milestone.kind);
|
||||
knownByQr.set(milestone.qrId, kinds);
|
||||
});
|
||||
|
||||
const records: Array<{ qrId: string; kind: string }> = [];
|
||||
crossedByQr.forEach((crossed, candidateQrId) => {
|
||||
const seen = knownByQr.get(candidateQrId);
|
||||
// A QR code seen for the first time may already be past several
|
||||
// thresholds. Announce the highest one only: the post quotes the current
|
||||
// scan count rather than the threshold, so the lower ones would produce a
|
||||
// second prompt and a second brand post with the very same number in it.
|
||||
const pending = seen
|
||||
? crossed.filter(threshold => !seen.has(milestoneKind(threshold)))
|
||||
: crossed.slice(-1);
|
||||
pending.forEach(threshold => records.push({ qrId: candidateQrId, kind: milestoneKind(threshold) }));
|
||||
});
|
||||
if (!records.length) return 0;
|
||||
|
||||
const qrs = await db.qRCode.findMany({
|
||||
where: { id: { in: Array.from(new Set(records.map(record => record.qrId))) } },
|
||||
select: { id: true, userId: true, title: true, createdAt: true, user: { select: { primaryUseCase: true } } },
|
||||
});
|
||||
const cardByQr = new Map<string, Awaited<ReturnType<typeof createCardSnapshot>>>();
|
||||
await Promise.all(qrs.map(async qr => cardByQr.set(qr.id, await createCardSnapshot(qr, new Date()))));
|
||||
const userIdByQr = new Map(qrs.map(qr => [qr.id, qr.userId]));
|
||||
const created = await db.socialMilestone.createMany({
|
||||
data: records.filter(record => userIdByQr.has(record.qrId)).map(record => ({
|
||||
...record,
|
||||
userId: userIdByQr.get(record.qrId)!,
|
||||
cardData: cardByQr.get(record.qrId),
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
return created.count;
|
||||
}
|
||||
|
||||
async function createCardSnapshot(
|
||||
qr: { id: string; title: string; createdAt: Date; user: { primaryUseCase: string | null } },
|
||||
reachedAt: Date,
|
||||
locale: SocialLocale = 'en',
|
||||
configuredThreshold?: number,
|
||||
) {
|
||||
const scans = await db.qRScan.findMany({
|
||||
where: { qrId: qr.id, ts: { lte: reachedAt } },
|
||||
select: { ts: true, isUnique: true },
|
||||
orderBy: { ts: 'asc' },
|
||||
});
|
||||
const uniqueScans = scans.filter(scan => scan.isUnique);
|
||||
// Keep a representative, cumulative history in the immutable snapshot.
|
||||
// It starts at QR creation and ends at the moment the milestone is detected.
|
||||
const stride = Math.max(1, Math.ceil(uniqueScans.length / 24));
|
||||
const points = [{ at: qr.createdAt.toISOString(), total: 0 }];
|
||||
uniqueScans.forEach((scan, index) => {
|
||||
const total = index + 1;
|
||||
if (total % stride === 0 || total === uniqueScans.length) points.push({ at: scan.ts.toISOString(), total });
|
||||
});
|
||||
const month = new Intl.DateTimeFormat('en', { month: 'short', year: '2-digit', timeZone: 'UTC' });
|
||||
const trend = {
|
||||
points,
|
||||
startLabel: month.format(qr.createdAt),
|
||||
endLabel: month.format(reachedAt),
|
||||
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.
|
||||
return buildMilestoneCardSnapshot({
|
||||
primaryUseCase: qr.user.primaryUseCase,
|
||||
qrTitle: qr.title,
|
||||
totalScans: scans.length,
|
||||
totalUniqueScans: uniqueScans.length,
|
||||
milestoneThreshold: configuredThreshold || uniqueScans.length,
|
||||
reachedAt,
|
||||
trend,
|
||||
locale,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Old test rows may contain the v1 card or a partial v2 snapshot. Repair once,
|
||||
* persist it, and return the exact same immutable payload to popup, OG and X.
|
||||
*/
|
||||
export async function ensureSocialMilestoneCard(input: {
|
||||
milestoneId: string;
|
||||
cardData: unknown;
|
||||
kind: string;
|
||||
detectedAt: Date;
|
||||
language: string;
|
||||
qr: { id: string; title: string; createdAt: Date };
|
||||
primaryUseCase: string | null;
|
||||
refresh?: boolean;
|
||||
snapshotAt?: Date;
|
||||
}): Promise<SocialMilestoneCard> {
|
||||
if (!input.refresh && isCompleteSocialMilestoneCard(input.cardData)) return input.cardData;
|
||||
const threshold = milestoneThreshold(input.kind) || 1;
|
||||
const card = await createCardSnapshot(
|
||||
{ ...input.qr, user: { primaryUseCase: input.primaryUseCase } },
|
||||
input.snapshotAt || input.detectedAt,
|
||||
socialLocale(input.language),
|
||||
threshold,
|
||||
);
|
||||
await db.socialMilestone.update({ where: { id: input.milestoneId }, data: { cardData: card } });
|
||||
return card;
|
||||
}
|
||||
212
src/lib/social-milestones.ts
Normal file
212
src/lib/social-milestones.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
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<string, string> = {
|
||||
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<SocialMilestoneCard>;
|
||||
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<string, string> = { 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<typeof milestonePostParts>[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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user