Compare commits
35 Commits
6d29aa0be1
...
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 | |||
| d623f39c54 | |||
| 40b73877b6 | |||
| 113acc073f | |||
| 53ef4b3b91 | |||
| 35ea8cc3e9 | |||
| e3276f5943 | |||
| ca1e432f80 | |||
| bec48ab8e1 | |||
| 68c531a1d5 | |||
| 999ee79aca | |||
| 87eb8c8883 | |||
| aac7283e59 | |||
| 50087f3c15 |
@@ -6,6 +6,12 @@
|
|||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
"runtimeArgs": ["run", "dev"],
|
"runtimeArgs": ["run", "dev"],
|
||||||
"port": 3050
|
"port": 3050
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dev-node",
|
||||||
|
"runtimeExecutable": "node",
|
||||||
|
"runtimeArgs": ["node_modules/next/dist/bin/next", "dev", "-p", "3050"],
|
||||||
|
"port": 3050
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
38
.env.example
38
.env.example
@@ -16,22 +16,50 @@ REDIS_URL=redis://redis:6379
|
|||||||
IP_SALT=CHANGE_ME_SALT
|
IP_SALT=CHANGE_ME_SALT
|
||||||
ENABLE_DEMO=true
|
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_HOST=smtp.qrmaster.net
|
||||||
SMTP_PORT=465
|
SMTP_PORT=465
|
||||||
SMTP_USER=timo@qrmaster.net
|
SMTP_USER=timo@qrmaster.net
|
||||||
SMTP_PASS=
|
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 job protection — generate with: openssl rand -base64 32
|
||||||
CRON_SECRET=
|
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)
|
# TikTok OAuth / posting (server-side only)
|
||||||
# Source of truth for cron posting: QRMaster server .env
|
# Source of truth for cron posting: QRMaster server .env
|
||||||
# Production example: https://qrmaster.net/api/tiktok/callback
|
# Production example: https://qrmaster.net/api/tiktok/callback
|
||||||
# Local dev example: http://localhost:3000/api/tiktok/callback
|
# Local dev example: http://localhost:3000/api/tiktok/callback
|
||||||
# Tokens are saved in the DB after the OAuth callback; do not store access tokens here.
|
# Tokens are saved in the DB after the OAuth callback; do not store access tokens here.
|
||||||
TIKTOK_CLIENT_KEY=
|
TIKTOK_CLIENT_KEY=
|
||||||
TIKTOK_CLIENT_SECRET=
|
TIKTOK_CLIENT_SECRET=
|
||||||
TIKTOK_REDIRECT_URI=https://qrmaster.net/api/tiktok/callback
|
TIKTOK_REDIRECT_URI=https://qrmaster.net/api/tiktok/callback
|
||||||
TIKTOK_ADMIN_KEY=
|
TIKTOK_ADMIN_KEY=
|
||||||
TIKTOK_EXPECTED_OPEN_ID=
|
TIKTOK_EXPECTED_OPEN_ID=
|
||||||
|
|||||||
53
.gitignore
vendored
53
.gitignore
vendored
@@ -8,10 +8,10 @@
|
|||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
/.next-stale-module-cache/
|
/.next-stale-module-cache/
|
||||||
/out/
|
/out/
|
||||||
|
|
||||||
# production
|
# production
|
||||||
/build
|
/build
|
||||||
@@ -28,6 +28,7 @@ yarn-error.log*
|
|||||||
# local env files
|
# local env files
|
||||||
.env*.local
|
.env*.local
|
||||||
.env
|
.env
|
||||||
|
.env.test
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
@@ -43,6 +44,9 @@ next-env.d.ts
|
|||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
*.sql
|
*.sql
|
||||||
!prisma/migrations/**/*.sql
|
!prisma/migrations/**/*.sql
|
||||||
|
# Hand-applied schema changes and analysis queries belong in history.
|
||||||
|
# Backup dumps land in the repo root, so they stay ignored.
|
||||||
|
!sql/**/*.sql
|
||||||
/backups/
|
/backups/
|
||||||
|
|
||||||
# logs
|
# logs
|
||||||
@@ -71,22 +75,25 @@ tmp/
|
|||||||
.codex-temp/
|
.codex-temp/
|
||||||
*.report.html
|
*.report.html
|
||||||
*.report.json
|
*.report.json
|
||||||
tmp_*.js
|
tmp_*.js
|
||||||
test_email.py
|
test_email.py
|
||||||
meta-fix.js
|
meta-fix.js
|
||||||
read-inbox.mjs
|
read-inbox.mjs
|
||||||
quora_antwort_statisch_dynamisch.txt
|
quora_antwort_statisch_dynamisch.txt
|
||||||
|
|
||||||
# Local blog audit reports and temporary snapshots
|
# Local blog audit reports and temporary snapshots
|
||||||
scratch_blog_analysis.json
|
scratch_blog_analysis.json
|
||||||
scratch_scored_blog_posts.json
|
scratch_scored_blog_posts.json
|
||||||
src/lib/blog-data.snapshot-*.ts
|
src/lib/blog-data.snapshot-*.ts
|
||||||
|
|
||||||
# Local developer-package workspaces and unreferenced generated media
|
# Local developer-package workspaces and unreferenced generated media
|
||||||
/packages/
|
/packages/
|
||||||
/public/Events/
|
/public/Events/
|
||||||
/public/Gyms/
|
/public/Gyms/
|
||||||
/public/Hotels/
|
/public/Hotels/
|
||||||
/public/Real Estate/
|
/public/Real Estate/
|
||||||
/public/restaurant/
|
/public/restaurant/
|
||||||
/.qr-master-api-health-state
|
/.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.
|
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
|
## 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
|
||||||
43
Dockerfile
43
Dockerfile
@@ -31,12 +31,35 @@ ENV NEXTAUTH_SECRET="build-time-secret"
|
|||||||
ENV IP_SALT="build-time-salt"
|
ENV IP_SALT="build-time-salt"
|
||||||
ENV STRIPE_SECRET_KEY="sk_test_placeholder_for_build"
|
ENV STRIPE_SECRET_KEY="sk_test_placeholder_for_build"
|
||||||
ENV RESEND_API_KEY="re_placeholder_for_build"
|
ENV RESEND_API_KEY="re_placeholder_for_build"
|
||||||
ENV NEXT_PUBLIC_APP_URL="https://www.qrmaster.net"
|
# Marketing host vs app host. NEXT_PUBLIC_WWW_URL must stay on www in production: it is the
|
||||||
|
# origin encoded into downloaded QR codes and used for public email links.
|
||||||
|
# Declared as ARG so the staging overlay can build the same image pointing at
|
||||||
|
# testmodul.qrmaster.net - the defaults keep a plain production build unchanged.
|
||||||
|
ARG NEXT_PUBLIC_WWW_URL="https://www.qrmaster.net"
|
||||||
|
ENV NEXT_PUBLIC_WWW_URL=$NEXT_PUBLIC_WWW_URL
|
||||||
|
ARG NEXT_PUBLIC_APP_URL="https://app.qrmaster.net"
|
||||||
|
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
|
||||||
# PostHog Analytics - REQUIRED at build time for client-side bundle
|
# PostHog Analytics - REQUIRED at build time for client-side bundle
|
||||||
ENV NEXT_PUBLIC_POSTHOG_KEY="phc_97JBJVVQlqqiZuTVRHuBnnG9HasOv3GSsdeVjossizJ"
|
ENV NEXT_PUBLIC_POSTHOG_KEY="phc_97JBJVVQlqqiZuTVRHuBnnG9HasOv3GSsdeVjossizJ"
|
||||||
ENV NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com"
|
ENV NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com"
|
||||||
ENV NEXT_PUBLIC_INDEXABLE="true"
|
ENV NEXT_PUBLIC_INDEXABLE="true"
|
||||||
ENV NEXT_PUBLIC_FACEBOOK_PIXEL_ID="1601718491252690"
|
ENV NEXT_PUBLIC_FACEBOOK_PIXEL_ID="1601718491252690"
|
||||||
|
# Umami Analytics - REQUIRED at build time (NEXT_PUBLIC_* is inlined by the compiler)
|
||||||
|
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.
|
||||||
|
ARG COOKIE_DOMAIN=""
|
||||||
|
ENV COOKIE_DOMAIN=$COOKIE_DOMAIN
|
||||||
|
# Distinct session cookie name for the staging deployment, so its cookie cannot collide
|
||||||
|
# with the production one the browser also sends to testmodul.qrmaster.net.
|
||||||
|
ARG AUTH_COOKIE_NAME=""
|
||||||
|
ENV AUTH_COOKIE_NAME=$AUTH_COOKIE_NAME
|
||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
@@ -51,18 +74,18 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/docker/entrypoint.sh ./docker/entrypoint.sh
|
COPY --from=builder --chown=nextjs:nodejs /app/docker/entrypoint.sh ./docker/entrypoint.sh
|
||||||
|
|
||||||
RUN chmod +x ./docker/entrypoint.sh
|
RUN chmod +x ./docker/entrypoint.sh
|
||||||
|
|
||||||
# Next writes ISR/prerender artifacts under .next/server/app at runtime.
|
# Next writes ISR/prerender artifacts under .next/server/app at runtime.
|
||||||
RUN mkdir -p /app/.next/cache /app/.next/server/app \
|
RUN mkdir -p /app/.next/cache /app/.next/server/app \
|
||||||
&& chown -R nextjs:nodejs /app/.next
|
&& chown -R nextjs:nodejs /app/.next
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
|
|||||||
259
PLAN_APP_SUBDOMAIN_2026-08-12.md
Normal file
259
PLAN_APP_SUBDOMAIN_2026-08-12.md
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
# Plan: Dashboard auf app.qrmaster.net
|
||||||
|
|
||||||
|
Stand: 2026-08-12 · Ziel: die eingeloggte App liegt auf `app.qrmaster.net`, Marketing/SEO bleibt auf `www.qrmaster.net`.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| Schritt | Stand |
|
||||||
|
|---|---|
|
||||||
|
| B1 Cookie-Domain | committed + gepusht (`35ea8cc`) |
|
||||||
|
| B2–B7 | Code fertig, typecheck + Production-Build grün, **noch nicht deployt** |
|
||||||
|
| A4 Google Console | erledigt (beide Redirect-URIs eingetragen) |
|
||||||
|
| A1 DNS, A2 Caddy, A3 .env, A6 Deploy | offen bei Timo |
|
||||||
|
|
||||||
|
Deploy-Reihenfolge unverändert: B1 zuerst allein live und einen Tag beobachten, dann B2–B7.
|
||||||
|
|
||||||
|
Neu gegenüber dem ursprünglichen Plan: `src/lib/hosts.ts` ist die einzige Quelle der Wahrheit
|
||||||
|
für die Host-Grenze (`APP_PATH_PREFIXES`, `isAppPath`, `wwwUrl`, `appUrl`, `urlForPath`).
|
||||||
|
Middleware, Stripe-Rückkehr-URLs und E-Mail-Links lesen alle daraus, damit sie nicht
|
||||||
|
auseinanderdriften.
|
||||||
|
|
||||||
|
## Zielarchitektur
|
||||||
|
|
||||||
|
**Ein Docker-Image, ein Container, zwei Hostnames.** Caddy routet `www.qrmaster.net` und
|
||||||
|
`app.qrmaster.net` auf denselben Upstream. Die Middleware macht Host-basiertes Routing.
|
||||||
|
|
||||||
|
**Wichtig: keine Datei zieht um.** Die Next-App serviert auf beiden Hosts weiterhin alle Routen.
|
||||||
|
`src/middleware.ts` entscheidet pro Host, welcher Pfad ausgeliefert wird, und 301t den Rest auf den
|
||||||
|
jeweils anderen Host. Damit bleiben alle relativen Links (`router.push('/dashboard')`,
|
||||||
|
`<Link href="/settings">`) unverändert korrekt, weil sie innerhalb desselben Hosts aufgelöst werden.
|
||||||
|
|
||||||
|
```
|
||||||
|
qrmaster.net --301--> www.qrmaster.net (bleibt wie heute)
|
||||||
|
www.qrmaster.net -> Marketing, /login, /signup, /r/*, /api/*
|
||||||
|
app.qrmaster.net -> /dashboard /create /analytics /settings /bulk-creation
|
||||||
|
/integrations /qr/* /upgrade /onboarding, /api/*
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fixierte Entscheidungen
|
||||||
|
|
||||||
|
| Frage | Entscheidung | Begründung |
|
||||||
|
|---|---|---|
|
||||||
|
| Hosting | Docker + Caddy auf eigenem Server | Bestand |
|
||||||
|
| `/login`, `/signup` | **bleiben auf www** | Alle 82 Marketing-CTAs zeigen auf `/signup`, `/signup` hat ein hartes Canonical auf www und trägt Ad-Traffic. Umzug wäre teuer ohne Nutzen. |
|
||||||
|
| `/onboarding` | zieht auf app | Reiner Logged-in-Flow, kein SEO-Wert |
|
||||||
|
| Host-Wechsel | genau **einmal**, nach erfolgreichem Login/Signup | einzige Cross-Host-Stelle im ganzen Flow |
|
||||||
|
| DB | keine Änderung | — |
|
||||||
|
|
||||||
|
## Teil A — Deine Aufgaben (Timo)
|
||||||
|
|
||||||
|
Reihenfolge beachten: A1–A2 **vor** dem Deploy von Schritt B5, sonst zeigt die Subdomain ins Leere.
|
||||||
|
|
||||||
|
### A1. DNS
|
||||||
|
CNAME `app` → auf denselben Zielhost wie `www` (bzw. A-Record auf dieselbe Server-IP).
|
||||||
|
Kein Proxy-Only-Sonderfall nötig, Caddy holt das Cert selbst.
|
||||||
|
|
||||||
|
### A2. Caddyfile auf dem Server
|
||||||
|
`app.qrmaster.net` in den bestehenden Site-Block aufnehmen, damit Caddy automatisch ein
|
||||||
|
Let's-Encrypt-Cert zieht:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
www.qrmaster.net, app.qrmaster.net {
|
||||||
|
reverse_proxy qrmaster-web:3000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach `caddy reload`. Prüfen: `curl -sI https://app.qrmaster.net` muss 200 oder 301 liefern,
|
||||||
|
kein TLS-Fehler.
|
||||||
|
|
||||||
|
### A3. `.env` auf dem Server ergänzen
|
||||||
|
Zwei Variablen statt einer. Die Trennung ist der Kern des ganzen Umbaus:
|
||||||
|
|
||||||
|
Für **Deploy 1** reicht:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
COOKIE_DOMAIN=.qrmaster.net
|
||||||
|
```
|
||||||
|
|
||||||
|
Für **Deploy 2** kommen dazu:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
NEXT_PUBLIC_WWW_URL=https://www.qrmaster.net
|
||||||
|
NEXT_PUBLIC_APP_URL=https://app.qrmaster.net
|
||||||
|
```
|
||||||
|
|
||||||
|
`NEXT_PUBLIC_APP_URL` erst zu Deploy 2 umstellen - vorher zeigt es auf www und muss dort
|
||||||
|
bleiben. Fehlen die Werte, greifen die Produktions-Fallbacks in `src/lib/hosts.ts`; ein
|
||||||
|
localhost-Wert kann damit nicht in gedruckte QR-Codes gelangen.
|
||||||
|
|
||||||
|
`NEXTAUTH_URL` bleibt `https://www.qrmaster.net` (wird nur noch von
|
||||||
|
`api/social-assets/route.ts` gelesen, kein Auth-Bezug mehr).
|
||||||
|
|
||||||
|
### A4. Google Cloud Console
|
||||||
|
Bei den OAuth-Credentials als **Authorized redirect URI** zusätzlich eintragen:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://app.qrmaster.net/api/auth/google
|
||||||
|
```
|
||||||
|
|
||||||
|
Die alte www-URI **nicht löschen** – sie wird während der Übergangszeit noch von
|
||||||
|
Sessions genutzt, die den Flow auf www gestartet haben.
|
||||||
|
|
||||||
|
### A5. Nichts zu tun bei Stripe und TikTok
|
||||||
|
- Stripe-Webhook zeigt auf `www.qrmaster.net/api/stripe/webhook` und bleibt gültig
|
||||||
|
(`/api/*` wird auf beiden Hosts weiter bedient, siehe B5).
|
||||||
|
- TikTok `redirect_uri` bleibt auf `qrmaster.net` – verifizierte Domain, nicht anfassen.
|
||||||
|
|
||||||
|
### A6. Deploy
|
||||||
|
`npm run docker:prod` (Rebuild ist zwingend – `NEXT_PUBLIC_*` wird zur Build-Zeit ins
|
||||||
|
Client-Bundle inlined, ein reiner Container-Restart genügt **nicht**).
|
||||||
|
|
||||||
|
## Teil B — Meine Aufgaben (Code), in Diff-Reihenfolge
|
||||||
|
|
||||||
|
### B1. Cookie-Domain teilen — muss zuerst live sein
|
||||||
|
Ohne das ist auf `app.qrmaster.net` jeder ausgeloggt: das `userId`-Cookie ist heute host-only.
|
||||||
|
|
||||||
|
- `src/lib/cookieConfig.ts:11` — `getAuthCookieOptions()`: `domain: process.env.COOKIE_DOMAIN` in Prod, in Dev `undefined` (localhost verträgt keine Punkt-Domain)
|
||||||
|
- `src/lib/cookieConfig.ts:24` — `getCsrfCookieOptions()`: dito
|
||||||
|
- `src/middleware.ts:34` — Attribution-Cookie: dito
|
||||||
|
- `src/app/(main)/api/auth/logout/route.ts:7` — **kritisch**: löscht heute host-only. Nach der
|
||||||
|
Umstellung existieren bei Bestandsnutzern beide Varianten (alt host-only + neu domain-scoped).
|
||||||
|
Logout muss **beide** überschreiben, sonst bleibt ein Zombie-Cookie und der Nutzer ist nicht
|
||||||
|
wirklich ausgeloggt. Gilt für `userId`, `newsletter-admin` und das Attribution-Cookie.
|
||||||
|
- `src/app/(main)/api/auth/google/route.ts:53,62` — OAuth-State + Post-Auth-Redirect-Cookie
|
||||||
|
|
||||||
|
Kein Forced-Logout nötig: beide Cookie-Varianten tragen denselben signierten Wert, der Server
|
||||||
|
akzeptiert jede. `verifySignedUserIdEdge` prüft die Signatur, das Teilen über eigene Subdomains
|
||||||
|
ist unkritisch.
|
||||||
|
|
||||||
|
**Dieser Schritt kann allein auf www deployt werden, bevor die Subdomain existiert** — nach außen
|
||||||
|
unsichtbar, und wenn app.* dann live geht, funktionieren Sessions sofort.
|
||||||
|
|
||||||
|
### B2. `NEXT_PUBLIC_APP_URL` entflechten
|
||||||
|
Die Variable bedient heute App- **und** öffentliche URLs. Jede Fundstelle einzeln zuordnen:
|
||||||
|
|
||||||
|
**Muss auf `WWW_URL` (öffentlich, teils in QR-Codes kodiert):**
|
||||||
|
- `src/components/dashboard/QRCodeCard.tsx:82` — **höchstes Risiko im ganzen Umbau**: Basis für
|
||||||
|
die in den QR-Code kodierte `/r/<slug>`-URL. Bleibt das auf `APP_URL`, zeigen alle neu
|
||||||
|
heruntergeladenen und gedruckten Codes auf die Subdomain.
|
||||||
|
- `src/app/(main)/r/[slug]/route.ts:50,61,84,89` — Landing-Basis vcard/text/coupon/feedback
|
||||||
|
- `src/lib/email.ts:56,562`, `src/lib/marketingEmail.ts:30` — Mail-Links auf Marketing-Inhalte
|
||||||
|
- `src/app/(main)/api/auth/signup/route.ts:20` — Verify-Mail-Link
|
||||||
|
- `src/app/(main)/api/stripe/checkout/route.ts:64` — `cancel_url` → `/pricing`
|
||||||
|
- `src/lib/metaConversions.ts:44`, `src/app/(main)/api/auth/signup/route.ts:150` — Event-Source-URLs
|
||||||
|
|
||||||
|
**Bleibt/wird `APP_URL` (eingeloggt):**
|
||||||
|
- `src/app/(main)/api/stripe/checkout/route.ts:63` — `success_url` → `/dashboard`
|
||||||
|
- `src/app/(main)/api/stripe/create-checkout-session/route.ts:112,128` — `appUrl` + returnPath
|
||||||
|
- `src/app/(main)/api/stripe/portal/route.ts:59` — `return_url` → `/settings`
|
||||||
|
- `src/lib/email.ts:505` — hartcodiertes `https://www.qrmaster.net/dashboard` im Mail-Footer
|
||||||
|
- `src/app/(main)/api/auth/google/route.ts:40,97` — `redirect_uri` (deckt A4 ab)
|
||||||
|
|
||||||
|
### B3. Post-Auth-Sprung auf app.*
|
||||||
|
Die einzige Cross-Host-Stelle. `sanitizeRedirectPath` (`src/lib/auth-flow.ts:4`) erlaubt bewusst
|
||||||
|
nur relative Pfade — bleibt so, ich baue den Host separat davor:
|
||||||
|
|
||||||
|
- `src/app/(main)/(auth)/login/ClientPage.tsx:56` und `login/LoginClient.tsx:65`
|
||||||
|
- `src/app/(main)/(auth)/signup/ClientPage.tsx:70`
|
||||||
|
- `src/app/(main)/api/auth/google/route.ts:224,228` — Server-Redirect
|
||||||
|
- `src/app/(main)/api/auth/verify-email/route.ts:38` — setzt Cookie und redirected
|
||||||
|
- `src/lib/auth-flow.ts:46` — `getPostOnboardingDestination`
|
||||||
|
|
||||||
|
Muster: relativen Zielpfad wie heute bestimmen, dann `new URL(path, APP_URL)`. Weil das
|
||||||
|
Auth-Cookie nach B1 auf `.qrmaster.net` gilt, ist der Nutzer nach dem Sprung sofort eingeloggt —
|
||||||
|
kein Token-Handover über die URL nötig.
|
||||||
|
|
||||||
|
### B4. Onboarding-Checkliste
|
||||||
|
`src/components/dashboard/OnboardingChecklist.tsx:141` verlinkt `/onboarding` mit
|
||||||
|
`redirect=/dashboard`. Beide Pfade liegen nach dem Umzug auf app.* → bleibt relativ, keine
|
||||||
|
Änderung. Nur verifizieren.
|
||||||
|
|
||||||
|
### B5. Middleware: Host-Routing
|
||||||
|
`src/middleware.ts` — Kern des Umbaus. Der bestehende Apex-Redirect (Zeile 49) bleibt unberührt.
|
||||||
|
Neu, direkt danach:
|
||||||
|
|
||||||
|
- Host `app.qrmaster.net`:
|
||||||
|
- `/api/*`, `/_next/*`, statische Dateien: durchlassen (Stripe-Webhook, CSRF, alles)
|
||||||
|
- `protectedPaths` (Zeile 145) + `/upgrade` + `/onboarding`: bedienen wie heute
|
||||||
|
- alles andere: 301 auf `WWW_URL` + gleicher Pfad
|
||||||
|
- `/r/*`: 301 auf www — QR-Redirects gehören nicht auf die App-Subdomain
|
||||||
|
- Host `www.qrmaster.net`:
|
||||||
|
- `protectedPaths` + `/upgrade` + `/onboarding`: 301 auf `APP_URL` + Pfad + Query
|
||||||
|
(damit alte Bookmarks und der Mail-Footer-Link weiter funktionieren)
|
||||||
|
- Auth-Fail-Redirect (Zeile 166): zeigt auf `/signup` — das liegt auf www, also absolut
|
||||||
|
auf `WWW_URL` umstellen, `redirect`-Param bleibt relativ
|
||||||
|
|
||||||
|
`/login` und `/signup` bleiben in `publicPaths` und werden nur auf www bedient.
|
||||||
|
|
||||||
|
### B6. Indexierung der Subdomain dichtmachen
|
||||||
|
`app.*` darf nicht in den Index, sonst Duplicate Content.
|
||||||
|
|
||||||
|
- `src/middleware.ts`: auf Host `app.*` `X-Robots-Tag: noindex, nofollow` auf alle Responses
|
||||||
|
- `public/robots-app.txt` neu anlegen (`User-agent: * / Disallow: /`), Middleware rewritet
|
||||||
|
`/robots.txt` auf app.* dorthin. `src/app/robots.ts` bleibt für www unverändert.
|
||||||
|
- `/sitemap.xml` auf app.* → 301 auf www
|
||||||
|
|
||||||
|
Gute Nachricht: `/dashboard`, `/create`, `/settings` sind in `src/app/robots.ts:7` bereits
|
||||||
|
disallowed und nicht in der Sitemap → **kein Ranking-Verlust durch den Umzug.** Die Canonicals
|
||||||
|
sind ohnehin hart auf www verdrahtet (`src/app/(main)/layout.tsx:13`).
|
||||||
|
|
||||||
|
### B7. Docker-Env-Kette
|
||||||
|
`NEXT_PUBLIC_*` wird zur Build-Zeit inlined **und** zur Laufzeit serverseitig gelesen. Beide
|
||||||
|
Stellen müssen übereinstimmen, sonst gibt es Bugs, die nur im Client oder nur im Server auftreten:
|
||||||
|
|
||||||
|
- `Dockerfile:34` — `NEXT_PUBLIC_APP_URL` auf `https://app.qrmaster.net`, neu
|
||||||
|
`ENV NEXT_PUBLIC_WWW_URL="https://www.qrmaster.net"`
|
||||||
|
- `docker-compose.yml:58` — `NEXT_PUBLIC_WWW_URL` und `COOKIE_DOMAIN` ins `environment` des
|
||||||
|
`web`-Service durchreichen
|
||||||
|
- `env.example` + `.env.example` — neue Variablen dokumentieren
|
||||||
|
- `src/lib/env.ts` — optional, das Schema kennt `NEXT_PUBLIC_*` bisher gar nicht
|
||||||
|
|
||||||
|
## Deploy-Choreografie
|
||||||
|
|
||||||
|
Zwei Deploys, nicht einer. Das entkoppelt das Cookie-Risiko vom Routing-Risiko:
|
||||||
|
|
||||||
|
1. **Deploy 1 (nur B1):** Cookie-Domain auf `.qrmaster.net`. Nur www ist live, nach außen
|
||||||
|
unsichtbar. 24 h beobachten: Login, Logout, Checkout müssen normal laufen.
|
||||||
|
2. **A1 + A2 + A4:** DNS, Caddy, Google Console. `app.qrmaster.net` antwortet, serviert aber
|
||||||
|
noch dieselbe App wie www — unkritisch, weil noch nicht verlinkt und dank B6 noch nicht
|
||||||
|
indexierbar.
|
||||||
|
3. **Deploy 2 (B2–B7):** Host-Routing scharf. Ab hier springt Login auf app.*.
|
||||||
|
|
||||||
|
Rollback: Deploy 2 zurücknehmen. Weil das Cookie auf `.qrmaster.net` gilt, bleiben Sessions
|
||||||
|
auch nach dem Rollback auf www gültig — niemand wird ausgeloggt. DNS/Caddy können stehen bleiben.
|
||||||
|
|
||||||
|
## Testcheckliste (nach Deploy 2)
|
||||||
|
|
||||||
|
Jeweils über beide Hosts:
|
||||||
|
|
||||||
|
- [ ] `www.qrmaster.net/dashboard` → 301 auf `app.qrmaster.net/dashboard`, eingeloggt
|
||||||
|
- [ ] `app.qrmaster.net/pricing` → 301 auf www
|
||||||
|
- [ ] Signup auf www → Verify-Mail → Link führt eingeloggt auf app.*
|
||||||
|
- [ ] Google-Login von www aus → landet eingeloggt auf app.*/dashboard bzw. /onboarding
|
||||||
|
- [ ] Logout auf app.* → auf www **auch** ausgeloggt (prüft B1, häufigster Fehler)
|
||||||
|
- [ ] Checkout: Upgrade auf app.* → Stripe → `success_url` app.*/dashboard, Abbruch → www/pricing
|
||||||
|
- [ ] Stripe-Portal → zurück auf app.*/settings
|
||||||
|
- [ ] Stripe-Webhook feuert weiter (Dashboard → Events, keine 4xx)
|
||||||
|
- [ ] **QR-Code neu anlegen + herunterladen → kodierte URL ist `www.qrmaster.net/r/<slug>`**,
|
||||||
|
nicht app.* (prüft B2, das teuerste Fehlerbild)
|
||||||
|
- [ ] Bestehender `/r/<slug>` redirected + trackt weiter, vcard/coupon/feedback-Landings laden
|
||||||
|
- [ ] Mutation auf app.* (QR umbenennen) → CSRF greift, kein 403
|
||||||
|
- [ ] `curl -sI https://app.qrmaster.net/dashboard | grep -i x-robots-tag` → noindex
|
||||||
|
- [ ] `https://app.qrmaster.net/robots.txt` → `Disallow: /`
|
||||||
|
- [ ] Search Console: `app.qrmaster.net` **nicht** als Property anlegen, keine Sitemap einreichen
|
||||||
|
|
||||||
|
## Risiken
|
||||||
|
|
||||||
|
| Risiko | Wo | Absicherung |
|
||||||
|
|---|---|---|
|
||||||
|
| Gedruckte QR-Codes zeigen auf app.* | `QRCodeCard.tsx:82` | B2, explizit im Test |
|
||||||
|
| Logout wirkt nicht (Zombie-Cookie) | `logout/route.ts` | B1 löscht beide Varianten |
|
||||||
|
| Client/Server-Env divergieren | `Dockerfile` vs. `docker-compose.yml` | B7, beide Stellen setzen |
|
||||||
|
| Google-OAuth bricht | Cloud Console | A4, alte URI stehen lassen |
|
||||||
|
| Duplicate Content auf app.* | — | B6 vor Deploy 2 |
|
||||||
|
|
||||||
|
## Aufwand
|
||||||
|
|
||||||
|
- Deine Seite: ~45 min (DNS, Caddy, .env, Google Console, Deploy)
|
||||||
|
- Meine Seite: ~4–6 h Code über zwei Deploys
|
||||||
|
- Keine DB-Änderung, kein Forced-Logout, kein SEO-Verlust
|
||||||
299
PLAN_TESTUMGEBUNG_2026-08-12.md
Normal file
299
PLAN_TESTUMGEBUNG_2026-08-12.md
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
# Plan: Testumgebung auf testmodul.qrmaster.net
|
||||||
|
|
||||||
|
Stand: 2026-08-12 · Ziel: eine vollständige zweite Instanz auf `testmodul.qrmaster.net`, die
|
||||||
|
einen anderen Branch (`test`) fährt, damit riskante Features - z.B. ein Bot, der bei
|
||||||
|
Kundenerfolgen automatisch tweetet - vor dem Merge nach `master` real getestet werden können.
|
||||||
|
|
||||||
|
**Status: nur Planung. Es wurde noch kein Code geändert.**
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
Zweiter, vollständig eigenständiger Compose-Stack aus demselben Repo, anderer Branch,
|
||||||
|
eigene Datenbank, eigenes Redis. Caddy routet `testmodul.qrmaster.net` auf den Test-Container.
|
||||||
|
|
||||||
|
```
|
||||||
|
www.qrmaster.net ─┐
|
||||||
|
app.qrmaster.net ─┴─> qrmaster-web (Branch master, Prod-DB)
|
||||||
|
testmodul.qrmaster.net ─> qrmaster-test-web (Branch test, eigene DB)
|
||||||
|
```
|
||||||
|
|
||||||
|
Prod und Test teilen sich **nichts** außer dem Docker-Netzwerk `qrmaster-network` (das ist
|
||||||
|
`external: true`, darüber erreicht Caddy beide Container per Namen).
|
||||||
|
|
||||||
|
## Ausgangslage im Repo
|
||||||
|
|
||||||
|
Im Working Tree liegen uncommittete Änderungen, die nicht aus der Subdomain-Arbeit stammen:
|
||||||
|
|
||||||
|
- `src/lib/cookieConfig.ts` - `getAuthCookieName()` ist angelegt (liest `AUTH_COOKIE_NAME`,
|
||||||
|
Default `userId`)
|
||||||
|
- `src/lib/session.ts` - importiert `getAuthCookieName`, **benutzt es aber nicht**; Zeile 14
|
||||||
|
hat weiterhin `export const AUTH_COOKIE_NAME = 'userId'`
|
||||||
|
|
||||||
|
Damit ist die Funktion aktuell wirkungslos. Die Verkabelung fehlt an 6 Stellen (siehe unten).
|
||||||
|
Vor dem Weiterbauen klären, ob diese Änderungen bewusst so stehen oder committed werden sollen.
|
||||||
|
|
||||||
|
Ein Branch `test` existiert noch nicht. Vorhanden: `analytics`, `dynamisch`,
|
||||||
|
`feature/mockup-landing-page`, `icons`, `master`.
|
||||||
|
|
||||||
|
## Falle 1 - Cookie-Kollision (blockierend)
|
||||||
|
|
||||||
|
Produktion setzt das Session-Cookie `userId` auf `.qrmaster.net`. Der Browser schickt es damit
|
||||||
|
**auch an `testmodul.qrmaster.net`**. Setzt Test sein eigenes `userId` als Host-Cookie, liegen
|
||||||
|
zwei gleichnamige Cookies vor, und `req.cookies.get('userId')` in
|
||||||
|
[middleware.ts:250](src/middleware.ts:250) liefert undefiniert welches davon. Folge: Login auf
|
||||||
|
Test verhält sich sporadisch wie ausgeloggt - ein Fehlerbild, das schwer zu greifen ist, weil es
|
||||||
|
vom Cookie-Zustand des jeweiligen Browsers abhängt.
|
||||||
|
|
||||||
|
**Lösung:** Cookie-Name pro Umgebung konfigurierbar. `getAuthCookieName()` fertig verkabeln,
|
||||||
|
Test setzt `AUTH_COOKIE_NAME=userId_test`.
|
||||||
|
|
||||||
|
Zu ändernde Stellen:
|
||||||
|
|
||||||
|
| Datei | Was |
|
||||||
|
|---|---|
|
||||||
|
| `src/lib/session.ts:14` | `AUTH_COOKIE_NAME`-Konstante durch `getAuthCookieName()` ersetzen (Zeilen 71, 78 ziehen nach) |
|
||||||
|
| `src/middleware.ts:250` | `req.cookies.get('userId')` → `getAuthCookieName()` |
|
||||||
|
| `src/app/(main)/api/auth/google/route.ts:227` | `cookies.set('userId', …)` |
|
||||||
|
| `src/app/(main)/api/auth/verify-email/route.ts:40` | `cookies.set('userId', …)` |
|
||||||
|
| `src/app/(main)/api/auth/logout/route.ts:12` | Eintrag in `appendExpiredCookies` |
|
||||||
|
| `src/app/(main)/api/user/delete/route.ts:77` | `cookies().delete('userId')` |
|
||||||
|
|
||||||
|
Wie `COOKIE_DOMAIN` muss `AUTH_COOKIE_NAME` **auch zur Build-Zeit** gesetzt sein - `process.env`
|
||||||
|
wird ins Edge-Middleware-Bundle inlined. Also `ARG` + `ENV` im Dockerfile und als Build-Arg im
|
||||||
|
Compose-Override, analog zu `COOKIE_DOMAIN`.
|
||||||
|
|
||||||
|
Achtung beim Umstellen der Produktion: ändert sich dort der Cookie-Name, werden **alle
|
||||||
|
bestehenden Sessions ungültig** (alle Nutzer ausgeloggt). Deshalb Prod beim Default `userId`
|
||||||
|
lassen und nur Test abweichen - dann passiert genau nichts.
|
||||||
|
|
||||||
|
## Falle 2 - Host-Routing würde nach Prod umleiten
|
||||||
|
|
||||||
|
Die Middleware kennt nur zwei Hosts. Auf `testmodul.qrmaster.net` greift der Zweig
|
||||||
|
`else if (isAppPath(path))` und schickt `/dashboard` per 301 auf **app.qrmaster.net**, also in
|
||||||
|
die Produktion.
|
||||||
|
|
||||||
|
**Lösung ohne Codeänderung:** Test zeigt beide Origins auf sich selbst. Dann ist
|
||||||
|
`isHostSplitEnabled()` false, `getAppHostname()` liefert null, und das komplette Host-Routing
|
||||||
|
schaltet sich ab. Test läuft als Ein-Host-Umgebung mit Marketing *und* App unter einer Domain -
|
||||||
|
genau wie die lokale Entwicklung.
|
||||||
|
|
||||||
|
Das ist der Grund, warum beide Variablen in `.env.test` identisch sein müssen. Wer dort später
|
||||||
|
aus Versehen `NEXT_PUBLIC_APP_URL=https://app.qrmaster.net` einträgt, schickt seine Testklicks
|
||||||
|
in die Produktion.
|
||||||
|
|
||||||
|
## Falle 3 - ausgehende Nebenwirkungen
|
||||||
|
|
||||||
|
Das ist beim Twitter-Bot der eigentliche Punkt. Eine Testumgebung, die auf echte Dienste zeigt,
|
||||||
|
ist keine Testumgebung.
|
||||||
|
|
||||||
|
| Dienst | Auf Test |
|
||||||
|
|---|---|
|
||||||
|
| Twitter/X | eigener App-Key auf einen Test-Account, **oder** ein `DRY_RUN`-Flag, das den Tweet nur loggt |
|
||||||
|
| Stripe | Test-Keys (`sk_test_…`), eigener Webhook-Endpoint auf testmodul |
|
||||||
|
| Resend / SMTP | Test-Key oder komplett deaktivieren - sonst mailt Test an echte Kunden |
|
||||||
|
| Meta Conversions / PostHog / Umami | leer lassen, sonst verschmutzt Test die Prod-Analytics |
|
||||||
|
| Cron (Retention-Mails) | auf Test abschalten |
|
||||||
|
|
||||||
|
Empfehlung für den Bot: das `DRY_RUN`-Flag von Anfang an einbauen, nicht erst wenn es einmal
|
||||||
|
schiefging. Ein Bot, der Kundenerfolge tweetet, ist genau die Sorte Feature, die man nicht
|
||||||
|
"kurz mal live" testen will.
|
||||||
|
|
||||||
|
## .env.test
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# Ein-Host-Betrieb: schaltet das Host-Routing ab
|
||||||
|
NEXT_PUBLIC_WWW_URL=https://testmodul.qrmaster.net
|
||||||
|
NEXT_PUBLIC_APP_URL=https://testmodul.qrmaster.net
|
||||||
|
NEXTAUTH_URL=https://testmodul.qrmaster.net
|
||||||
|
|
||||||
|
# Host-Cookie, nicht .qrmaster.net - sonst leckt die Test-Session nach Prod
|
||||||
|
COOKIE_DOMAIN=
|
||||||
|
AUTH_COOKIE_NAME=userId_test
|
||||||
|
|
||||||
|
# Anderes Secret: ein Leak auf Test kann dann keine Prod-Session fälschen
|
||||||
|
NEXTAUTH_SECRET=<eigenes Secret>
|
||||||
|
IP_SALT=<eigenes Salt>
|
||||||
|
|
||||||
|
# Nicht indexieren
|
||||||
|
NEXT_PUBLIC_INDEXABLE=false
|
||||||
|
|
||||||
|
# Eigene DB im Test-Stack
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=<eigenes, hex - ein "/" aus base64 zerlegt die DATABASE_URL>
|
||||||
|
POSTGRES_DB=qrmaster_test
|
||||||
|
# DATABASE_URL nicht setzen - Compose baut sie aus den drei Werten oben
|
||||||
|
|
||||||
|
# Test-Keys / leer, siehe Falle 3
|
||||||
|
STRIPE_SECRET_KEY=sk_test_…
|
||||||
|
RESEND_API_KEY=
|
||||||
|
```
|
||||||
|
|
||||||
|
`NEXTAUTH_URL` darf hier auf testmodul zeigen - der einzige echte Leser ist die
|
||||||
|
Social-Assets-Route, und die soll auf Test ohnehin nicht gegen die verifizierte TikTok-Domain
|
||||||
|
laufen.
|
||||||
|
|
||||||
|
## Infra
|
||||||
|
|
||||||
|
**`docker-compose.test.yml`** als Override, das nur die Abweichungen setzt:
|
||||||
|
|
||||||
|
- `container_name`: `qrmaster-test-db`, `qrmaster-test-redis`, `qrmaster-test-web` - die Namen
|
||||||
|
sind im Basis-File fest vergeben und würden sonst kollidieren
|
||||||
|
- Host-Ports entfernen - `5435` (db) und `8080` (adminer) sind schon von Prod belegt
|
||||||
|
- `entrypoint: ["node", "server.js"]` für `web`, siehe Migrationen oben
|
||||||
|
- Build-Args für `COOKIE_DOMAIN`, `AUTH_COOKIE_NAME`, `NEXT_PUBLIC_*`
|
||||||
|
|
||||||
|
### Netzwerk-Isolation (kritisch)
|
||||||
|
|
||||||
|
`db` und `redis` hängen im Basis-File am Netzwerk `qrmaster-network`
|
||||||
|
([docker-compose.yml:22](docker-compose.yml:22)), und das ist `external: true`, also für beide
|
||||||
|
Stacks dasselbe. Compose vergibt jedem Service automatisch einen Netzwerk-Alias mit seinem
|
||||||
|
Servicenamen - zwei Stacks mit einem Service `db` am selben Netzwerk heißt: **`db` löst auf
|
||||||
|
zwei Container auf und Dockers DNS wählt zufällig.** Der Test-Container könnte damit auf der
|
||||||
|
Produktionsdatenbank landen, nicht deterministisch, sondern mal so und mal so.
|
||||||
|
|
||||||
|
Deshalb bekommt der Test-Stack ein eigenes internes Netzwerk. Nur `web` hängt zusätzlich am
|
||||||
|
geteilten Netz, damit Caddy es erreicht:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
container_name: qrmaster-test-db
|
||||||
|
ports: !reset []
|
||||||
|
networks: [test-internal]
|
||||||
|
redis:
|
||||||
|
container_name: qrmaster-test-redis
|
||||||
|
networks: [test-internal]
|
||||||
|
web:
|
||||||
|
container_name: qrmaster-test-web
|
||||||
|
entrypoint: ["node", "server.js"]
|
||||||
|
networks: [test-internal, qrmaster-network]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
test-internal:
|
||||||
|
driver: bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
`POSTGRES_DB` heißt auf Test `qrmaster_test`. Die Trennung kommt zwar schon vom eigenen
|
||||||
|
Container und Volume, aber der abweichende Name macht bei einer von Hand getippten
|
||||||
|
`psql`-Sitzung sofort sichtbar, auf welcher Instanz man ist - die billigste Versicherung
|
||||||
|
gegen ein `DELETE` in der falschen Datenbank.
|
||||||
|
|
||||||
|
Dafür muss der Healthcheck mitgezogen werden: das Basis-File hat `pg_isready -d qrmaster`
|
||||||
|
hartkodiert ([Zeile 19](docker-compose.yml:19)). Ohne Override prüft er eine Datenbank, die
|
||||||
|
es nicht gibt, der Container bleibt `unhealthy`, und `web` startet wegen
|
||||||
|
`depends_on: condition: service_healthy` nie.
|
||||||
|
|
||||||
|
Start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -p qrmaster-test --env-file .env.test \
|
||||||
|
-f docker-compose.yml -f docker-compose.test.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Projektname `-p qrmaster-test` gibt automatisch eigene Volumes - die Test-DB kann die
|
||||||
|
Prod-DB also nicht anfassen.
|
||||||
|
|
||||||
|
**Caddy:**
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
testmodul.qrmaster.net {
|
||||||
|
reverse_proxy qrmaster-test-web:3000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`robots.ts`** an `NEXT_PUBLIC_INDEXABLE` koppeln. Aktuell liefert es hart `Allow` plus
|
||||||
|
www-Sitemap; auf Test soll `Disallow: /` stehen. `NEXT_PUBLIC_INDEXABLE=false` setzt heute nur
|
||||||
|
das Meta-Tag in den beiden Layouts, nicht die robots.txt.
|
||||||
|
|
||||||
|
## Branch-Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout -b test master
|
||||||
|
git push -u origin test
|
||||||
|
```
|
||||||
|
|
||||||
|
Deploy auf Test: auf dem Server `git checkout test && git pull`, dann der Compose-Befehl oben.
|
||||||
|
Rebuild ist immer nötig, weil `NEXT_PUBLIC_*` und `AUTH_COOKIE_NAME` zur Build-Zeit inlined
|
||||||
|
werden.
|
||||||
|
|
||||||
|
Ablauf für ein Feature: Branch von `test` abzweigen → auf Test deployen und prüfen → nach
|
||||||
|
`master` mergen → Prod-Deploy. `test` bleibt dauerhaft bestehen und wird regelmäßig von
|
||||||
|
`master` nachgezogen, damit er nicht wegdriftet.
|
||||||
|
|
||||||
|
## Datenbank - entschieden: leeres Schema
|
||||||
|
|
||||||
|
Eigene PostgreSQL-Instanz im Test-Stack, befüllt mit **Struktur ohne Zeilen**. Keine
|
||||||
|
Kundendaten verlassen die Produktion.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Struktur aus Prod ziehen (keine Zeilen)
|
||||||
|
docker exec qrmaster-db pg_dump -U postgres --schema-only qrmaster > schema.sql
|
||||||
|
|
||||||
|
# in die Test-DB einspielen
|
||||||
|
docker exec -i qrmaster-test-db psql -U postgres -d qrmaster_test < schema.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach einen Testaccount anlegen - entweder über das Signup-Formular auf testmodul oder per
|
||||||
|
`INSERT`.
|
||||||
|
|
||||||
|
**Warum der Dump aus Prod und nicht aus Prisma:** `prisma/migrations` steht auf April 2026.
|
||||||
|
Alles seitdem (Pulse-Spalten, `BARCODE`-Enum-Wert, ...) kam per Hand-SQL. Die Produktions-DB
|
||||||
|
ist damit die einzige Stelle, die die aktuelle Struktur kennt.
|
||||||
|
|
||||||
|
**Warum leer statt Kopie:** beim Tweet-Bot willst du wissen, welcher Testfall den Tweet
|
||||||
|
ausgelöst hat. Mit Prod-Daten könnte es auch irgendein echter Kunde von vor Monaten gewesen sein.
|
||||||
|
|
||||||
|
### Keine Migrationen - der Entrypoint muss überschrieben werden
|
||||||
|
|
||||||
|
[docker/entrypoint.sh:5](docker/entrypoint.sh:5) führt bei **jedem** Container-Start
|
||||||
|
`npx prisma migrate deploy` aus. Das widerspricht der Policy aus CLAUDE.md (Schema-Änderungen
|
||||||
|
nur per Hand-SQL) und würde auf einer frischen Test-DB die 6 veralteten Migrationen anwenden -
|
||||||
|
also ein Schema vom Stand April 2026 bauen, dem alle späteren SQL-Änderungen fehlen. Die App
|
||||||
|
liefe dann in "column does not exist".
|
||||||
|
|
||||||
|
Im Test-Override deshalb:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
web:
|
||||||
|
entrypoint: ["node", "server.js"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Prod bleibt unverändert. Der Schema-Dump bringt die Tabelle `_prisma_migrations` ohnehin mit,
|
||||||
|
inklusive der 6 als angewandt markierten Einträge - der Zustand ist also identisch zu Prod.
|
||||||
|
|
||||||
|
**Separat zu klären (nicht Teil dieses Plans):** ob `prisma migrate deploy` langfristig auch
|
||||||
|
aus dem Prod-Entrypoint verschwinden soll. Heute ist es dort ein No-Op, aber es ist eine
|
||||||
|
scharfe Waffe, die bei einem versehentlich hinzugefügten Migrationsfile auf die Produktion
|
||||||
|
losgeht.
|
||||||
|
|
||||||
|
### Spätere Schema-Änderungen
|
||||||
|
|
||||||
|
Ein SQL-Statement, das auf Test getestet wurde, wird auf Prod **erneut von Hand** ausgeführt -
|
||||||
|
es gibt keinen automatischen Weg von Test nach Prod. Die Dateien in `sql/` sind der Ort dafür.
|
||||||
|
|
||||||
|
## Aufwand
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Cookie-Name verkabeln (6 Stellen) + Dockerfile/Compose | ~1 h |
|
||||||
|
| `docker-compose.test.yml` + `robots.ts` an INDEXABLE koppeln | ~1 h |
|
||||||
|
| `.env.test`, Secrets, Test-Keys besorgen | Timo |
|
||||||
|
| Caddy-Block + erster Deploy | ~30 min |
|
||||||
|
| DB-Variante B zusätzlich | +2-3 h für das Anonymisierungs-Skript |
|
||||||
|
|
||||||
|
Der Twitter-Bot selbst ist davon unabhängig und noch nicht geschätzt.
|
||||||
|
|
||||||
|
## Testcheckliste nach dem ersten Test-Deploy
|
||||||
|
|
||||||
|
- [ ] `testmodul.qrmaster.net` lädt, gültiges Zertifikat
|
||||||
|
- [ ] `testmodul.qrmaster.net/dashboard` bleibt **auf testmodul** und springt nicht nach app.qrmaster.net
|
||||||
|
- [ ] Login auf Test funktioniert, während man in Prod eingeloggt ist - beide Sessions unabhängig
|
||||||
|
- [ ] Logout auf Test loggt **nicht** aus Prod aus (und umgekehrt)
|
||||||
|
- [ ] Im Browser liegen zwei Cookies: `userId` (Domain `.qrmaster.net`) und `userId_test` (Host `testmodul.qrmaster.net`)
|
||||||
|
- [ ] `curl -sI https://testmodul.qrmaster.net` → kein `X-Robots-Tag` nötig, aber `/robots.txt` liefert `Disallow: /`
|
||||||
|
- [ ] Test-DB enthält **keine** echten Kunden: `SELECT count(*) FROM "User";` muss die Zahl der selbst angelegten Testaccounts sein
|
||||||
|
- [ ] `docker compose -p qrmaster-test exec web env | grep DATABASE_URL` zeigt auf den Test-Container, und `SELECT count(*)` dort weicht von Prod ab - beweist, dass der `db`-Alias nicht auf Prod zeigt
|
||||||
|
- [ ] Container-Logs beim Start enthalten **kein** "Applying Prisma migrations"
|
||||||
|
- [ ] Ein Testlauf des Bots postet nichts auf dem echten Account
|
||||||
85
docker-compose.test.yml
Normal file
85
docker-compose.test.yml
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Test/staging overlay for testmodul.qrmaster.net.
|
||||||
|
#
|
||||||
|
# Start with:
|
||||||
|
# docker compose -p qrmaster-test --env-file .env.test \
|
||||||
|
# -f docker-compose.yml -f docker-compose.test.yml up -d --build
|
||||||
|
#
|
||||||
|
# The project name is what keeps the data apart: `-p qrmaster-test` gives this stack its own
|
||||||
|
# volumes, so its Postgres can never touch the production one.
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
container_name: qrmaster-test-db
|
||||||
|
# Production already publishes 5435 on the host.
|
||||||
|
ports: !reset []
|
||||||
|
# The base file hardcodes the database name in the probe. Staging uses its own name so a
|
||||||
|
# hand-typed psql session makes it obvious which instance you are on - without this
|
||||||
|
# override the probe would check a database that does not exist, the container would stay
|
||||||
|
# unhealthy and web (depends_on: service_healthy) would never start.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d qrmaster_test"]
|
||||||
|
# Only on the internal network. `db` and `redis` are network aliases assigned per
|
||||||
|
# compose project, so leaving them on the shared external network would make `db`
|
||||||
|
# resolve to two containers and this stack could reach the production database.
|
||||||
|
#
|
||||||
|
# `!override` is required: compose MERGES the networks mapping from the base file, so a
|
||||||
|
# plain list would leave qrmaster-network attached and reintroduce exactly that bug.
|
||||||
|
networks: !override
|
||||||
|
- test-internal
|
||||||
|
|
||||||
|
redis:
|
||||||
|
container_name: qrmaster-test-redis
|
||||||
|
networks: !override
|
||||||
|
- test-internal
|
||||||
|
|
||||||
|
web:
|
||||||
|
container_name: qrmaster-test-web
|
||||||
|
# No `prisma migrate deploy` here. The migrations in prisma/migrations stopped in
|
||||||
|
# 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:
|
||||||
|
# Host-only cookie on staging, so `:-` (empty) is the correct value here.
|
||||||
|
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
|
||||||
|
# These three use `:?` on purpose: an empty value would silently fall back to the
|
||||||
|
# production defaults baked into the Dockerfile, and the staging frontend would then
|
||||||
|
# talk to production. Better to fail the build with a readable message.
|
||||||
|
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:?set AUTH_COOKIE_NAME in .env.test, e.g. userId_test}
|
||||||
|
NEXT_PUBLIC_WWW_URL: ${NEXT_PUBLIC_WWW_URL:?set NEXT_PUBLIC_WWW_URL in .env.test to https://testmodul.qrmaster.net}
|
||||||
|
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:?set NEXT_PUBLIC_APP_URL in .env.test to https://testmodul.qrmaster.net}
|
||||||
|
# Reachable by Caddy over the shared network, everything else stays internal.
|
||||||
|
networks:
|
||||||
|
- 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 []
|
||||||
|
networks: !override
|
||||||
|
- test-internal
|
||||||
|
|
||||||
|
networks:
|
||||||
|
test-internal:
|
||||||
|
driver: bridge
|
||||||
@@ -43,6 +43,11 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_UMAMI_SRC: ${NEXT_PUBLIC_UMAMI_SRC:-}
|
||||||
|
NEXT_PUBLIC_UMAMI_ID: ${NEXT_PUBLIC_UMAMI_ID:-}
|
||||||
|
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
|
||||||
|
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-}
|
||||||
container_name: qrmaster-web
|
container_name: qrmaster-web
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -53,12 +58,25 @@ services:
|
|||||||
NEXTAUTH_URL: ${NEXTAUTH_URL}
|
NEXTAUTH_URL: ${NEXTAUTH_URL}
|
||||||
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
|
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
|
||||||
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3050}
|
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3050}
|
||||||
|
NEXT_PUBLIC_WWW_URL: ${NEXT_PUBLIC_WWW_URL:-http://localhost:3050}
|
||||||
|
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
|
||||||
|
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-}
|
||||||
INTERNAL_API_SECRET: ${INTERNAL_API_SECRET}
|
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_KEY: ${TIKTOK_CLIENT_KEY:-}
|
||||||
TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-}
|
TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-}
|
||||||
TIKTOK_REDIRECT_URI: ${TIKTOK_REDIRECT_URI:-https://qrmaster.net/api/tiktok/callback}
|
TIKTOK_REDIRECT_URI: ${TIKTOK_REDIRECT_URI:-https://qrmaster.net/api/tiktok/callback}
|
||||||
TIKTOK_ADMIN_KEY: ${TIKTOK_ADMIN_KEY:-}
|
TIKTOK_ADMIN_KEY: ${TIKTOK_ADMIN_KEY:-}
|
||||||
TIKTOK_EXPECTED_OPEN_ID: ${TIKTOK_EXPECTED_OPEN_ID:-}
|
# 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}
|
IP_SALT: ${IP_SALT:-your-salt-change-in-production}
|
||||||
ENABLE_DEMO: ${ENABLE_DEMO:-false}
|
ENABLE_DEMO: ${ENABLE_DEMO:-false}
|
||||||
NEXT_PUBLIC_INDEXABLE: ${NEXT_PUBLIC_INDEXABLE:-true}
|
NEXT_PUBLIC_INDEXABLE: ${NEXT_PUBLIC_INDEXABLE:-true}
|
||||||
@@ -76,14 +94,14 @@ services:
|
|||||||
# Email & Analytics
|
# Email & Analytics
|
||||||
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||||
SMTP_HOST: ${SMTP_HOST:-smtp.qrmaster.net}
|
SMTP_HOST: ${SMTP_HOST:-smtp.qrmaster.net}
|
||||||
SMTP_PORT: ${SMTP_PORT:-465}
|
SMTP_PORT: ${SMTP_PORT:-465}
|
||||||
SMTP_USER: ${SMTP_USER:-timo@qrmaster.net}
|
SMTP_USER: ${SMTP_USER:-info@qrmaster.net}
|
||||||
SMTP_PASS: ${SMTP_PASS:-}
|
SMTP_PASS: ${SMTP_PASS:-}
|
||||||
NEWSLETTER_ADMIN_EMAIL: ${NEWSLETTER_ADMIN_EMAIL:-}
|
NEWSLETTER_ADMIN_EMAIL: ${NEWSLETTER_ADMIN_EMAIL:-}
|
||||||
NEWSLETTER_ADMIN_PASSWORD: ${NEWSLETTER_ADMIN_PASSWORD:-}
|
NEWSLETTER_ADMIN_PASSWORD: ${NEWSLETTER_ADMIN_PASSWORD:-}
|
||||||
NEWSLETTER_TEST_EMAIL: ${NEWSLETTER_TEST_EMAIL:-}
|
NEWSLETTER_TEST_EMAIL: ${NEWSLETTER_TEST_EMAIL:-}
|
||||||
EMAIL_UNSUBSCRIBE_SECRET: ${EMAIL_UNSUBSCRIBE_SECRET:-}
|
EMAIL_UNSUBSCRIBE_SECRET: ${EMAIL_UNSUBSCRIBE_SECRET:-}
|
||||||
NEXT_PUBLIC_POSTHOG_KEY: ${NEXT_PUBLIC_POSTHOG_KEY:-}
|
NEXT_PUBLIC_POSTHOG_KEY: ${NEXT_PUBLIC_POSTHOG_KEY:-}
|
||||||
NEXT_PUBLIC_POSTHOG_HOST: ${NEXT_PUBLIC_POSTHOG_HOST:-https://us.i.posthog.com}
|
NEXT_PUBLIC_POSTHOG_HOST: ${NEXT_PUBLIC_POSTHOG_HOST:-https://us.i.posthog.com}
|
||||||
# Cloudflare R2 Storage
|
# Cloudflare R2 Storage
|
||||||
R2_ACCOUNT_ID: ${R2_ACCOUNT_ID:-}
|
R2_ACCOUNT_ID: ${R2_ACCOUNT_ID:-}
|
||||||
@@ -91,6 +109,8 @@ services:
|
|||||||
R2_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY:-}
|
R2_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY:-}
|
||||||
R2_BUCKET_NAME: ${R2_BUCKET_NAME:-qrmaster-menus}
|
R2_BUCKET_NAME: ${R2_BUCKET_NAME:-qrmaster-menus}
|
||||||
R2_PUBLIC_URL: ${R2_PUBLIC_URL:-}
|
R2_PUBLIC_URL: ${R2_PUBLIC_URL:-}
|
||||||
|
NEXT_PUBLIC_UMAMI_SRC: ${NEXT_PUBLIC_UMAMI_SRC:-}
|
||||||
|
NEXT_PUBLIC_UMAMI_ID: ${NEXT_PUBLIC_UMAMI_ID:-}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -103,6 +123,35 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
networks:
|
networks:
|
||||||
- qrmaster-network
|
- 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 - Database Management UI (Optional)
|
||||||
|
|||||||
54
docker/init-db.sh
Normal file → Executable file
54
docker/init-db.sh
Normal file → Executable file
@@ -1,26 +1,28 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# This script runs when the PostgreSQL container is first created
|
# This script runs when the PostgreSQL container is first created
|
||||||
# It ensures the database is properly initialized
|
# It ensures the database is properly initialized
|
||||||
|
#
|
||||||
echo "🚀 Initializing QR Master database..."
|
# 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
|
||||||
# Create the database if it doesn't exist (already created by POSTGRES_DB)
|
# the init, and the container never becomes healthy.
|
||||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
# Must stay LF-only and executable - Postgres sources non-executable init
|
||||||
-- Enable required extensions
|
# scripts, and CRLF breaks them on the first line.
|
||||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
|
||||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
echo "🚀 Initializing QR Master database..."
|
||||||
|
|
||||||
-- Grant privileges
|
# The database itself is already created by POSTGRES_DB
|
||||||
GRANT ALL PRIVILEGES ON DATABASE qrmaster TO postgres;
|
psql -v ON_ERROR_STOP=1 -v dbname="$POSTGRES_DB" --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
||||||
|
-- Enable required extensions
|
||||||
-- Set timezone
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
ALTER DATABASE qrmaster SET timezone TO 'UTC';
|
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
||||||
EOSQL
|
|
||||||
|
-- Set timezone
|
||||||
echo "✅ Database initialization complete!"
|
ALTER DATABASE :"dbname" SET timezone TO 'UTC';
|
||||||
echo "📊 Database: $POSTGRES_DB"
|
EOSQL
|
||||||
echo "👤 User: $POSTGRES_USER"
|
|
||||||
echo "🌐 Ready to accept connections on port 5432"
|
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\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\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\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-x-autopost\.env` | X/Twitter GreenLens creds |
|
||||||
| `C:\Users\timo\Documents\greenlens\Greenlens\.env` | GreenLens TikTok + plant import admin 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).
|
||||||
28
env.example
28
env.example
@@ -16,6 +16,27 @@ DATABASE_URL=postgresql://postgres:postgres@db:5432/qrmaster?schema=public
|
|||||||
NEXTAUTH_URL=http://localhost:3050
|
NEXTAUTH_URL=http://localhost:3050
|
||||||
NEXTAUTH_SECRET=your-secret-key-here-change-in-production
|
NEXTAUTH_SECRET=your-secret-key-here-change-in-production
|
||||||
|
|
||||||
|
# Session cookie scope. Leave EMPTY for local development (browsers reject dotted
|
||||||
|
# domains on localhost). In production set to `.qrmaster.net` so the session is shared
|
||||||
|
# between www.qrmaster.net and app.qrmaster.net. Only honoured when NODE_ENV=production.
|
||||||
|
COOKIE_DOMAIN=
|
||||||
|
|
||||||
|
# Name of the session cookie. Leave empty in production and development (defaults to
|
||||||
|
# `userId`). The staging deployment on testmodul.qrmaster.net must set its own name, e.g.
|
||||||
|
# `userId_test`: production scopes its cookie to .qrmaster.net, so the browser sends it to
|
||||||
|
# every subdomain, and two cookies with the same name make the lookup ambiguous.
|
||||||
|
# Changing this in production logs out every user.
|
||||||
|
AUTH_COOKIE_NAME=
|
||||||
|
|
||||||
|
# Host split: marketing/SEO on WWW, the logged-in app on APP. Keep both pointing at the
|
||||||
|
# same origin locally so nothing redirects across hosts in development.
|
||||||
|
# In production: NEXT_PUBLIC_WWW_URL=https://www.qrmaster.net
|
||||||
|
# NEXT_PUBLIC_APP_URL=https://app.qrmaster.net
|
||||||
|
# NEXT_PUBLIC_WWW_URL must stay on www - it is the origin encoded into downloaded QR
|
||||||
|
# codes and used for public links in emails.
|
||||||
|
NEXT_PUBLIC_WWW_URL=http://localhost:3050
|
||||||
|
NEXT_PUBLIC_APP_URL=http://localhost:3050
|
||||||
|
|
||||||
# OAuth Providers (Optional)
|
# OAuth Providers (Optional)
|
||||||
GOOGLE_CLIENT_ID=
|
GOOGLE_CLIENT_ID=
|
||||||
GOOGLE_CLIENT_SECRET=
|
GOOGLE_CLIENT_SECRET=
|
||||||
@@ -49,10 +70,13 @@ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
|||||||
NEXT_PUBLIC_POSTHOG_KEY=
|
NEXT_PUBLIC_POSTHOG_KEY=
|
||||||
NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com
|
NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com
|
||||||
|
|
||||||
|
# Analytics (Optional - Microsoft Clarity session recordings & heatmaps)
|
||||||
|
NEXT_PUBLIC_CLARITY_PROJECT_ID=
|
||||||
|
|
||||||
# TikTok Content Posting API (Hermes Agent automated posting)
|
# TikTok Content Posting API (Hermes Agent automated posting)
|
||||||
TIKTOK_CLIENT_KEY=
|
TIKTOK_CLIENT_KEY=
|
||||||
TIKTOK_CLIENT_SECRET=
|
TIKTOK_CLIENT_SECRET=
|
||||||
TIKTOK_REDIRECT_URI=https://qrmaster.net/api/tiktok/callback
|
TIKTOK_REDIRECT_URI=https://qrmaster.net/api/tiktok/callback
|
||||||
# Optional: protects /api/tiktok/connect from being triggered by strangers
|
# Optional: protects /api/tiktok/connect from being triggered by strangers
|
||||||
TIKTOK_ADMIN_KEY=
|
TIKTOK_ADMIN_KEY=
|
||||||
TIKTOK_EXPECTED_OPEN_ID=
|
TIKTOK_EXPECTED_OPEN_ID=
|
||||||
|
|||||||
@@ -95,6 +95,11 @@ const nextConfig = {
|
|||||||
destination: '/restaurants',
|
destination: '/restaurants',
|
||||||
permanent: true,
|
permanent: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source: '/blog/qr-code-print-size-guide',
|
||||||
|
destination: '/qr-code-print-size-guide',
|
||||||
|
permanent: true,
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
source: '/create-qr',
|
source: '/create-qr',
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ model User {
|
|||||||
thirtyDayNudgeSentAt DateTime?
|
thirtyDayNudgeSentAt DateTime?
|
||||||
limitReachedNudgeSentAt DateTime?
|
limitReachedNudgeSentAt DateTime?
|
||||||
firstScanNudgeSentAt DateTime?
|
firstScanNudgeSentAt DateTime?
|
||||||
|
qrPulseSentAt DateTime?
|
||||||
|
|
||||||
|
/// When the user last looked at their own scan numbers. A live session is not
|
||||||
|
/// the same as someone having seen a number, so this is what "inactive" means.
|
||||||
|
lastAnalyticsViewAt DateTime?
|
||||||
|
|
||||||
// RevOps attribution
|
// RevOps attribution
|
||||||
signupSource String?
|
signupSource String?
|
||||||
@@ -84,6 +89,13 @@ model User {
|
|||||||
accounts Account[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
lifecycleLogs UserLifecycleLog[]
|
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 {
|
enum Plan {
|
||||||
@@ -143,11 +155,73 @@ model QRCode {
|
|||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
scans QRScan[]
|
scans QRScan[]
|
||||||
|
socialMilestones SocialMilestone[]
|
||||||
|
|
||||||
@@index([userId, createdAt])
|
@@index([userId, createdAt])
|
||||||
@@index([userId, type, status])
|
@@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 {
|
enum QRType {
|
||||||
STATIC
|
STATIC
|
||||||
DYNAMIC
|
DYNAMIC
|
||||||
|
|||||||
114
public/llms.txt
114
public/llms.txt
@@ -1,57 +1,57 @@
|
|||||||
# QR Master
|
# QR Master
|
||||||
|
|
||||||
> QR Master combines 20 free QR code generators and a free barcode generator with dynamic QR codes, scan analytics, bulk generation, and privacy-conscious campaign tracking.
|
> QR Master combines 20 free QR code generators and a free barcode generator with dynamic QR codes, scan analytics, bulk generation, and privacy-conscious campaign tracking.
|
||||||
|
|
||||||
- Primary domain: https://www.qrmaster.net
|
- Primary domain: https://www.qrmaster.net
|
||||||
- 20 free QR code generators plus a free barcode generator; 3 active dynamic QR codes are included on the free plan, with unlimited static codes
|
- 20 free QR code generators plus a free barcode generator; 3 active dynamic QR codes are included on the free plan, with unlimited static codes
|
||||||
- Paid plans add higher dynamic-code limits, advanced analytics, custom branding, and bulk CSV/Excel workflows
|
- Paid plans add higher dynamic-code limits, advanced analytics, custom branding, and bulk CSV/Excel workflows
|
||||||
- Main audience: marketers, restaurants, event teams, retail, and SMB operators
|
- Main audience: marketers, restaurants, event teams, retail, and SMB operators
|
||||||
- Public content is optimized for citation and retrieval by AI search systems
|
- Public content is optimized for citation and retrieval by AI search systems
|
||||||
|
|
||||||
## Core Product Pages
|
## Core Product Pages
|
||||||
|
|
||||||
- [Homepage](https://www.qrmaster.net): Product overview and positioning for dynamic QR codes
|
- [Homepage](https://www.qrmaster.net): Product overview and positioning for dynamic QR codes
|
||||||
- [Pricing](https://www.qrmaster.net/pricing): Plans, limits, and upgrade paths
|
- [Pricing](https://www.qrmaster.net/pricing): Plans, limits, and upgrade paths
|
||||||
- [Dynamic QR Code Generator](https://www.qrmaster.net/dynamic-qr-code-generator): Main page for editable QR codes
|
- [Dynamic QR Code Generator](https://www.qrmaster.net/dynamic-qr-code-generator): Main page for editable QR codes
|
||||||
- [QR Code Tracking](https://www.qrmaster.net/qr-code-tracking): Analytics, scan reporting, and campaign measurement
|
- [QR Code Tracking](https://www.qrmaster.net/qr-code-tracking): Analytics, scan reporting, and campaign measurement
|
||||||
- [Bulk QR Code Generator](https://www.qrmaster.net/bulk-qr-code-generator): High-volume QR creation for CSV and Excel workflows
|
- [Bulk QR Code Generator](https://www.qrmaster.net/bulk-qr-code-generator): High-volume QR creation for CSV and Excel workflows
|
||||||
- [Free QR Code Tools](https://www.qrmaster.net/tools): 20 specialized QR generators for links, contact cards, Wi-Fi, social profiles, payments, meetings, reviews, and more
|
- [Free QR Code Tools](https://www.qrmaster.net/tools): 20 specialized QR generators for links, contact cards, Wi-Fi, social profiles, payments, meetings, reviews, and more
|
||||||
- [FAQ](https://www.qrmaster.net/faq): Direct answers to product, billing, and implementation questions
|
- [FAQ](https://www.qrmaster.net/faq): Direct answers to product, billing, and implementation questions
|
||||||
|
|
||||||
## Cornerstone Guides
|
## Cornerstone Guides
|
||||||
|
|
||||||
- [Dynamic vs Static QR Codes](https://www.qrmaster.net/blog/dynamic-vs-static-qr-codes/raw): Best guide for choosing editable vs fixed QR codes
|
- [Static vs Dynamic QR Codes](https://www.qrmaster.net/blog/static-vs-dynamic-qr-code/raw): Best guide for choosing editable vs fixed QR codes
|
||||||
- [QR Code Tracking Guide](https://www.qrmaster.net/blog/qr-code-tracking-guide-2025/raw): Best guide for analytics, attribution, and ROI measurement
|
- [QR Code Tracking Guide](https://www.qrmaster.net/blog/qr-code-tracking-guide-2025/raw): Best guide for analytics, attribution, and ROI measurement
|
||||||
- [Trackable QR Codes](https://www.qrmaster.net/blog/trackable-qr-codes/raw): Best guide for understanding scan measurement and dynamic redirects
|
- [Trackable QR Codes](https://www.qrmaster.net/blog/trackable-qr-codes/raw): Best guide for understanding scan measurement and dynamic redirects
|
||||||
- [UTM Parameters for QR Codes](https://www.qrmaster.net/blog/utm-parameter-qr-codes/raw): Best guide for campaign attribution in analytics tools
|
- [UTM Parameters for QR Codes](https://www.qrmaster.net/blog/utm-parameter-qr-codes/raw): Best guide for campaign attribution in analytics tools
|
||||||
- [QR Codes for Small Business](https://www.qrmaster.net/blog/qr-code-small-business/raw): Best guide for SMB use cases and buying criteria
|
- [QR Codes for Small Business](https://www.qrmaster.net/blog/qr-code-small-business/raw): Best guide for SMB use cases and buying criteria
|
||||||
|
|
||||||
## Additional Retrieval Guides
|
## Additional Retrieval Guides
|
||||||
|
|
||||||
- [QR Code Scan Statistics 2026](https://www.qrmaster.net/blog/qr-code-scan-statistics-2026/raw): Best guide for which QR statistics are traceable to a named source and which circulate unverified
|
- [QR Code Scan Statistics 2026](https://www.qrmaster.net/blog/qr-code-scan-statistics-2026/raw): Best guide for which QR statistics are traceable to a named source and which circulate unverified
|
||||||
- [QR Code Analytics](https://www.qrmaster.net/qr-code-analytics): Best guide for scan metrics, dashboards, and performance analysis
|
- [QR Code Analytics](https://www.qrmaster.net/qr-code-analytics): Best guide for scan metrics, dashboards, and performance analysis
|
||||||
- [QR Codes for Events](https://www.qrmaster.net/blog/qr-code-events/raw): Best guide for ticketing, check-in workflows, and event ROI tracking
|
- [QR Codes for Events](https://www.qrmaster.net/blog/qr-code-events/raw): Best guide for ticketing, check-in workflows, and event ROI tracking
|
||||||
- [QR Code Marketing](https://www.qrmaster.net/blog/qr-code-marketing/raw): Best guide for campaign strategy, CTAs, placement, and UTM-driven ROI measurement
|
- [QR Code Marketing](https://www.qrmaster.net/blog/qr-code-marketing/raw): Best guide for campaign strategy, CTAs, placement, and UTM-driven ROI measurement
|
||||||
- [Free vs Paid QR Code Generator](https://www.qrmaster.net/blog/free-vs-paid-qr-generator/raw): Best guide for comparing static vs dynamic, tracking, branding, and reliability
|
- [Free vs Paid QR Code Generator](https://www.qrmaster.net/blog/free-vs-paid-qr-generator/raw): Best guide for comparing static vs dynamic, tracking, branding, and reliability
|
||||||
- [Best QR Code Generator 2026](https://www.qrmaster.net/blog/best-qr-code-generator-2026/raw): Best guide for evaluating QR platforms by tracking, API, design, and pricing
|
- [Best QR Code Generator 2026](https://www.qrmaster.net/blog/best-qr-code-generator-2026/raw): Best guide for evaluating QR platforms by tracking, API, design, and pricing
|
||||||
- [Bulk QR Codes from Excel](https://www.qrmaster.net/blog/bulk-qr-code-generator-excel/raw): Best guide for CSV and Excel batch generation workflows
|
- [Bulk QR Codes from Excel](https://www.qrmaster.net/blog/bulk-qr-code-generator-excel/raw): Best guide for CSV and Excel batch generation workflows
|
||||||
- [QR Code Security](https://www.qrmaster.net/blog/qr-code-security/raw): Best guide for quishing risks, verification, and safe QR deployment practices
|
- [QR Code Security](https://www.qrmaster.net/blog/qr-code-security/raw): Best guide for quishing risks, verification, and safe QR deployment practices
|
||||||
|
|
||||||
## Task-Specific Guides
|
## Task-Specific Guides
|
||||||
|
|
||||||
- [Google Review QR Codes](https://www.qrmaster.net/blog/google-review-qr-code/raw): Best guide for finding the direct review link or Place ID, placement at point of payment, and the review gating rule that violates Google policy
|
- [Google Review QR Codes](https://www.qrmaster.net/blog/google-review-qr-code/raw): Best guide for finding the direct review link or Place ID, placement at point of payment, and the review gating rule that violates Google policy
|
||||||
- [Location QR Codes](https://www.qrmaster.net/blog/location-qr-code/raw): Best guide for choosing between geo: URIs, Google Maps URLs and Apple Maps links, including why geo: is unreliable on iOS
|
- [Location QR Codes](https://www.qrmaster.net/blog/location-qr-code/raw): Best guide for choosing between geo: URIs, Google Maps URLs and Apple Maps links, including why geo: is unreliable on iOS
|
||||||
- [Custom QR Codes with Logo](https://www.qrmaster.net/blog/custom-qr-code-design/raw): Best guide for logo coverage limits, untouchable finder patterns, and why luminance contrast matters more than hue
|
- [Custom QR Codes with Logo](https://www.qrmaster.net/blog/custom-qr-code-design/raw): Best guide for logo coverage limits, untouchable finder patterns, and why luminance contrast matters more than hue
|
||||||
- [QR Code Coupons](https://www.qrmaster.net/blog/qr-code-coupons/raw): Best guide for unique single-use codes versus one shared code, redemption tracking, and expiry handling
|
- [QR Code Coupons](https://www.qrmaster.net/blog/qr-code-coupons/raw): Best guide for unique single-use codes versus one shared code, redemption tracking, and expiry handling
|
||||||
- [Feedback QR Codes](https://www.qrmaster.net/blog/feedback-qr-code/raw): Best guide for form length and response rates, timing, and keeping private feedback separate from public review requests
|
- [Feedback QR Codes](https://www.qrmaster.net/blog/feedback-qr-code/raw): Best guide for form length and response rates, timing, and keeping private feedback separate from public review requests
|
||||||
- [Generating QR Codes Programmatically](https://www.qrmaster.net/blog/qr-code-api-documentation/raw): Best guide for generating static codes in Python or Node, batch generation, and why dynamic codes require a hosted redirect
|
- [Generating QR Codes Programmatically](https://www.qrmaster.net/blog/qr-code-api-documentation/raw): Best guide for generating static codes in Python or Node, batch generation, and why dynamic codes require a hosted redirect
|
||||||
- [QR Code Print Size Guide](https://www.qrmaster.net/blog/qr-code-print-size-guide/raw): Best guide for sizing by scan distance, module size, quiet zone, and error correction levels
|
- [QR Code Print Size Guide](https://www.qrmaster.net/blog/qr-code-print-size-guide/raw): Best guide for sizing by scan distance, module size, quiet zone, and error correction levels
|
||||||
- [WhatsApp QR Codes](https://www.qrmaster.net/blog/whatsapp-qr-code-generator/raw): Best guide for the wa.me number format, pre-filled messages, and URL encoding
|
- [WhatsApp QR Codes](https://www.qrmaster.net/blog/whatsapp-qr-code-generator/raw): Best guide for the wa.me number format, pre-filled messages, and URL encoding
|
||||||
- [vCard QR Codes](https://www.qrmaster.net/blog/vcard-qr-code-generator/raw): Best guide for vCard 3.0 field selection, international phone formats, and keeping the grid printable
|
- [vCard QR Codes](https://www.qrmaster.net/blog/vcard-qr-code-generator/raw): Best guide for vCard 3.0 field selection, international phone formats, and keeping the grid printable
|
||||||
- [Barcode Generator Guide](https://www.qrmaster.net/blog/barcode-generator-tool/raw): Best guide for choosing between EAN-13, UPC-A and Code 128, and why retail barcodes require GS1 numbers
|
- [Barcode Generator Guide](https://www.qrmaster.net/blog/barcode-generator-tool/raw): Best guide for choosing between EAN-13, UPC-A and Code 128, and why retail barcodes require GS1 numbers
|
||||||
|
|
||||||
## Additional Context
|
## Additional Context
|
||||||
|
|
||||||
- [Blog Index](https://www.qrmaster.net/blog): All published QR marketing and implementation guides
|
- [Blog Index](https://www.qrmaster.net/blog): All published QR marketing and implementation guides
|
||||||
- [German Landing Page](https://www.qrmaster.net/qr-code-erstellen): Main German-language marketing page
|
- [German Landing Page](https://www.qrmaster.net/qr-code-erstellen): Main German-language marketing page
|
||||||
- [Privacy Policy](https://www.qrmaster.net/privacy): Privacy and data handling information
|
- [Privacy Policy](https://www.qrmaster.net/privacy): Privacy and data handling information
|
||||||
|
|||||||
5
public/robots-app.txt
Normal file
5
public/robots-app.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Served at app.qrmaster.net/robots.txt via a middleware rewrite.
|
||||||
|
# The app host holds only the logged-in application - all indexable content lives on
|
||||||
|
# www.qrmaster.net, so nothing here should ever enter a search index.
|
||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
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 { trackEvent, identifyUser } from '@/components/PostHogProvider';
|
||||||
import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans';
|
import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans';
|
||||||
import { OnboardingChecklist } from '@/components/dashboard/OnboardingChecklist';
|
import { OnboardingChecklist } from '@/components/dashboard/OnboardingChecklist';
|
||||||
|
import { SocialMilestoneDialog } from '@/components/dashboard/SocialMilestoneDialog';
|
||||||
|
|
||||||
interface QRCodeData {
|
interface QRCodeData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -322,6 +323,7 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<SocialMilestoneDialog />
|
||||||
{/* Header with Plan Badge */}
|
{/* Header with Plan Badge */}
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|||||||
@@ -10,11 +10,51 @@ import ChangePasswordModal from '@/components/settings/ChangePasswordModal';
|
|||||||
|
|
||||||
type TabType = 'profile' | 'subscription';
|
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() {
|
export default function SettingsPage() {
|
||||||
const { fetchWithCsrf } = useCsrf();
|
const { fetchWithCsrf } = useCsrf();
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('profile');
|
const [activeTab, setActiveTab] = useState<TabType>('profile');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||||
|
const [socialPromptsEnabled, setSocialPromptsEnabled] = useState(true);
|
||||||
|
const [socialTestResetAvailable, setSocialTestResetAvailable] = useState(false);
|
||||||
|
const [socialSaving, setSocialSaving] = useState(false);
|
||||||
|
const [milestones, setMilestones] = useState<MilestoneHistoryItem[]>([]);
|
||||||
|
const [milestoneBusy, setMilestoneBusy] = useState<string | null>(null);
|
||||||
|
|
||||||
// Profile states
|
// Profile states
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
@@ -49,10 +89,23 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
// Fetch usage stats from API
|
// Fetch usage stats from API
|
||||||
const statsResponse = await fetch('/api/user/stats');
|
const statsResponse = await fetch('/api/user/stats');
|
||||||
if (statsResponse.ok) {
|
if (statsResponse.ok) {
|
||||||
const data = await statsResponse.json();
|
const data = await statsResponse.json();
|
||||||
setUsageStats(data);
|
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) {
|
} catch (e) {
|
||||||
console.error('Failed to load user data:', e);
|
console.error('Failed to load user data:', e);
|
||||||
}
|
}
|
||||||
@@ -92,8 +145,50 @@ export default function SettingsPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSocialPrompts = async (action: 'enable' | 'disable' | 'reset_test') => {
|
||||||
|
setSocialSaving(true);
|
||||||
|
try {
|
||||||
|
const response = await fetchWithCsrf('/api/social-milestones/preferences', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ action }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.error || 'Could not update milestone prompts');
|
||||||
|
setSocialPromptsEnabled(data.promptsEnabled !== false);
|
||||||
|
showToast(action === 'reset_test' ? 'Milestone test reset. Open the dashboard to test it again.' : 'Milestone preference updated.', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error instanceof Error ? error.message : 'Could not update milestone prompts', 'error');
|
||||||
|
} finally {
|
||||||
|
setSocialSaving(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const 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 () => {
|
const handleManageSubscription = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
@@ -245,9 +340,78 @@ export default function SettingsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Security */}
|
<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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Security</CardTitle>
|
<CardTitle>Security</CardTitle>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/Button';
|
|||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { useCsrf } from '@/hooks/useCsrf';
|
import { useCsrf } from '@/hooks/useCsrf';
|
||||||
import { appendRedirectParam, sanitizeRedirectPath } from '@/lib/auth-flow';
|
import { appendRedirectParam, sanitizeRedirectPath } from '@/lib/auth-flow';
|
||||||
|
import { needsHostChange, urlForPath } from '@/lib/hosts';
|
||||||
|
|
||||||
type LoginClientProps = {
|
type LoginClientProps = {
|
||||||
showPageHeading?: boolean;
|
showPageHeading?: boolean;
|
||||||
@@ -63,6 +64,15 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps
|
|||||||
const redirectUrl = data.needsOnboarding
|
const redirectUrl = data.needsOnboarding
|
||||||
? appendRedirectParam('/onboarding', redirectTarget)
|
? appendRedirectParam('/onboarding', redirectTarget)
|
||||||
: (redirectTarget || '/dashboard');
|
: (redirectTarget || '/dashboard');
|
||||||
|
|
||||||
|
// Login lives on the marketing host, the app on app.*. The router cannot
|
||||||
|
// push across origins, so a host change needs a full load. The session
|
||||||
|
// cookie is shared via COOKIE_DOMAIN, so the user arrives signed in.
|
||||||
|
if (needsHostChange(redirectUrl)) {
|
||||||
|
window.location.assign(urlForPath(redirectUrl));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
router.push(redirectUrl);
|
router.push(redirectUrl);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/Button';
|
|||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { useCsrf } from '@/hooks/useCsrf';
|
import { useCsrf } from '@/hooks/useCsrf';
|
||||||
import { appendRedirectParam, sanitizeRedirectPath } from '@/lib/auth-flow';
|
import { appendRedirectParam, sanitizeRedirectPath } from '@/lib/auth-flow';
|
||||||
|
import { needsHostChange, urlForPath } from '@/lib/hosts';
|
||||||
|
|
||||||
export default function SignupClient() {
|
export default function SignupClient() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -48,15 +49,15 @@ export default function SignupClient() {
|
|||||||
body: JSON.stringify({ name, email, password }),
|
body: JSON.stringify({ name, email, password }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (response.ok && data.success) {
|
if (response.ok && data.success) {
|
||||||
if (data.requiresEmailVerification) {
|
if (data.requiresEmailVerification) {
|
||||||
router.push(`/verify-email?email=${encodeURIComponent(data.email)}`);
|
router.push(`/verify-email?email=${encodeURIComponent(data.email)}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store user in localStorage for client-side
|
// Store user in localStorage for client-side
|
||||||
localStorage.setItem('user', JSON.stringify(data.user));
|
localStorage.setItem('user', JSON.stringify(data.user));
|
||||||
|
|
||||||
// Track successful signup with PostHog
|
// Track successful signup with PostHog
|
||||||
@@ -76,8 +77,16 @@ export default function SignupClient() {
|
|||||||
console.error('PostHog tracking error:', error);
|
console.error('PostHog tracking error:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to onboarding
|
// Redirect to onboarding - which lives on the app host, so this normally
|
||||||
router.push(appendRedirectParam('/onboarding', redirectTarget));
|
// crosses the host boundary and cannot go through the router.
|
||||||
|
const onboardingUrl = appendRedirectParam('/onboarding', redirectTarget);
|
||||||
|
|
||||||
|
if (needsHostChange(onboardingUrl)) {
|
||||||
|
window.location.assign(urlForPath(onboardingUrl));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(onboardingUrl);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
setError(data.error || 'Failed to create account');
|
setError(data.error || 'Failed to create account');
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default function CookiePolicyPage() {
|
|||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">How We Use Cookies</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">How We Use Cookies</h2>
|
||||||
<ul className="list-disc pl-6 space-y-2">
|
<ul className="list-disc pl-6 space-y-2">
|
||||||
<li><strong>Essential Cookies:</strong> Required for the website to function (e.g., login, session management).</li>
|
<li><strong>Essential Cookies:</strong> Required for the website to function (e.g., login, session management).</li>
|
||||||
<li><strong>Analytics Cookies (Optional):</strong> We use tools like PostHog to understand how you use the site and improve it. These are only set with your consent.</li>
|
<li><strong>Analytics Cookies (Optional):</strong> We use tools like PostHog and Microsoft Clarity (session recordings, heatmaps) to understand how you use the site and improve it. These are only set with your consent.</li>
|
||||||
<li><strong>Functionality Cookies:</strong> To remember your preferences.</li>
|
<li><strong>Functionality Cookies:</strong> To remember your preferences.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Github,
|
Github,
|
||||||
Package,
|
Package,
|
||||||
TerminalSquare,
|
TerminalSquare,
|
||||||
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -20,7 +21,17 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const tools = [
|
type DeveloperTool = {
|
||||||
|
title: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
description: string;
|
||||||
|
command: string;
|
||||||
|
linkLabel: string;
|
||||||
|
url: string;
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tools: DeveloperTool[] = [
|
||||||
{
|
{
|
||||||
title: 'VS Code / Cursor / Windsurf Extension',
|
title: 'VS Code / Cursor / Windsurf Extension',
|
||||||
icon: Code2,
|
icon: Code2,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function PrivacyPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">Privacy Policy</h1>
|
<h1 className="text-4xl font-bold text-gray-900 mb-4">Privacy Policy</h1>
|
||||||
<p className="text-gray-600 mb-8">Last updated: August 2026</p>
|
<p className="text-gray-600 mb-8">Last updated: August 2026</p>
|
||||||
|
|
||||||
<div className="prose prose-lg max-w-none">
|
<div className="prose prose-lg max-w-none">
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
@@ -38,20 +38,20 @@ export default function PrivacyPage() {
|
|||||||
We implement appropriate security measures including secure HTTPS transmission, password hashing, database access controls,
|
We implement appropriate security measures including secure HTTPS transmission, password hashing, database access controls,
|
||||||
and CSRF protection to keep your data safe.
|
and CSRF protection to keep your data safe.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-8">
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">2. Chrome Extension</h2>
|
|
||||||
<p className="text-gray-700 mb-4">
|
|
||||||
The QR Master: QR Code for Current Tab Chrome extension processes the active tab URL and the URL, text, Wi-Fi, or vCard details you enter only to create a QR code in the extension popup. This information is processed locally in your browser, is not stored by the extension, and is not transmitted to QR Master or third parties.
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700 mb-4">
|
|
||||||
The extension uses clipboard access only after you select Copy Image, so that it can copy the generated QR code image to your device clipboard. We do not sell, share, or use extension input data for advertising, analytics, profiling, or creditworthiness decisions.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">3. Information We Collect</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">2. Chrome Extension</h2>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
The QR Master: QR Code for Current Tab Chrome extension processes the active tab URL and the URL, text, Wi-Fi, or vCard details you enter only to create a QR code in the extension popup. This information is processed locally in your browser, is not stored by the extension, and is not transmitted to QR Master or third parties.
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
The extension uses clipboard access only after you select Copy Image, so that it can copy the generated QR code image to your device clipboard. We do not sell, share, or use extension input data for advertising, analytics, profiling, or creditworthiness decisions.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">3. Information We Collect</h2>
|
||||||
|
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Information You Provide</h3>
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">Information You Provide</h3>
|
||||||
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
@@ -64,12 +64,12 @@ export default function PrivacyPage() {
|
|||||||
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
<li><strong>Usage Data:</strong> QR code scans and analytics</li>
|
<li><strong>Usage Data:</strong> QR code scans and analytics</li>
|
||||||
<li><strong>Technical Data:</strong> IP address, browser type, and device information</li>
|
<li><strong>Technical Data:</strong> IP address, browser type, and device information</li>
|
||||||
<li><strong>Cookies:</strong> Essential cookies for authentication and optional analytics cookies (PostHog) with your consent</li>
|
<li><strong>Cookies:</strong> Essential cookies for authentication and optional analytics cookies (PostHog, Microsoft Clarity) with your consent</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">4. How We Use Your Information</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">4. How We Use Your Information</h2>
|
||||||
<p className="text-gray-700 mb-4">We use your data to:</p>
|
<p className="text-gray-700 mb-4">We use your data to:</p>
|
||||||
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
<li>Provide and maintain our QR code services</li>
|
<li>Provide and maintain our QR code services</li>
|
||||||
@@ -85,11 +85,12 @@ export default function PrivacyPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">5. Data Sharing</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">5. Data Sharing</h2>
|
||||||
<p className="text-gray-700 mb-4">We may share your data with:</p>
|
<p className="text-gray-700 mb-4">We may share your data with:</p>
|
||||||
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
<li><strong>Stripe:</strong> Payment processing</li>
|
<li><strong>Stripe:</strong> Payment processing</li>
|
||||||
<li><strong>PostHog:</strong> Analytics (only with your consent, respects Do Not Track)</li>
|
<li><strong>PostHog:</strong> Analytics (only with your consent, respects Do Not Track)</li>
|
||||||
|
<li><strong>Microsoft Clarity:</strong> Session recordings and heatmaps (only with your consent)</li>
|
||||||
<li><strong>Vercel:</strong> Cloud hosting provider</li>
|
<li><strong>Vercel:</strong> Cloud hosting provider</li>
|
||||||
<li><strong>Legal Requirements:</strong> When required by law</li>
|
<li><strong>Legal Requirements:</strong> When required by law</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -99,7 +100,7 @@ export default function PrivacyPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">6. Your Rights (GDPR)</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">6. Your Rights (GDPR)</h2>
|
||||||
<p className="text-gray-700 mb-4">You have the right to:</p>
|
<p className="text-gray-700 mb-4">You have the right to:</p>
|
||||||
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
<li><strong>Access:</strong> Request a copy of your personal data</li>
|
<li><strong>Access:</strong> Request a copy of your personal data</li>
|
||||||
@@ -122,7 +123,7 @@ export default function PrivacyPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">7. Contact Us</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">7. Contact Us</h2>
|
||||||
<p className="text-gray-700 mb-4">
|
<p className="text-gray-700 mb-4">
|
||||||
If you have questions about this privacy policy, please contact us:
|
If you have questions about this privacy policy, please contact us:
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -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." },
|
"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." },
|
"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." },
|
"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> = {
|
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>;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { getSessionUserId } from '@/lib/session';
|
import { getSessionUserId } from '@/lib/session';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||||
|
import { touchAnalyticsView } from '@/lib/analyticsActivity';
|
||||||
import { TrendData } from '@/types/analytics';
|
import { TrendData } from '@/types/analytics';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -67,6 +68,10 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Serves both the analytics page and the dashboard, so this is the one
|
||||||
|
// place that knows the user actually saw their numbers. Fire and forget.
|
||||||
|
touchAnalyticsView(userId);
|
||||||
|
|
||||||
// Get date range from query params (default: last 30 days)
|
// Get date range from query params (default: last 30 days)
|
||||||
const { searchParams } = request.nextUrl;
|
const { searchParams } = request.nextUrl;
|
||||||
const range = searchParams.get('range') || '30';
|
const range = searchParams.get('range') || '30';
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
import {
|
||||||
|
appendExpiredCookies,
|
||||||
|
getAuthCookieName,
|
||||||
|
getAuthCookieOptions,
|
||||||
|
getCookieDomain,
|
||||||
|
getFlowCookieOptions,
|
||||||
|
} from '@/lib/cookieConfig';
|
||||||
|
import { appUrl, urlForPath, wwwUrl } from '@/lib/hosts';
|
||||||
import { signUserId } from '@/lib/session';
|
import { signUserId } from '@/lib/session';
|
||||||
import {
|
import {
|
||||||
appendRedirectParam,
|
appendRedirectParam,
|
||||||
@@ -16,8 +23,6 @@ import {
|
|||||||
} from '@/lib/revops';
|
} from '@/lib/revops';
|
||||||
import { triggerLifecycleScoring } from '@/lib/revops-server';
|
import { triggerLifecycleScoring } from '@/lib/revops-server';
|
||||||
|
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const code = searchParams.get('code');
|
const code = searchParams.get('code');
|
||||||
@@ -37,7 +42,7 @@ export async function GET(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`;
|
const redirectUri = appUrl('/api/auth/google');
|
||||||
const scope = 'openid email profile';
|
const scope = 'openid email profile';
|
||||||
const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect'));
|
const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect'));
|
||||||
const oauthState = crypto.randomUUID();
|
const oauthState = crypto.randomUUID();
|
||||||
@@ -50,24 +55,16 @@ export async function GET(request: NextRequest) {
|
|||||||
googleAuthUrl.searchParams.set('state', oauthState);
|
googleAuthUrl.searchParams.set('state', oauthState);
|
||||||
|
|
||||||
const response = NextResponse.redirect(googleAuthUrl);
|
const response = NextResponse.redirect(googleAuthUrl);
|
||||||
response.cookies.set(GOOGLE_OAUTH_STATE_COOKIE_NAME, oauthState, {
|
response.cookies.set(GOOGLE_OAUTH_STATE_COOKIE_NAME, oauthState, getFlowCookieOptions(60 * 10));
|
||||||
httpOnly: true,
|
|
||||||
secure: isProduction,
|
|
||||||
sameSite: 'lax',
|
|
||||||
path: '/',
|
|
||||||
maxAge: 60 * 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (redirectTarget) {
|
if (redirectTarget) {
|
||||||
response.cookies.set(POST_AUTH_REDIRECT_COOKIE_NAME, redirectTarget, {
|
response.cookies.set(POST_AUTH_REDIRECT_COOKIE_NAME, redirectTarget, getFlowCookieOptions(60 * 10));
|
||||||
httpOnly: true,
|
|
||||||
secure: isProduction,
|
|
||||||
sameSite: 'lax',
|
|
||||||
path: '/',
|
|
||||||
maxAge: 60 * 10,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
response.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
|
response.cookies.delete({
|
||||||
|
name: POST_AUTH_REDIRECT_COOKIE_NAME,
|
||||||
|
path: '/',
|
||||||
|
domain: getCookieDomain(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
@@ -77,10 +74,10 @@ export async function GET(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
if (!state || !savedOauthState || state !== savedOauthState) {
|
if (!state || !savedOauthState || state !== savedOauthState) {
|
||||||
const invalidStateResponse = NextResponse.redirect(
|
const invalidStateResponse = NextResponse.redirect(
|
||||||
`${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-state-invalid`
|
wwwUrl('/login?error=google-state-invalid')
|
||||||
);
|
);
|
||||||
invalidStateResponse.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME);
|
invalidStateResponse.cookies.delete({ name: GOOGLE_OAUTH_STATE_COOKIE_NAME, path: '/', domain: getCookieDomain() });
|
||||||
invalidStateResponse.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
|
invalidStateResponse.cookies.delete({ name: POST_AUTH_REDIRECT_COOKIE_NAME, path: '/', domain: getCookieDomain() });
|
||||||
return invalidStateResponse;
|
return invalidStateResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +91,7 @@ export async function GET(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`;
|
const redirectUri = appUrl('/api/auth/google');
|
||||||
|
|
||||||
// Exchange code for tokens
|
// Exchange code for tokens
|
||||||
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
|
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
|
||||||
@@ -225,21 +222,24 @@ export async function GET(request: NextRequest) {
|
|||||||
authMethod: 'google',
|
authMethod: 'google',
|
||||||
isNewUser: isNewUser.toString(),
|
isNewUser: isNewUser.toString(),
|
||||||
}));
|
}));
|
||||||
const redirectUrl = new URL(`${process.env.NEXT_PUBLIC_APP_URL}${onboardingTarget}`);
|
const redirectUrl = new URL(urlForPath(onboardingTarget));
|
||||||
|
|
||||||
const response = NextResponse.redirect(redirectUrl.toString());
|
const response = NextResponse.redirect(redirectUrl.toString());
|
||||||
response.cookies.set('userId', signUserId(user.id), getAuthCookieOptions());
|
response.cookies.set(getAuthCookieName(), signUserId(user.id), getAuthCookieOptions());
|
||||||
response.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME);
|
response.cookies.delete({ name: GOOGLE_OAUTH_STATE_COOKIE_NAME, path: '/', domain: getCookieDomain() });
|
||||||
response.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
|
response.cookies.delete({ name: POST_AUTH_REDIRECT_COOKIE_NAME, path: '/', domain: getCookieDomain() });
|
||||||
response.cookies.delete(ATTRIBUTION_COOKIE_NAME);
|
// Must stay after the last cookies.set()/delete() call - see appendExpiredCookies.
|
||||||
|
// The attribution cookie lives 90 days, so a pre-COOKIE_DOMAIN host-only copy can
|
||||||
|
// still be around and has to be expired alongside the domain-scoped one.
|
||||||
|
appendExpiredCookies(response.headers, [{ name: ATTRIBUTION_COOKIE_NAME, httpOnly: false }]);
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Google OAuth error:', error);
|
console.error('Google OAuth error:', error);
|
||||||
const errorResponse = NextResponse.redirect(
|
const errorResponse = NextResponse.redirect(
|
||||||
`${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-signin-failed`
|
wwwUrl('/login?error=google-signin-failed')
|
||||||
);
|
);
|
||||||
errorResponse.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME);
|
errorResponse.cookies.delete({ name: GOOGLE_OAUTH_STATE_COOKIE_NAME, path: '/', domain: getCookieDomain() });
|
||||||
errorResponse.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
|
errorResponse.cookies.delete({ name: POST_AUTH_REDIRECT_COOKIE_NAME, path: '/', domain: getCookieDomain() });
|
||||||
return errorResponse;
|
return errorResponse;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,18 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { ATTRIBUTION_COOKIE_NAME } from '@/lib/revops';
|
import { ATTRIBUTION_COOKIE_NAME } from '@/lib/revops';
|
||||||
|
import { appendExpiredCookies, getAuthCookieName } from '@/lib/cookieConfig';
|
||||||
export async function POST() {
|
|
||||||
const response = NextResponse.json({ success: true });
|
export async function POST() {
|
||||||
|
const response = NextResponse.json({ success: true });
|
||||||
response.cookies.set('userId', '', {
|
|
||||||
httpOnly: true,
|
// Deliberately not using response.cookies.set() here: it is keyed by cookie name, so
|
||||||
secure: process.env.NODE_ENV === 'production',
|
// it can only ever emit one variant per cookie. Logout has to expire both the
|
||||||
sameSite: 'lax',
|
// host-only and the domain-scoped variant (see appendExpiredCookies).
|
||||||
path: '/',
|
appendExpiredCookies(response.headers, [
|
||||||
maxAge: 0,
|
{ name: getAuthCookieName(), httpOnly: true },
|
||||||
});
|
{ name: 'newsletter-admin', httpOnly: true },
|
||||||
response.cookies.set('newsletter-admin', '', {
|
{ name: ATTRIBUTION_COOKIE_NAME, httpOnly: false },
|
||||||
httpOnly: true,
|
]);
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: 'lax',
|
return response;
|
||||||
path: '/',
|
}
|
||||||
maxAge: 0,
|
|
||||||
});
|
|
||||||
response.cookies.set(ATTRIBUTION_COOKIE_NAME, '', {
|
|
||||||
httpOnly: false,
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: 'lax',
|
|
||||||
path: '/',
|
|
||||||
maxAge: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { wwwUrl } from '@/lib/hosts';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
@@ -17,7 +18,9 @@ import { triggerLifecycleScoring } from '@/lib/revops-server';
|
|||||||
|
|
||||||
async function issueVerificationEmail(user: { email: string; name: string | null }) {
|
async function issueVerificationEmail(user: { email: string; name: string | null }) {
|
||||||
const verificationToken = crypto.randomBytes(32).toString('base64url');
|
const verificationToken = crypto.randomBytes(32).toString('base64url');
|
||||||
const verificationUrl = new URL('/api/auth/verify-email', process.env.NEXT_PUBLIC_APP_URL || 'https://www.qrmaster.net');
|
// Public link in an outgoing email, so it points at the marketing host. The endpoint
|
||||||
|
// itself is served on both hosts and redirects into the app afterwards.
|
||||||
|
const verificationUrl = new URL(wwwUrl('/api/auth/verify-email'));
|
||||||
verificationUrl.searchParams.set('token', verificationToken);
|
verificationUrl.searchParams.set('token', verificationToken);
|
||||||
|
|
||||||
await db.verificationToken.deleteMany({ where: { identifier: user.email } });
|
await db.verificationToken.deleteMany({ where: { identifier: user.email } });
|
||||||
@@ -147,7 +150,7 @@ export async function POST(request: NextRequest) {
|
|||||||
fbc: request.cookies.get('_fbc')?.value,
|
fbc: request.cookies.get('_fbc')?.value,
|
||||||
fbp: request.cookies.get('_fbp')?.value,
|
fbp: request.cookies.get('_fbp')?.value,
|
||||||
},
|
},
|
||||||
eventSourceUrl: `${process.env.NEXT_PUBLIC_APP_URL}/signup`,
|
eventSourceUrl: wwwUrl('/signup'),
|
||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
|
|
||||||
// Create response
|
// Create response
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
import { getAuthCookieName, getAuthCookieOptions } from '@/lib/cookieConfig';
|
||||||
import { signUserId } from '@/lib/session';
|
import { signUserId } from '@/lib/session';
|
||||||
import { sendWelcomeEmail } from '@/lib/email';
|
import { sendWelcomeEmail } from '@/lib/email';
|
||||||
|
import { appUrl, wwwUrl } from '@/lib/hosts';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const token = new URL(request.url).searchParams.get('token');
|
const token = new URL(request.url).searchParams.get('token');
|
||||||
const publicAppUrl = process.env.NEXT_PUBLIC_APP_URL || new URL(request.url).origin;
|
// /verify-email is a public page on the marketing host; /onboarding lives on the app
|
||||||
const expiredUrl = new URL('/verify-email?status=expired', publicAppUrl);
|
// host. The session cookie is shared across both, so the user stays signed in.
|
||||||
|
const expiredUrl = new URL(wwwUrl('/verify-email?status=expired'));
|
||||||
|
|
||||||
if (!token) return NextResponse.redirect(expiredUrl);
|
if (!token) return NextResponse.redirect(expiredUrl);
|
||||||
|
|
||||||
@@ -34,7 +36,7 @@ export async function GET(request: NextRequest) {
|
|||||||
console.error('Welcome email after verification failed:', error);
|
console.error('Welcome email after verification failed:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = NextResponse.redirect(new URL('/onboarding?email_verified=1', publicAppUrl));
|
const response = NextResponse.redirect(appUrl('/onboarding?email_verified=1'));
|
||||||
response.cookies.set('userId', signUserId(user.id), getAuthCookieOptions());
|
response.cookies.set(getAuthCookieName(), signUserId(user.id), getAuthCookieOptions());
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ 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 =
|
const provided =
|
||||||
request.headers.get('x-admin-key') || request.nextUrl.searchParams.get('key');
|
request.headers.get('x-admin-key') || request.nextUrl.searchParams.get('key');
|
||||||
if (!adminKey || provided !== adminKey) {
|
if (!adminKey || provided !== adminKey) {
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import { db } from '@/lib/db';
|
|||||||
// are served from qrmaster.net via GET /api/social-assets/[id].
|
// are served from qrmaster.net via GET /api/social-assets/[id].
|
||||||
|
|
||||||
const isAdminRequest = (request: NextRequest) => {
|
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;
|
if (!adminKey) return false;
|
||||||
const provided =
|
const provided =
|
||||||
request.headers.get('x-admin-key') || request.nextUrl.searchParams.get('key');
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { stripe } from '@/lib/stripe';
|
import { stripe } from '@/lib/stripe';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
|
import { appUrl, wwwUrl } from '@/lib/hosts';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -53,15 +54,16 @@ export async function POST(request: NextRequest) {
|
|||||||
customer: customerId,
|
customer: customerId,
|
||||||
mode: 'subscription',
|
mode: 'subscription',
|
||||||
payment_method_types: ['card'],
|
payment_method_types: ['card'],
|
||||||
allow_promotion_codes: true,
|
allow_promotion_codes: true,
|
||||||
line_items: [
|
line_items: [
|
||||||
{
|
{
|
||||||
price: priceId,
|
price: priceId,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
|
// /dashboard is on the app host, /pricing on the marketing host.
|
||||||
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`,
|
success_url: appUrl('/dashboard?success=true'),
|
||||||
|
cancel_url: wwwUrl('/pricing?canceled=true'),
|
||||||
metadata: {
|
metadata: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
plan,
|
plan,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { stripe, STRIPE_PLANS } from '@/lib/stripe';
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { getSessionUserId } from '@/lib/session';
|
import { getSessionUserId } from '@/lib/session';
|
||||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||||
|
import { urlForPath } from '@/lib/hosts';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -109,7 +110,11 @@ export async function POST(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin;
|
// Resolved per path rather than against a single origin: safeReturnPath comes from an
|
||||||
|
// in-app page (/dashboard, /upgrade, ...) and belongs on the app host, while the
|
||||||
|
// default cancel target /pricing belongs on the marketing host.
|
||||||
|
const withParam = (path: string, param: string) =>
|
||||||
|
`${path}${path.includes('?') ? '&' : '?'}${param}`;
|
||||||
|
|
||||||
// Create Stripe Checkout Session
|
// Create Stripe Checkout Session
|
||||||
const checkoutSession = await stripe.checkout.sessions.create({
|
const checkoutSession = await stripe.checkout.sessions.create({
|
||||||
@@ -123,12 +128,13 @@ export async function POST(request: NextRequest) {
|
|||||||
quantity: 1,
|
quantity: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
success_url: safeReturnPath
|
success_url: urlForPath(
|
||||||
? `${appUrl}${safeReturnPath}${safeReturnPath.includes('?') ? '&' : '?'}success=true&session_id={CHECKOUT_SESSION_ID}`
|
withParam(
|
||||||
: `${appUrl}/dashboard?success=true&session_id={CHECKOUT_SESSION_ID}`,
|
safeReturnPath || '/dashboard',
|
||||||
cancel_url: safeReturnPath
|
'success=true&session_id={CHECKOUT_SESSION_ID}'
|
||||||
? `${appUrl}${safeReturnPath}${safeReturnPath.includes('?') ? '&' : '?'}canceled=true`
|
)
|
||||||
: `${appUrl}/pricing?canceled=true`,
|
),
|
||||||
|
cancel_url: urlForPath(withParam(safeReturnPath || '/pricing', 'canceled=true')),
|
||||||
metadata: {
|
metadata: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
plan,
|
plan,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getSessionUserId } from '@/lib/session';
|
|||||||
import { stripe } from '@/lib/stripe';
|
import { stripe } from '@/lib/stripe';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||||
|
import { appUrl } from '@/lib/hosts';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -56,7 +57,7 @@ export async function POST(request: NextRequest) {
|
|||||||
// Create Stripe Customer Portal session
|
// Create Stripe Customer Portal session
|
||||||
const portalSession = await stripe.billingPortal.sessions.create({
|
const portalSession = await stripe.billingPortal.sessions.create({
|
||||||
customer: user.stripeCustomerId,
|
customer: user.stripeCustomerId,
|
||||||
return_url: `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/settings`,
|
return_url: appUrl('/settings'),
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ url: portalSession.url });
|
return NextResponse.json({ url: portalSession.url });
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import { wwwUrl } from '@/lib/hosts';
|
||||||
import {
|
import {
|
||||||
assertExpectedTiktokAccount,
|
assertExpectedTiktokAccount,
|
||||||
TIKTOK_ACCOUNT_KEY,
|
TIKTOK_ACCOUNT_KEY,
|
||||||
@@ -39,8 +40,10 @@ export async function GET(request: NextRequest) {
|
|||||||
return textResponse('TikTok client credentials are not configured.', 500);
|
return textResponse('TikTok client credentials are not configured.', 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Must match the URI used in /api/tiktok/connect - see the note there about the
|
||||||
|
// verified domain.
|
||||||
const redirectUri =
|
const redirectUri =
|
||||||
process.env.TIKTOK_REDIRECT_URI || `${process.env.NEXT_PUBLIC_APP_URL}/api/tiktok/callback`;
|
process.env.TIKTOK_REDIRECT_URI || wwwUrl('/api/tiktok/callback');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tokenResponse = await fetch('https://open.tiktokapis.com/v2/oauth/token/', {
|
const tokenResponse = await fetch('https://open.tiktokapis.com/v2/oauth/token/', {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { TIKTOK_OAUTH_STATE_COOKIE_NAME } from '@/lib/tiktok';
|
import { TIKTOK_OAUTH_STATE_COOKIE_NAME } from '@/lib/tiktok';
|
||||||
|
import { wwwUrl } from '@/lib/hosts';
|
||||||
|
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
@@ -17,8 +18,10 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'TIKTOK_CLIENT_KEY not configured' }, { status: 500 });
|
return NextResponse.json({ error: 'TIKTOK_CLIENT_KEY not configured' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Falls back to the marketing host on purpose: TikTok only accepts callbacks on the
|
||||||
|
// verified domain, and app.qrmaster.net is not verified with them.
|
||||||
const redirectUri =
|
const redirectUri =
|
||||||
process.env.TIKTOK_REDIRECT_URI || `${process.env.NEXT_PUBLIC_APP_URL}/api/tiktok/callback`;
|
process.env.TIKTOK_REDIRECT_URI || wwwUrl('/api/tiktok/callback');
|
||||||
|
|
||||||
const oauthState = crypto.randomUUID();
|
const oauthState = crypto.randomUUID();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { appendExpiredCookies, getAuthCookieName } from '@/lib/cookieConfig';
|
||||||
import { getSessionUserId } from '@/lib/session';
|
import { getSessionUserId } from '@/lib/session';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { stripe } from '@/lib/stripe';
|
import { stripe } from '@/lib/stripe';
|
||||||
@@ -73,10 +73,13 @@ export async function DELETE(request: NextRequest) {
|
|||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear auth cookie
|
// Clear auth cookie. Same reasoning as the logout route: both the host-only and the
|
||||||
cookies().delete('userId');
|
// domain-scoped variant have to be expired, otherwise the survivor keeps a session
|
||||||
|
// pointing at a user row that no longer exists.
|
||||||
|
const response = NextResponse.json({ success: true });
|
||||||
|
appendExpiredCookies(response.headers, [{ name: getAuthCookieName(), httpOnly: true }]);
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting account:', error);
|
console.error('Error deleting account:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
import Script from "next/script";
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import '@/styles/globals.css';
|
import '@/styles/globals.css';
|
||||||
import { Providers } from '@/components/Providers';
|
import { Providers } from '@/components/Providers';
|
||||||
import AdSenseScript from '@/components/ads/AdSenseScript';
|
import AdSenseScript from '@/components/ads/AdSenseScript';
|
||||||
import FacebookPixel from '@/components/analytics/FacebookPixel';
|
import FacebookPixel from '@/components/analytics/FacebookPixel';
|
||||||
|
import MicrosoftClarity from '@/components/analytics/MicrosoftClarity';
|
||||||
|
|
||||||
const isIndexable = process.env.NEXT_PUBLIC_INDEXABLE === 'true';
|
const isIndexable = process.env.NEXT_PUBLIC_INDEXABLE === 'true';
|
||||||
|
|
||||||
@@ -20,6 +22,7 @@ export const metadata: Metadata = {
|
|||||||
: { index: false, follow: false },
|
: { index: false, follow: false },
|
||||||
icons: {
|
icons: {
|
||||||
icon: [
|
icon: [
|
||||||
|
|
||||||
{ url: '/favicon.svg', type: 'image/svg+xml' },
|
{ url: '/favicon.svg', type: 'image/svg+xml' },
|
||||||
{ url: '/favicon.ico', sizes: '16x16 32x32', type: 'image/x-icon' },
|
{ url: '/favicon.ico', sizes: '16x16 32x32', type: 'image/x-icon' },
|
||||||
],
|
],
|
||||||
@@ -63,7 +66,16 @@ export default function RootLayout({
|
|||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<FacebookPixel />
|
<FacebookPixel />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
<MicrosoftClarity />
|
||||||
{children}
|
{children}
|
||||||
|
{process.env.NEXT_PUBLIC_UMAMI_SRC && process.env.NEXT_PUBLIC_UMAMI_ID && (
|
||||||
|
<Script
|
||||||
|
defer
|
||||||
|
src={process.env.NEXT_PUBLIC_UMAMI_SRC}
|
||||||
|
data-website-id={process.env.NEXT_PUBLIC_UMAMI_ID}
|
||||||
|
strategy="afterInteractive"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Providers>
|
</Providers>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
// These landing pages (/vcard, /display, /coupon, /feedback) are public marketing-host
|
||||||
|
// pages reached straight from a scanned QR code - never the app host.
|
||||||
|
import { getWwwOrigin } from '@/lib/hosts';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { hashIP } from '@/lib/hash';
|
import { hashIP } from '@/lib/hash';
|
||||||
import { triggerLifecycleScoring } from '@/lib/revops-server';
|
import { triggerLifecycleScoring } from '@/lib/revops-server';
|
||||||
|
import { detectSocialMilestones } from '@/lib/social-milestones-server';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -47,7 +51,7 @@ export async function GET(
|
|||||||
break;
|
break;
|
||||||
case 'VCARD':
|
case 'VCARD':
|
||||||
// For vCard, redirect to display page
|
// For vCard, redirect to display page
|
||||||
const baseUrlVcard = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
|
const baseUrlVcard = getWwwOrigin();
|
||||||
destination = `${baseUrlVcard}/vcard?firstName=${encodeURIComponent(content.firstName || '')}&lastName=${encodeURIComponent(content.lastName || '')}&email=${encodeURIComponent(content.email || '')}&phone=${encodeURIComponent(content.phone || '')}&organization=${encodeURIComponent(content.organization || '')}&title=${encodeURIComponent(content.title || '')}`;
|
destination = `${baseUrlVcard}/vcard?firstName=${encodeURIComponent(content.firstName || '')}&lastName=${encodeURIComponent(content.lastName || '')}&email=${encodeURIComponent(content.email || '')}&phone=${encodeURIComponent(content.phone || '')}&organization=${encodeURIComponent(content.organization || '')}&title=${encodeURIComponent(content.title || '')}`;
|
||||||
break;
|
break;
|
||||||
case 'GEO':
|
case 'GEO':
|
||||||
@@ -58,7 +62,7 @@ export async function GET(
|
|||||||
break;
|
break;
|
||||||
case 'TEXT':
|
case 'TEXT':
|
||||||
// For plain text, redirect to a display page
|
// For plain text, redirect to a display page
|
||||||
const baseUrlText = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
|
const baseUrlText = getWwwOrigin();
|
||||||
destination = `${baseUrlText}/display?text=${encodeURIComponent(content.text || '')}`;
|
destination = `${baseUrlText}/display?text=${encodeURIComponent(content.text || '')}`;
|
||||||
break;
|
break;
|
||||||
case 'PDF':
|
case 'PDF':
|
||||||
@@ -81,12 +85,12 @@ export async function GET(
|
|||||||
break;
|
break;
|
||||||
case 'COUPON':
|
case 'COUPON':
|
||||||
// Redirect to coupon display page
|
// Redirect to coupon display page
|
||||||
const baseUrlCoupon = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
|
const baseUrlCoupon = getWwwOrigin();
|
||||||
destination = `${baseUrlCoupon}/coupon/${slug}`;
|
destination = `${baseUrlCoupon}/coupon/${slug}`;
|
||||||
break;
|
break;
|
||||||
case 'FEEDBACK':
|
case 'FEEDBACK':
|
||||||
// Redirect to feedback form page
|
// Redirect to feedback form page
|
||||||
const baseUrlFeedback = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
|
const baseUrlFeedback = getWwwOrigin();
|
||||||
destination = `${baseUrlFeedback}/feedback/${slug}`;
|
destination = `${baseUrlFeedback}/feedback/${slug}`;
|
||||||
break;
|
break;
|
||||||
case 'BARCODE':
|
case 'BARCODE':
|
||||||
@@ -257,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({
|
const activatedUsers = await db.user.updateMany({
|
||||||
where: {
|
where: {
|
||||||
id: userId,
|
id: userId,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Providers } from '@/components/Providers';
|
|||||||
import MarketingDeLayout from '@/components/marketing/MarketingDeLayout';
|
import MarketingDeLayout from '@/components/marketing/MarketingDeLayout';
|
||||||
import { organizationSchema, websiteSchema } from '@/lib/schema';
|
import { organizationSchema, websiteSchema } from '@/lib/schema';
|
||||||
import FacebookPixel from '@/components/analytics/FacebookPixel';
|
import FacebookPixel from '@/components/analytics/FacebookPixel';
|
||||||
|
import MicrosoftClarity from '@/components/analytics/MicrosoftClarity';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
@@ -68,6 +69,7 @@ export default function MarketingDeGroupLayout({
|
|||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Providers>
|
<Providers>
|
||||||
<FacebookPixel />
|
<FacebookPixel />
|
||||||
|
<MicrosoftClarity />
|
||||||
<script
|
<script
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationSchema()) }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationSchema()) }}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Providers } from '@/components/Providers';
|
|||||||
import MarketingDeLayout from '@/components/marketing/MarketingDeLayout';
|
import MarketingDeLayout from '@/components/marketing/MarketingDeLayout';
|
||||||
import { organizationSchema, websiteSchema } from '@/lib/schema';
|
import { organizationSchema, websiteSchema } from '@/lib/schema';
|
||||||
import FacebookPixel from '@/components/analytics/FacebookPixel';
|
import FacebookPixel from '@/components/analytics/FacebookPixel';
|
||||||
|
import MicrosoftClarity from '@/components/analytics/MicrosoftClarity';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
@@ -60,6 +61,7 @@ export default function GermanRootLayout({
|
|||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Providers>
|
<Providers>
|
||||||
<FacebookPixel />
|
<FacebookPixel />
|
||||||
|
<MicrosoftClarity />
|
||||||
<script
|
<script
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationSchema()) }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationSchema()) }}
|
||||||
|
|||||||
@@ -2,6 +2,17 @@ import { MetadataRoute } from 'next';
|
|||||||
|
|
||||||
export default function robots(): MetadataRoute.Robots {
|
export default function robots(): MetadataRoute.Robots {
|
||||||
const baseUrl = 'https://www.qrmaster.net';
|
const baseUrl = 'https://www.qrmaster.net';
|
||||||
|
|
||||||
|
// Staging (testmodul.qrmaster.net) runs the same code on a real qrmaster.net subdomain.
|
||||||
|
// Without this it would serve the production robots.txt and invite crawlers in, competing
|
||||||
|
// with www for the same content. The layouts already emit a noindex meta tag when this
|
||||||
|
// flag is off; this closes the robots.txt half.
|
||||||
|
if (process.env.NEXT_PUBLIC_INDEXABLE !== 'true') {
|
||||||
|
return {
|
||||||
|
rules: [{ userAgent: '*', disallow: '/' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const privatePaths = [
|
const privatePaths = [
|
||||||
'/api/',
|
'/api/',
|
||||||
'/dashboard/',
|
'/dashboard/',
|
||||||
|
|||||||
@@ -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 = [
|
const publishedPseoPages = [
|
||||||
...publishedComparisonPages.map((page) => ({
|
...publishedComparisonPages.map((page) => ({
|
||||||
url: `${baseUrl}${page.canonicalPath}`,
|
url: `${baseUrl}${page.canonicalPath}`,
|
||||||
@@ -216,12 +235,8 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
|||||||
changeFrequency: 'weekly',
|
changeFrequency: 'weekly',
|
||||||
priority: 0.95,
|
priority: 0.95,
|
||||||
},
|
},
|
||||||
{
|
// NOTE: /dynamic-barcode-generator and /barcode-generator are 301'd to
|
||||||
url: `${baseUrl}/dynamic-barcode-generator`,
|
// /tools/barcode-generator in next.config.mjs and must not be listed here.
|
||||||
lastModified: new Date(),
|
|
||||||
changeFrequency: 'monthly',
|
|
||||||
priority: 0.9,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
url: `${baseUrl}/bulk-qr-code-generator`,
|
url: `${baseUrl}/bulk-qr-code-generator`,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
@@ -234,6 +249,24 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
|||||||
changeFrequency: 'weekly',
|
changeFrequency: 'weekly',
|
||||||
priority: 0.9,
|
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`,
|
url: `${baseUrl}/pricing`,
|
||||||
@@ -272,6 +305,18 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
|||||||
changeFrequency: 'yearly',
|
changeFrequency: 'yearly',
|
||||||
priority: 0.4,
|
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`,
|
url: `${baseUrl}/contact`,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
@@ -301,6 +346,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
|||||||
...blogPages,
|
...blogPages,
|
||||||
...learnPages,
|
...learnPages,
|
||||||
...growthUseCasePages,
|
...growthUseCasePages,
|
||||||
|
...comparisonPages,
|
||||||
...publishedPseoPages,
|
...publishedPseoPages,
|
||||||
...industryUrls,
|
...industryUrls,
|
||||||
...authorPages,
|
...authorPages,
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export default function CookieBanner() {
|
|||||||
<svg className="w-3.5 h-3.5 text-primary-600 mr-1.5" fill="currentColor" viewBox="0 0 20 20">
|
<svg className="w-3.5 h-3.5 text-primary-600 mr-1.5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-gray-700"><strong>Analytics:</strong> PostHog & Google Analytics</span>
|
<span className="text-gray-700"><strong>Analytics:</strong> PostHog, Microsoft Clarity & Google Analytics</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
27
src/components/analytics/MicrosoftClarity.tsx
Normal file
27
src/components/analytics/MicrosoftClarity.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Script from 'next/script';
|
||||||
|
|
||||||
|
export default function MicrosoftClarity() {
|
||||||
|
const [consented, setConsented] = useState(false);
|
||||||
|
const projectId = process.env.NEXT_PUBLIC_CLARITY_PROJECT_ID;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check consent (same gate as PostHog / Facebook Pixel)
|
||||||
|
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||||
|
if (cookieConsent === 'accepted') setConsented(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!projectId || !consented) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Script id="ms-clarity" strategy="afterInteractive">
|
||||||
|
{`(function(c,l,a,r,i,t,y){
|
||||||
|
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
|
||||||
|
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
|
||||||
|
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
|
||||||
|
})(window, document, "clarity", "script", "${projectId}");`}
|
||||||
|
</Script>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { Card, CardContent } from '@/components/ui/Card';
|
|||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { Dropdown, DropdownItem } from '@/components/ui/Dropdown';
|
import { Dropdown, DropdownItem } from '@/components/ui/Dropdown';
|
||||||
import { formatDate } from '@/lib/utils';
|
import { formatDate } from '@/lib/utils';
|
||||||
|
import { getWwwOrigin } from '@/lib/hosts';
|
||||||
import {
|
import {
|
||||||
ONBOARDING_DOWNLOAD_COMPLETE_EVENT,
|
ONBOARDING_DOWNLOAD_COMPLETE_EVENT,
|
||||||
ONBOARDING_DOWNLOAD_COMPLETE_KEY,
|
ONBOARDING_DOWNLOAD_COMPLETE_KEY,
|
||||||
@@ -79,7 +80,10 @@ export const QRCodeCard: React.FC<QRCodeCardProps> = ({
|
|||||||
|
|
||||||
// For dynamic QR codes, use the redirect URL for tracking
|
// For dynamic QR codes, use the redirect URL for tracking
|
||||||
// For static QR codes, use the direct URL from content
|
// For static QR codes, use the direct URL from content
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || (typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3050');
|
//
|
||||||
|
// Must be the WWW origin, never the app origin: this value gets encoded into the QR
|
||||||
|
// code the user downloads and prints. /r/<slug> is served by the marketing host.
|
||||||
|
const baseUrl = getWwwOrigin();
|
||||||
|
|
||||||
// Get the QR URL based on type
|
// Get the QR URL based on type
|
||||||
let qrUrl = '';
|
let qrUrl = '';
|
||||||
|
|||||||
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>;
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface DialogProps {
|
interface DialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
children: React.ReactNode;
|
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;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -16,7 +17,7 @@ export const Dialog: React.FC<DialogProps> = ({ open, onOpenChange, children })
|
|||||||
className="fixed inset-0 bg-black/50"
|
className="fixed inset-0 bg-black/50"
|
||||||
onClick={() => onOpenChange(false)}
|
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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,4 +90,4 @@ export const DialogFooter = React.forwardRef<HTMLDivElement, DialogFooterProps>(
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
DialogFooter.displayName = 'DialogFooter';
|
DialogFooter.displayName = 'DialogFooter';
|
||||||
|
|||||||
41
src/lib/analyticsActivity.ts
Normal file
41
src/lib/analyticsActivity.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records that a signed-in user looked at their own scan numbers.
|
||||||
|
*
|
||||||
|
* This exists because "inactive" cannot be answered from anything else we
|
||||||
|
* store. A session can stay alive for weeks without the user ever opening their
|
||||||
|
* analytics, so a login timestamp would call someone active who has not seen a
|
||||||
|
* number in a month. PostHog cannot answer it either - capture there is gated on
|
||||||
|
* cookie consent and runs client-side, so it covers an unknown subset.
|
||||||
|
*
|
||||||
|
* Written server-side, on the endpoint that serves the numbers. That endpoint is
|
||||||
|
* the single choke point for both the analytics page and the dashboard.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Repeat views inside this window do not cause another write. */
|
||||||
|
const THROTTLE_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export function touchAnalyticsView(userId: string): void {
|
||||||
|
const now = new Date();
|
||||||
|
const staleBefore = new Date(now.getTime() - THROTTLE_MS);
|
||||||
|
|
||||||
|
// updateMany, not update: the throttle lives in the WHERE clause, so a repeat
|
||||||
|
// view inside the window matches no rows instead of racing a read.
|
||||||
|
db.user
|
||||||
|
.updateMany({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
OR: [
|
||||||
|
{ lastAnalyticsViewAt: null },
|
||||||
|
{ lastAnalyticsViewAt: { lt: staleBefore } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
data: { lastAnalyticsViewAt: now },
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
// Fire and forget. Analytics must still render if this write fails - it
|
||||||
|
// also fails harmlessly if the column has not been added yet.
|
||||||
|
console.error('Failed to record analytics view:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
9428
src/lib/blog-data.ts
9428
src/lib/blog-data.ts
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,9 @@ import type { BlogPost, PillarKey, AuthorProfile } from "./types";
|
|||||||
export const REDIRECTED_BLOG_SLUGS = new Set<string>([
|
export const REDIRECTED_BLOG_SLUGS = new Set<string>([
|
||||||
"qr-code-analytics",
|
"qr-code-analytics",
|
||||||
"qr-code-restaurant-menu",
|
"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 {
|
export function isRedirectedSlug(slug: string): boolean {
|
||||||
|
|||||||
@@ -1,39 +1,153 @@
|
|||||||
/**
|
/**
|
||||||
* Cookie configuration helpers
|
* Cookie configuration helpers
|
||||||
* Automatically uses secure settings in production
|
* Automatically uses secure settings in production
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get cookie options for authentication cookies
|
* Domain the session cookies are scoped to.
|
||||||
*/
|
*
|
||||||
export function getAuthCookieOptions() {
|
* Set `COOKIE_DOMAIN=.qrmaster.net` in production so one session is shared between
|
||||||
return {
|
* www.qrmaster.net (marketing, login) and app.qrmaster.net (the app). Without it the
|
||||||
httpOnly: true,
|
* cookie stays host-only and a user logged in on www would be anonymous on app.
|
||||||
secure: isProduction, // HTTPS only in production
|
*
|
||||||
sameSite: 'lax' as const,
|
* Only honoured in production on purpose: browsers reject dotted domains for
|
||||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
* `localhost`, so a prod .env copied into a dev environment would silently break
|
||||||
};
|
* every login instead of just ignoring the value.
|
||||||
}
|
*/
|
||||||
|
export function getCookieDomain(): string | undefined {
|
||||||
/**
|
if (!isProduction) {
|
||||||
* Get cookie options for CSRF tokens
|
return undefined;
|
||||||
* Note: httpOnly is false so the client can read it, but we verify via double-submit pattern
|
}
|
||||||
*/
|
|
||||||
export function getCsrfCookieOptions() {
|
const domain = process.env.COOKIE_DOMAIN?.trim();
|
||||||
return {
|
|
||||||
httpOnly: false, // Client needs to read this token for the header
|
return domain ? domain : undefined;
|
||||||
secure: isProduction, // HTTPS only in production
|
}
|
||||||
sameSite: 'lax' as const,
|
|
||||||
maxAge: 60 * 60 * 24, // 24 hours
|
/**
|
||||||
path: '/', // Available on all paths
|
* Name of the session cookie.
|
||||||
};
|
*
|
||||||
}
|
* Configurable so a staging deployment on another qrmaster.net subdomain can pick a
|
||||||
|
* distinct name. Production scopes its cookie to `.qrmaster.net`, so the browser sends it
|
||||||
/**
|
* to testmodul.qrmaster.net as well; two cookies with the same name would make
|
||||||
* Check if running in production
|
* `cookies.get()` ambiguous and staging logins flaky.
|
||||||
*/
|
*
|
||||||
export function isProductionEnvironment(): boolean {
|
* Like COOKIE_DOMAIN this must be set at build time too, because process.env is inlined
|
||||||
return isProduction;
|
* into the Edge middleware bundle.
|
||||||
}
|
*/
|
||||||
|
export function getAuthCookieName(): string {
|
||||||
|
return process.env.AUTH_COOKIE_NAME?.trim() || 'userId';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cookie options for authentication cookies
|
||||||
|
*/
|
||||||
|
export function getAuthCookieOptions() {
|
||||||
|
return {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isProduction, // HTTPS only in production
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
path: '/', // Explicit so the expiry in buildExpiredCookieHeaders() matches
|
||||||
|
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||||
|
domain: getCookieDomain(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cookie options for CSRF tokens
|
||||||
|
* Note: httpOnly is false so the client can read it, but we verify via double-submit pattern
|
||||||
|
*/
|
||||||
|
export function getCsrfCookieOptions() {
|
||||||
|
return {
|
||||||
|
httpOnly: false, // Client needs to read this token for the header
|
||||||
|
secure: isProduction, // HTTPS only in production
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
maxAge: 60 * 60 * 24, // 24 hours
|
||||||
|
path: '/', // Available on all paths
|
||||||
|
domain: getCookieDomain(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cookie options for short-lived flow cookies (OAuth state, post-auth redirect).
|
||||||
|
*/
|
||||||
|
export function getFlowCookieOptions(maxAgeSeconds: number) {
|
||||||
|
return {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isProduction,
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
path: '/',
|
||||||
|
maxAge: maxAgeSeconds,
|
||||||
|
domain: getCookieDomain(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeExpiredCookie(name: string, httpOnly: boolean, domain?: string): string {
|
||||||
|
const parts = [
|
||||||
|
`${name}=`,
|
||||||
|
'Path=/',
|
||||||
|
'Max-Age=0',
|
||||||
|
'Expires=Thu, 01 Jan 1970 00:00:00 GMT',
|
||||||
|
'SameSite=Lax',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (domain) {
|
||||||
|
parts.push(`Domain=${domain}`);
|
||||||
|
}
|
||||||
|
if (httpOnly) {
|
||||||
|
parts.push('HttpOnly');
|
||||||
|
}
|
||||||
|
if (isProduction) {
|
||||||
|
parts.push('Secure');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build every `Set-Cookie` value needed to actually delete a cookie.
|
||||||
|
*
|
||||||
|
* A cookie is only removed by a Set-Cookie whose name, path AND domain match what the
|
||||||
|
* browser stored. Since we moved the session to a shared COOKIE_DOMAIN, a returning user
|
||||||
|
* can hold BOTH variants at once: a host-only cookie set before the switch and a
|
||||||
|
* domain-scoped one set after. Expiring only one leaves the other in place and the user
|
||||||
|
* stays effectively logged in — so we always emit both.
|
||||||
|
*/
|
||||||
|
export function buildExpiredCookieHeaders(name: string, httpOnly: boolean): string[] {
|
||||||
|
const domain = getCookieDomain();
|
||||||
|
const headers = [serializeExpiredCookie(name, httpOnly)];
|
||||||
|
|
||||||
|
if (domain) {
|
||||||
|
headers.push(serializeExpiredCookie(name, httpOnly, domain));
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append expiry headers for the given cookies onto a response.
|
||||||
|
*
|
||||||
|
* IMPORTANT: call this AFTER the last `response.cookies.set()` on the same response.
|
||||||
|
* Next's ResponseCookies is keyed by cookie name and rewrites the whole `set-cookie`
|
||||||
|
* header from its internal map on every `set()`, which would drop these appends and
|
||||||
|
* collapse our two variants back into one.
|
||||||
|
*/
|
||||||
|
export function appendExpiredCookies(
|
||||||
|
headers: Headers,
|
||||||
|
cookies: Array<{ name: string; httpOnly: boolean }>
|
||||||
|
): void {
|
||||||
|
for (const cookie of cookies) {
|
||||||
|
for (const value of buildExpiredCookieHeaders(cookie.name, cookie.httpOnly)) {
|
||||||
|
headers.append('set-cookie', value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if running in production
|
||||||
|
*/
|
||||||
|
export function isProductionEnvironment(): boolean {
|
||||||
|
return isProduction;
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Resend } from 'resend';
|
import { Resend } from 'resend';
|
||||||
|
import { appUrl, getWwwOrigin, wwwUrl } from '@/lib/hosts';
|
||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
|
|
||||||
// Use a placeholder during build time, real key at runtime
|
// Use a placeholder during build time, real key at runtime
|
||||||
@@ -47,19 +48,32 @@ async function waitForRateLimit() {
|
|||||||
lastEmailSent = Date.now();
|
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
|
* Password Reset Email - Security focused with clear urgency
|
||||||
*/
|
*/
|
||||||
export async function sendPasswordResetEmail(email: string, resetToken: string) {
|
export async function sendPasswordResetEmail(email: string, resetToken: string) {
|
||||||
await waitForRateLimit();
|
await waitForRateLimit();
|
||||||
|
|
||||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
|
const resetUrl = wwwUrl(`/reset-password?token=${resetToken}`);
|
||||||
const resetUrl = `${appUrl}/reset-password?token=${resetToken}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await resend.emails.send({
|
await resend.emails.send({
|
||||||
from: 'QR Master Security <noreply@qrmaster.net>',
|
from: getEmailFromSecurity(),
|
||||||
replyTo: 'support@qrmaster.net',
|
replyTo: getEmailReplyTo(),
|
||||||
to: email,
|
to: email,
|
||||||
subject: '🔐 Reset Your QR Master Password (Expires in 1 Hour)',
|
subject: '🔐 Reset Your QR Master Password (Expires in 1 Hour)',
|
||||||
html: `
|
html: `
|
||||||
@@ -190,8 +204,8 @@ export async function sendNewsletterWelcomeEmail(email: string) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await resend.emails.send({
|
await resend.emails.send({
|
||||||
from: 'Timo from QR Master <timo@qrmaster.net>',
|
from: getEmailFrom(),
|
||||||
replyTo: 'support@qrmaster.net',
|
replyTo: getEmailReplyTo(),
|
||||||
to: email,
|
to: email,
|
||||||
subject: '🎉 You\'re In! Here\'s What Happens Next (AI QR Features)',
|
subject: '🎉 You\'re In! Here\'s What Happens Next (AI QR Features)',
|
||||||
html: `
|
html: `
|
||||||
@@ -362,8 +376,8 @@ export async function sendAIFeatureLaunchEmail(email: string) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await resend.emails.send({
|
await resend.emails.send({
|
||||||
from: 'Timo from QR Master <timo@qrmaster.net>',
|
from: getEmailFrom(),
|
||||||
replyTo: 'support@qrmaster.net',
|
replyTo: getEmailReplyTo(),
|
||||||
to: email,
|
to: email,
|
||||||
subject: '🚀 They\'re Live! Your AI QR Features Are Ready',
|
subject: '🚀 They\'re Live! Your AI QR Features Are Ready',
|
||||||
html: `
|
html: `
|
||||||
@@ -502,7 +516,7 @@ export async function sendAIFeatureLaunchEmail(email: string) {
|
|||||||
<td align="center">
|
<td align="center">
|
||||||
<p style="margin: 0 0 8px 0; color: #888888; font-size: 13px;">
|
<p style="margin: 0 0 8px 0; color: #888888; font-size: 13px;">
|
||||||
<a href="https://www.qrmaster.net" style="color: #667eea; text-decoration: none;">www.qrmaster.net</a> •
|
<a href="https://www.qrmaster.net" style="color: #667eea; text-decoration: none;">www.qrmaster.net</a> •
|
||||||
<a href="https://www.qrmaster.net/dashboard" style="color: #667eea; text-decoration: none;">Dashboard</a> •
|
<a href="${appUrl('/dashboard')}" style="color: #667eea; text-decoration: none;">Dashboard</a> •
|
||||||
<a href="https://www.qrmaster.net/faq" style="color: #667eea; text-decoration: none;">Help</a>
|
<a href="https://www.qrmaster.net/faq" style="color: #667eea; text-decoration: none;">Help</a>
|
||||||
</p>
|
</p>
|
||||||
<p style="margin: 0; color: #999999; font-size: 12px;">
|
<p style="margin: 0; color: #999999; font-size: 12px;">
|
||||||
@@ -559,15 +573,17 @@ function createSmtpTransport() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://www.qrmaster.net';
|
// Public marketing origin, used for the email chrome: logo, hero image, footer links.
|
||||||
|
// Per-page links below resolve their own host through appUrl() / wwwUrl().
|
||||||
|
const wwwOrigin = getWwwOrigin();
|
||||||
|
|
||||||
export async function sendEmailVerificationEmail(email: string, name: string, verificationUrl: string) {
|
export async function sendEmailVerificationEmail(email: string, name: string, verificationUrl: string) {
|
||||||
const transport = createSmtpTransport();
|
const transport = createSmtpTransport();
|
||||||
const firstName = name.trim().split(/\s+/)[0] || 'there';
|
const firstName = name.trim().split(/\s+/)[0] || 'there';
|
||||||
|
|
||||||
await transport.sendMail({
|
await transport.sendMail({
|
||||||
from: 'Timo from QR Master <timo@qrmaster.net>',
|
from: getEmailFrom(),
|
||||||
replyTo: 'support@qrmaster.net',
|
replyTo: getEmailReplyTo(),
|
||||||
to: email,
|
to: email,
|
||||||
subject: 'Confirm your QR Master email address',
|
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>`,
|
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>`,
|
||||||
@@ -579,13 +595,13 @@ export async function sendEmailVerificationEmail(email: string, name: string, ve
|
|||||||
export async function sendDesignerAnnouncementEmail(email: string, unsubscribeUrl: string) {
|
export async function sendDesignerAnnouncementEmail(email: string, unsubscribeUrl: string) {
|
||||||
await waitForRateLimit();
|
await waitForRateLimit();
|
||||||
|
|
||||||
const createUrl = `${appUrl}/create`;
|
const createUrl = appUrl('/create');
|
||||||
|
|
||||||
const transport = createSmtpTransport();
|
const transport = createSmtpTransport();
|
||||||
|
|
||||||
await transport.sendMail({
|
await transport.sendMail({
|
||||||
from: 'Timo from QR Master <timo@qrmaster.net>',
|
from: getEmailFrom(),
|
||||||
replyTo: 'support@qrmaster.net',
|
replyTo: getEmailReplyTo(),
|
||||||
to: email,
|
to: email,
|
||||||
subject: 'Your QR codes can now look like your brand',
|
subject: 'Your QR codes can now look like your brand',
|
||||||
html: `
|
html: `
|
||||||
@@ -650,8 +666,8 @@ export async function sendNewsletterEmail({
|
|||||||
const transport = createSmtpTransport();
|
const transport = createSmtpTransport();
|
||||||
|
|
||||||
await transport.sendMail({
|
await transport.sendMail({
|
||||||
from: 'Timo from QR Master <timo@qrmaster.net>',
|
from: getEmailFrom(),
|
||||||
replyTo: 'support@qrmaster.net',
|
replyTo: getEmailReplyTo(),
|
||||||
to: email,
|
to: email,
|
||||||
subject,
|
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>`,
|
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>`,
|
||||||
@@ -714,7 +730,7 @@ function emailShell(headExtra: string, bodyContent: string): string {
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="text-align:center;padding:0 20px;">
|
<td style="text-align:center;padding:0 20px;">
|
||||||
<p style="margin:0 0 6px;font-family:'DM Sans',-apple-system,sans-serif;font-size:12px;color:${clr.textMuted};">
|
<p style="margin:0 0 6px;font-family:'DM Sans',-apple-system,sans-serif;font-size:12px;color:${clr.textMuted};">
|
||||||
<a href="${appUrl}" style="color:${clr.gold};text-decoration:none;font-weight:500;">www.qrmaster.net</a>
|
<a href="${wwwOrigin}" style="color:${clr.gold};text-decoration:none;font-weight:500;">www.qrmaster.net</a>
|
||||||
·
|
·
|
||||||
<a href="mailto:support@qrmaster.net" style="color:${clr.textMuted};text-decoration:none;">support@qrmaster.net</a>
|
<a href="mailto:support@qrmaster.net" style="color:${clr.textMuted};text-decoration:none;">support@qrmaster.net</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -740,7 +756,7 @@ const dotGridPattern = `url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2F
|
|||||||
*/
|
*/
|
||||||
export async function sendWelcomeEmail(email: string, name: string) {
|
export async function sendWelcomeEmail(email: string, name: string) {
|
||||||
const transport = createSmtpTransport();
|
const transport = createSmtpTransport();
|
||||||
const createUrl = `${appUrl}/create`;
|
const createUrl = appUrl('/create');
|
||||||
const firstName = name.split(' ')[0];
|
const firstName = name.split(' ')[0];
|
||||||
|
|
||||||
const html = emailShell('', `
|
const html = emailShell('', `
|
||||||
@@ -792,7 +808,7 @@ export async function sendWelcomeEmail(email: string, name: string) {
|
|||||||
<!-- ── HERO IMAGE ── -->
|
<!-- ── HERO IMAGE ── -->
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 0; text-align: center; background-color: ${clr.card};">
|
<td style="padding: 0; text-align: center; background-color: ${clr.card};">
|
||||||
<img src="${appUrl}/email-hero-light.png" width="560" style="display:block;width:100%;max-width:560px;height:auto;border-bottom:3px solid ${clr.gold};" alt="Beautiful QR Code Experience">
|
<img src="${wwwOrigin}/email-hero-light.png" width="560" style="display:block;width:100%;max-width:560px;height:auto;border-bottom:3px solid ${clr.gold};" alt="Beautiful QR Code Experience">
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
@@ -904,7 +920,7 @@ export async function sendWelcomeEmail(email: string, name: string) {
|
|||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width:56px; height:56px; background-color:#0B0D14; border-radius:50%; text-align:center; vertical-align:middle; border:2px solid ${clr.border}; box-shadow:0 4px 10px rgba(0,0,0,0.05);">
|
<td style="width:56px; height:56px; background-color:#0B0D14; border-radius:50%; text-align:center; vertical-align:middle; border:2px solid ${clr.border}; box-shadow:0 4px 10px rgba(0,0,0,0.05);">
|
||||||
<img src="${appUrl}/favicon1.png" width="32" height="32" alt="Timo" style="display:inline-block; vertical-align:middle; border-radius:50%; object-fit:cover;">
|
<img src="${wwwOrigin}/favicon1.png" width="32" height="32" alt="Timo" style="display:inline-block; vertical-align:middle; border-radius:50%; object-fit:cover;">
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -927,8 +943,8 @@ export async function sendWelcomeEmail(email: string, name: string) {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
await transport.sendMail({
|
await transport.sendMail({
|
||||||
from: 'Timo from QR Master <timo@qrmaster.net>',
|
from: getEmailFrom(),
|
||||||
replyTo: 'support@qrmaster.net',
|
replyTo: getEmailReplyTo(),
|
||||||
to: email,
|
to: email,
|
||||||
subject: 'Your QR Master account is ready',
|
subject: 'Your QR Master account is ready',
|
||||||
html,
|
html,
|
||||||
@@ -940,7 +956,7 @@ export async function sendWelcomeEmail(email: string, name: string) {
|
|||||||
*/
|
*/
|
||||||
export async function sendActivationNudgeEmail(email: string, name: string) {
|
export async function sendActivationNudgeEmail(email: string, name: string) {
|
||||||
const transport = createSmtpTransport();
|
const transport = createSmtpTransport();
|
||||||
const createUrl = `${appUrl}/create`;
|
const createUrl = appUrl('/create');
|
||||||
const firstName = name.split(' ')[0];
|
const firstName = name.split(' ')[0];
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
@@ -1064,8 +1080,8 @@ export async function sendActivationNudgeEmail(email: string, name: string) {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
await transport.sendMail({
|
await transport.sendMail({
|
||||||
from: 'Timo from QR Master <timo@qrmaster.net>',
|
from: getEmailFrom(),
|
||||||
replyTo: 'support@qrmaster.net',
|
replyTo: getEmailReplyTo(),
|
||||||
to: email,
|
to: email,
|
||||||
subject: "Your 3 free codes are still sitting there",
|
subject: "Your 3 free codes are still sitting there",
|
||||||
html,
|
html,
|
||||||
@@ -1077,7 +1093,7 @@ export async function sendActivationNudgeEmail(email: string, name: string) {
|
|||||||
*/
|
*/
|
||||||
export async function sendUpgradeNudgeEmail(email: string, name: string, qrCount: number) {
|
export async function sendUpgradeNudgeEmail(email: string, name: string, qrCount: number) {
|
||||||
const transport = createSmtpTransport();
|
const transport = createSmtpTransport();
|
||||||
const pricingUrl = `${appUrl}/pricing`;
|
const pricingUrl = wwwUrl('/pricing');
|
||||||
const firstName = name.split(' ')[0];
|
const firstName = name.split(' ')[0];
|
||||||
|
|
||||||
const features = [
|
const features = [
|
||||||
@@ -1235,8 +1251,8 @@ export async function sendUpgradeNudgeEmail(email: string, name: string, qrCount
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
await transport.sendMail({
|
await transport.sendMail({
|
||||||
from: 'Timo from QR Master <timo@qrmaster.net>',
|
from: getEmailFrom(),
|
||||||
replyTo: 'support@qrmaster.net',
|
replyTo: getEmailReplyTo(),
|
||||||
to: email,
|
to: email,
|
||||||
subject: 'You just hit the free limit',
|
subject: 'You just hit the free limit',
|
||||||
html,
|
html,
|
||||||
@@ -1253,7 +1269,7 @@ export async function sendThirtyDayNudgeEmail(
|
|||||||
scanCount: number = 0
|
scanCount: number = 0
|
||||||
) {
|
) {
|
||||||
const transport = createSmtpTransport();
|
const transport = createSmtpTransport();
|
||||||
const pricingUrl = `${appUrl}/pricing`;
|
const pricingUrl = wwwUrl('/pricing');
|
||||||
const firstName = name.split(' ')[0];
|
const firstName = name.split(' ')[0];
|
||||||
|
|
||||||
const html = emailShell('', `
|
const html = emailShell('', `
|
||||||
@@ -1411,8 +1427,8 @@ export async function sendThirtyDayNudgeEmail(
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
await transport.sendMail({
|
await transport.sendMail({
|
||||||
from: 'Timo from QR Master <timo@qrmaster.net>',
|
from: getEmailFrom(),
|
||||||
replyTo: 'support@qrmaster.net',
|
replyTo: getEmailReplyTo(),
|
||||||
to: email,
|
to: email,
|
||||||
subject: `${firstName}, your codes were scanned ${scanCount} time${scanCount !== 1 ? 's' : ''} this month`,
|
subject: `${firstName}, your codes were scanned ${scanCount} time${scanCount !== 1 ? 's' : ''} this month`,
|
||||||
html,
|
html,
|
||||||
@@ -1435,7 +1451,7 @@ export async function sendFirstScanEmail(
|
|||||||
) {
|
) {
|
||||||
const transport = createSmtpTransport();
|
const transport = createSmtpTransport();
|
||||||
const firstName = name.split(' ')[0];
|
const firstName = name.split(' ')[0];
|
||||||
const analyticsUrl = `${appUrl}/analytics`;
|
const analyticsUrl = appUrl('/analytics');
|
||||||
|
|
||||||
const time = scan.ts.toLocaleTimeString('en-GB', {
|
const time = scan.ts.toLocaleTimeString('en-GB', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
@@ -1526,8 +1542,8 @@ export async function sendFirstScanEmail(
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
await transport.sendMail({
|
await transport.sendMail({
|
||||||
from: 'Timo from QR Master <timo@qrmaster.net>',
|
from: getEmailFrom(),
|
||||||
replyTo: 'support@qrmaster.net',
|
replyTo: getEmailReplyTo(),
|
||||||
to: email,
|
to: email,
|
||||||
subject: 'Your QR code was just scanned for the first time',
|
subject: 'Your QR code was just scanned for the first time',
|
||||||
html,
|
html,
|
||||||
|
|||||||
@@ -291,8 +291,8 @@ export const supportResources: SupportResourceLink[] = [
|
|||||||
'Editorial pillar page for educational browsing and broader QR workflow discovery.',
|
'Editorial pillar page for educational browsing and broader QR workflow discovery.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/blog/dynamic-vs-static-qr-codes',
|
href: '/blog/static-vs-dynamic-qr-code',
|
||||||
title: 'Dynamic vs Static QR Codes',
|
title: 'Static vs Dynamic QR Codes',
|
||||||
description:
|
description:
|
||||||
'Explainer for the operational difference between fixed and editable QR destinations.',
|
'Explainer for the operational difference between fixed and editable QR destinations.',
|
||||||
},
|
},
|
||||||
@@ -1484,8 +1484,8 @@ export const useCasePageContent: Record<string, UseCasePageContent> = {
|
|||||||
description: 'Useful when the same physical surfaces need an up-to-date post-scan payment action.',
|
description: 'Useful when the same physical surfaces need an up-to-date post-scan payment action.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/blog/dynamic-vs-static-qr-codes',
|
href: '/blog/static-vs-dynamic-qr-code',
|
||||||
title: 'Dynamic vs Static QR Codes',
|
title: 'Static vs Dynamic QR Codes',
|
||||||
description: 'Existing editorial asset for choosing editable QR destinations.',
|
description: 'Existing editorial asset for choosing editable QR destinations.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1572,8 +1572,8 @@ export const useCasePageContent: Record<string, UseCasePageContent> = {
|
|||||||
description: 'Another contact-first workflow where the destination needs to stay current after print.',
|
description: 'Another contact-first workflow where the destination needs to stay current after print.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/blog/dynamic-vs-static-qr-codes',
|
href: '/blog/static-vs-dynamic-qr-code',
|
||||||
title: 'Dynamic vs Static QR Codes',
|
title: 'Static vs Dynamic QR Codes',
|
||||||
description: 'Useful background for choosing dynamic QR destinations.',
|
description: 'Useful background for choosing dynamic QR destinations.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
125
src/lib/hosts.ts
Normal file
125
src/lib/hosts.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Host boundary between the marketing site and the app.
|
||||||
|
*
|
||||||
|
* Marketing/SEO content and the auth entry points (/login, /signup) live on
|
||||||
|
* www.qrmaster.net; everything behind the login lives on app.qrmaster.net.
|
||||||
|
*
|
||||||
|
* Both hostnames are served by the SAME Next deployment - nothing moves in the file
|
||||||
|
* tree. This module is the single source of truth for which host owns which path, shared
|
||||||
|
* by the middleware (which redirects the mismatches) and by every place that builds an
|
||||||
|
* absolute URL: Stripe return URLs, emails, OAuth redirects.
|
||||||
|
*
|
||||||
|
* Safe to import from middleware, route handlers and client components alike: no node
|
||||||
|
* APIs, and the NEXT_PUBLIC_* reads stay literal so the compiler can inline them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Production fallbacks are hardcoded on purpose. If NEXT_PUBLIC_WWW_URL were missing in
|
||||||
|
* production a localhost fallback would end up encoded into downloaded - and printed -
|
||||||
|
* QR codes. A wrong-but-real domain is recoverable, `http://localhost:3050` on a flyer
|
||||||
|
* is not.
|
||||||
|
*/
|
||||||
|
const WWW_FALLBACK = isProduction ? 'https://www.qrmaster.net' : 'http://localhost:3050';
|
||||||
|
const APP_FALLBACK = isProduction ? 'https://app.qrmaster.net' : 'http://localhost:3050';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path prefixes owned by the app host.
|
||||||
|
*
|
||||||
|
* Keep in sync with the `(app)` route group. `/upgrade` is included even though it is not
|
||||||
|
* in the middleware's protectedPaths list - it is an in-app page, only ever linked from
|
||||||
|
* inside the app.
|
||||||
|
*/
|
||||||
|
export const APP_PATH_PREFIXES = [
|
||||||
|
'/analytics',
|
||||||
|
'/bulk-creation',
|
||||||
|
'/create',
|
||||||
|
'/dashboard',
|
||||||
|
'/integrations',
|
||||||
|
'/onboarding',
|
||||||
|
'/qr',
|
||||||
|
'/settings',
|
||||||
|
'/upgrade',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function stripTrailingSlash(url: string): string {
|
||||||
|
return url.endsWith('/') ? url.slice(0, -1) : url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when `path` is served by the app host.
|
||||||
|
*
|
||||||
|
* Accepts a bare pathname or a path with query/hash - callers routinely pass things like
|
||||||
|
* `/dashboard?success=true`, and matching those against the prefixes directly would miss.
|
||||||
|
*/
|
||||||
|
export function isAppPath(path: string): boolean {
|
||||||
|
const pathname = path.split(/[?#]/)[0];
|
||||||
|
|
||||||
|
return APP_PATH_PREFIXES.some(
|
||||||
|
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Origin of the marketing host, e.g. `https://www.qrmaster.net`. */
|
||||||
|
export function getWwwOrigin(): string {
|
||||||
|
return stripTrailingSlash(process.env.NEXT_PUBLIC_WWW_URL || WWW_FALLBACK);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Origin of the app host, e.g. `https://app.qrmaster.net`. */
|
||||||
|
export function getAppOrigin(): string {
|
||||||
|
return stripTrailingSlash(process.env.NEXT_PUBLIC_APP_URL || APP_FALLBACK);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether marketing and app actually live on different hostnames.
|
||||||
|
*
|
||||||
|
* False in development, where both point at localhost:3050 - the middleware must not try
|
||||||
|
* to split hosts there or every request would redirect to itself.
|
||||||
|
*/
|
||||||
|
export function isHostSplitEnabled(): boolean {
|
||||||
|
try {
|
||||||
|
return new URL(getWwwOrigin()).host !== new URL(getAppOrigin()).host;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Absolute URL for `path` on the marketing host. */
|
||||||
|
export function wwwUrl(path: string): string {
|
||||||
|
return new URL(path, getWwwOrigin()).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Absolute URL for `path` on the app host. */
|
||||||
|
export function appUrl(path: string): string {
|
||||||
|
return new URL(path, getAppOrigin()).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute URL for `path` on whichever host owns it.
|
||||||
|
*
|
||||||
|
* Use this whenever the path is not known statically - Stripe return paths, post-auth
|
||||||
|
* redirect targets - so a caller can never send a user to the wrong host.
|
||||||
|
*/
|
||||||
|
export function urlForPath(path: string): string {
|
||||||
|
return isAppPath(path) ? appUrl(path) : wwwUrl(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether navigating to `path` from the current page crosses the host boundary.
|
||||||
|
*
|
||||||
|
* next/navigation's router can only push same-origin URLs, so a crossing needs a full
|
||||||
|
* `window.location` load. Always false on the server and in development, where both
|
||||||
|
* hosts are the same origin - callers then keep their normal client-side navigation.
|
||||||
|
*/
|
||||||
|
export function needsHostChange(path: string): boolean {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(urlForPath(path)).origin !== window.location.origin;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,7 +89,8 @@ export function getAllIndexableUrls(): string[] {
|
|||||||
`${baseUrl}/qr-code-tracking`,
|
`${baseUrl}/qr-code-tracking`,
|
||||||
`${baseUrl}/reprint-calculator`,
|
`${baseUrl}/reprint-calculator`,
|
||||||
`${baseUrl}/dynamic-qr-code-generator`,
|
`${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}/bulk-qr-code-generator`,
|
||||||
`${baseUrl}/custom-qr-code-generator`,
|
`${baseUrl}/custom-qr-code-generator`,
|
||||||
`${baseUrl}/manage-qr-codes`,
|
`${baseUrl}/manage-qr-codes`,
|
||||||
@@ -105,6 +106,7 @@ export function getAllIndexableUrls(): string[] {
|
|||||||
`${baseUrl}/restaurants`,
|
`${baseUrl}/restaurants`,
|
||||||
`${baseUrl}/qr-code-analytics`,
|
`${baseUrl}/qr-code-analytics`,
|
||||||
`${baseUrl}/qr-code-print-size-guide`,
|
`${baseUrl}/qr-code-print-size-guide`,
|
||||||
|
`${baseUrl}/developers`,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Alternatives & comparison hub pages
|
// Alternatives & comparison hub pages
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const allIndustries: IndustryPage[] = [
|
|||||||
"Loyalty programs and promotional offers",
|
"Loyalty programs and promotional offers",
|
||||||
"WiFi access for guest connectivity"
|
"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: [
|
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: "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." },
|
{ 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",
|
"Social media promotion to grow your following",
|
||||||
"Customer feedback forms to improve service"
|
"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: [
|
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: "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." },
|
{ 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",
|
"Amenity booking for spa and dining",
|
||||||
"Guest feedback surveys and review collection"
|
"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: [
|
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: "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." },
|
{ 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",
|
"Agent vCard contact information",
|
||||||
"Neighborhood information and local amenities"
|
"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: [
|
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: "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." },
|
{ 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",
|
"Fitness app downloads at key touchpoints",
|
||||||
"Loyalty programs with attendance tracking"
|
"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: [
|
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: "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." },
|
{ 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",
|
"Aftercare instructions and prescription information",
|
||||||
"Staff directory with provider specialties"
|
"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: [
|
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: "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." },
|
{ 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",
|
"Table reservation and waitlist management",
|
||||||
"Loyalty punch card replacement for regulars"
|
"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: [
|
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: "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." },
|
{ 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",
|
"Pre-order or call-ahead link for lunch rush management",
|
||||||
"Feedback form to collect reviews between stops"
|
"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: [
|
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: "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." },
|
{ 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",
|
"Pre-order link for morning rush management",
|
||||||
"Newsletter sign-up to notify customers of seasonal items"
|
"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: [
|
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: "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." },
|
{ 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",
|
"Online shop or local delivery ordering",
|
||||||
"Beer club membership sign-up at the bar"
|
"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: [
|
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: "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." },
|
{ 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",
|
"Wristband QR linking to after-party details",
|
||||||
"Guest list sign-up from social media flyers"
|
"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: [
|
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: "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." },
|
{ 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",
|
"vCard contact details for event planners",
|
||||||
"Video testimonials from past events"
|
"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: [
|
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: "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." },
|
{ 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",
|
"Food pairing suggestions and recipe ideas",
|
||||||
"Online shop for direct-to-consumer sales"
|
"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: [
|
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: "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." },
|
{ 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",
|
"Workshop and retreat registration",
|
||||||
"Member portal for existing students"
|
"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: [
|
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: "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." },
|
{ 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",
|
"Post-treatment care instructions and product recommendations",
|
||||||
"Google review link on checkout receipts"
|
"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: [
|
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: "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." },
|
{ 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",
|
"Loyalty program with visit tracking",
|
||||||
"Instagram follow link at reception"
|
"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: [
|
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: "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>" },
|
{ 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",
|
"Google review request on checkout card",
|
||||||
"Loyalty card replacement for regular clients"
|
"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: [
|
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: "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." },
|
{ 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",
|
"Aftercare instructions for gel and acrylic maintenance",
|
||||||
"Instagram follow link to see new seasonal designs"
|
"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: [
|
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: "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." },
|
{ 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",
|
"Consent and health waiver form filled on the client's phone",
|
||||||
"Booking page for consultation requests"
|
"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: [
|
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: "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." },
|
{ 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",
|
"Loyalty card sign-up at the counter",
|
||||||
"Health guide resources for common conditions"
|
"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: [
|
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: "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." },
|
{ 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",
|
"Vehicle history and inspection report",
|
||||||
"Salesperson vCard for after-hours follow-up"
|
"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: [
|
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: "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." },
|
{ 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",
|
"Wedding and event flower inquiry form",
|
||||||
"Gift message and delivery scheduling"
|
"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: [
|
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: "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." },
|
{ 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",
|
"Engagement ring guide for nervous buyers",
|
||||||
"Gift wrapping and message scheduling for occasions"
|
"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: [
|
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: "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." },
|
{ 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",
|
"Preferred vendor list for referred couples",
|
||||||
"Wedding planning guide and checklist download"
|
"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: [
|
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: "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." },
|
{ 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",
|
"Client gallery access with QR for private delivery",
|
||||||
"Google review request on delivery card"
|
"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: [
|
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: "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." },
|
{ 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",
|
"Client portal access for case updates",
|
||||||
"Firm overview and partner biographies for referrals"
|
"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: [
|
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: "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." },
|
{ 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",
|
"Client portal access for accounts and reports",
|
||||||
"Referral program landing page for new client acquisition"
|
"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: [
|
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: "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." },
|
{ 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",
|
"Claims process guide and contact links",
|
||||||
"Policy renewal reminder and self-service link"
|
"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: [
|
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: "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." },
|
{ 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",
|
"Booking confirmation and travel document portal",
|
||||||
"Loyalty and returning customer discount landing page"
|
"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: [
|
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: "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." },
|
{ 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",
|
"Treatment information and FAQ for nervous patients",
|
||||||
"Google review request at checkout"
|
"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: [
|
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: "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." },
|
{ 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",
|
"Digital vCard for instant contact saving",
|
||||||
"Special offer and referral program links"
|
"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: [
|
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: "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." },
|
{ 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",
|
"Post-operative care and medication guides",
|
||||||
"Pet insurance information and quote links"
|
"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: [
|
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: "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." },
|
{ 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." },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'server-only';
|
import 'server-only';
|
||||||
|
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import { wwwUrl } from '@/lib/hosts';
|
||||||
|
|
||||||
const TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 365;
|
const TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 365;
|
||||||
|
|
||||||
@@ -27,9 +28,7 @@ export function createMarketingUnsubscribeUrl(email: string): string {
|
|||||||
JSON.stringify({ email: normalizeEmail(email), expiresAt: Date.now() + TOKEN_TTL_MS })
|
JSON.stringify({ email: normalizeEmail(email), expiresAt: Date.now() + TOKEN_TTL_MS })
|
||||||
).toString('base64url');
|
).toString('base64url');
|
||||||
const token = `${payload}.${sign(payload)}`;
|
const token = `${payload}.${sign(payload)}`;
|
||||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://www.qrmaster.net';
|
return wwwUrl(`/unsubscribe?token=${encodeURIComponent(token)}`);
|
||||||
|
|
||||||
return `${appUrl}/unsubscribe?token=${encodeURIComponent(token)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUnsubscribeEmail(token: string | null | undefined): string | null {
|
export function getUnsubscribeEmail(token: string | null | undefined): string | null {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
|
import { getWwwOrigin } from '@/lib/hosts';
|
||||||
|
|
||||||
const BASE_URL = 'https://graph.facebook.com/v21.0';
|
const BASE_URL = 'https://graph.facebook.com/v21.0';
|
||||||
const PIXEL_ID = process.env.META_PIXEL_ID;
|
const PIXEL_ID = process.env.META_PIXEL_ID;
|
||||||
@@ -41,7 +42,8 @@ export async function sendConversionEvent(event: ConversionEvent): Promise<void>
|
|||||||
{
|
{
|
||||||
event_name: event.eventName,
|
event_name: event.eventName,
|
||||||
event_time: event.eventTime ?? Math.floor(Date.now() / 1000),
|
event_time: event.eventTime ?? Math.floor(Date.now() / 1000),
|
||||||
event_source_url: event.eventSourceUrl ?? process.env.NEXT_PUBLIC_APP_URL,
|
// Ad attribution happens on the public site, so the fallback is the marketing host.
|
||||||
|
event_source_url: event.eventSourceUrl ?? getWwwOrigin(),
|
||||||
action_source: 'website',
|
action_source: 'website',
|
||||||
user_data: hashedUserData,
|
user_data: hashedUserData,
|
||||||
custom_data: event.customData ?? {},
|
custom_data: event.customData ?? {},
|
||||||
|
|||||||
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)}`;
|
||||||
|
}
|
||||||
@@ -30,14 +30,25 @@ export function organizationSchema() {
|
|||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'Organization',
|
'@type': 'Organization',
|
||||||
'@id': `${SITE_URL}/#organization`,
|
'@id': `${SITE_URL}/#organization`,
|
||||||
name: 'QR Master',
|
name: 'QR Master',
|
||||||
alternateName: ['QRMaster', 'QR Master QR Code Generator'],
|
// The brand name is contested: a competing generator runs on qr-master.org
|
||||||
url: SITE_URL,
|
// and two unrelated Android apps ship as "QR Master". Listing the spellings
|
||||||
logo: {
|
// people actually type (incl. the hyphenated form) helps Google tie those
|
||||||
'@type': 'ImageObject',
|
// queries to this entity rather than a namesake.
|
||||||
url: `${SITE_URL}/og-image.png`,
|
alternateName: [
|
||||||
width: 1200,
|
'QRMaster',
|
||||||
height: 630,
|
'QR-Master',
|
||||||
|
'qrmaster.net',
|
||||||
|
'QR Master QR Code Generator',
|
||||||
|
],
|
||||||
|
description:
|
||||||
|
'QR Master is a QR code generator for dynamic QR codes that stay editable after printing, with scan analytics by time, device, and location.',
|
||||||
|
url: SITE_URL,
|
||||||
|
logo: {
|
||||||
|
'@type': 'ImageObject',
|
||||||
|
url: `${SITE_URL}/og-image.png`,
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
},
|
},
|
||||||
sameAs: [
|
sameAs: [
|
||||||
'https://www.wikidata.org/wiki/Q137918857',
|
'https://www.wikidata.org/wiki/Q137918857',
|
||||||
@@ -95,9 +106,9 @@ export function blogPostingSchema(post: BlogPost, author?: AuthorProfile) {
|
|||||||
name: "QR Master",
|
name: "QR Master",
|
||||||
url: SITE_URL,
|
url: SITE_URL,
|
||||||
logo: {
|
logo: {
|
||||||
'@type': 'ImageObject',
|
'@type': 'ImageObject',
|
||||||
url: `${SITE_URL}/og-image.png`,
|
url: `${SITE_URL}/og-image.png`,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isPartOf: {
|
isPartOf: {
|
||||||
'@type': 'Blog',
|
'@type': 'Blog',
|
||||||
@@ -272,7 +283,7 @@ export function articleSchema(params: {
|
|||||||
url: SITE_URL,
|
url: SITE_URL,
|
||||||
logo: {
|
logo: {
|
||||||
'@type': 'ImageObject',
|
'@type': 'ImageObject',
|
||||||
url: `${SITE_URL}/og-image.png`,
|
url: `${SITE_URL}/og-image.png`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
url: params.url,
|
url: params.url,
|
||||||
@@ -287,7 +298,7 @@ export function reviewSchema(testimonial: Testimonial) {
|
|||||||
'@type': 'SoftwareApplication',
|
'@type': 'SoftwareApplication',
|
||||||
name: 'QR Master',
|
name: 'QR Master',
|
||||||
description: 'Professional QR code generator with dynamic QR codes, analytics, and customization.',
|
description: 'Professional QR code generator with dynamic QR codes, analytics, and customization.',
|
||||||
image: `${SITE_URL}/og-image.png`,
|
image: `${SITE_URL}/og-image.png`,
|
||||||
applicationCategory: 'BusinessApplication',
|
applicationCategory: 'BusinessApplication',
|
||||||
operatingSystem: 'Web Browser',
|
operatingSystem: 'Web Browser',
|
||||||
offers: {
|
offers: {
|
||||||
@@ -320,7 +331,7 @@ export function aggregateRatingSchema(aggregateRating: AggregateRating) {
|
|||||||
'@type': 'SoftwareApplication',
|
'@type': 'SoftwareApplication',
|
||||||
name: 'QR Master',
|
name: 'QR Master',
|
||||||
description: 'Professional QR code generator with dynamic QR codes, analytics, and customization.',
|
description: 'Professional QR code generator with dynamic QR codes, analytics, and customization.',
|
||||||
image: `${SITE_URL}/og-image.png`,
|
image: `${SITE_URL}/og-image.png`,
|
||||||
applicationCategory: 'BusinessApplication',
|
applicationCategory: 'BusinessApplication',
|
||||||
operatingSystem: 'Web Browser',
|
operatingSystem: 'Web Browser',
|
||||||
url: SITE_URL,
|
url: SITE_URL,
|
||||||
@@ -367,7 +378,7 @@ export function newsArticleSchema(params: NewsArticleParams) {
|
|||||||
'@id': `${SITE_URL}/#organization`,
|
'@id': `${SITE_URL}/#organization`,
|
||||||
logo: {
|
logo: {
|
||||||
'@type': 'ImageObject',
|
'@type': 'ImageObject',
|
||||||
url: `${SITE_URL}/og-image.png`,
|
url: `${SITE_URL}/og-image.png`,
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 630,
|
height: 630,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'server-only';
|
import 'server-only';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { getAuthCookieOptions } from './cookieConfig';
|
import { getAuthCookieName, getAuthCookieOptions } from './cookieConfig';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Signed session cookie.
|
* Signed session cookie.
|
||||||
@@ -11,8 +11,6 @@ import { getAuthCookieOptions } from './cookieConfig';
|
|||||||
* detect a tampered/forged cookie and reject it. Format: `<userId>.<signature>`.
|
* detect a tampered/forged cookie and reject it. Format: `<userId>.<signature>`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const AUTH_COOKIE_NAME = 'userId';
|
|
||||||
|
|
||||||
function getSecret(): string {
|
function getSecret(): string {
|
||||||
const secret = process.env.NEXTAUTH_SECRET;
|
const secret = process.env.NEXTAUTH_SECRET;
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
@@ -68,12 +66,12 @@ export function verifySignedUserId(value: string | undefined | null): string | n
|
|||||||
* Use this in route handlers instead of reading the `userId` cookie directly.
|
* Use this in route handlers instead of reading the `userId` cookie directly.
|
||||||
*/
|
*/
|
||||||
export function getSessionUserId(): string | null {
|
export function getSessionUserId(): string | null {
|
||||||
return verifySignedUserId(cookies().get(AUTH_COOKIE_NAME)?.value);
|
return verifySignedUserId(cookies().get(getAuthCookieName())?.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the signed auth cookie for the given user id (server component / route handler context).
|
* Set the signed auth cookie for the given user id (server component / route handler context).
|
||||||
*/
|
*/
|
||||||
export function setSessionCookie(userId: string): void {
|
export function setSessionCookie(userId: string): void {
|
||||||
cookies().set(AUTH_COOKIE_NAME, signUserId(userId), getAuthCookieOptions());
|
cookies().set(getAuthCookieName(), signUserId(userId), getAuthCookieOptions());
|
||||||
}
|
}
|
||||||
|
|||||||
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}`;
|
||||||
|
}
|
||||||
@@ -6,6 +6,14 @@ import {
|
|||||||
serializeAttributionCookie,
|
serializeAttributionCookie,
|
||||||
} from '@/lib/revops';
|
} from '@/lib/revops';
|
||||||
import { verifySignedUserIdEdge } from '@/lib/session-edge';
|
import { verifySignedUserIdEdge } from '@/lib/session-edge';
|
||||||
|
import { getAuthCookieName, getCookieDomain } from '@/lib/cookieConfig';
|
||||||
|
import {
|
||||||
|
getAppOrigin,
|
||||||
|
getWwwOrigin,
|
||||||
|
isAppPath,
|
||||||
|
isHostSplitEnabled,
|
||||||
|
wwwUrl,
|
||||||
|
} from '@/lib/hosts';
|
||||||
|
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
@@ -37,12 +45,75 @@ function attachAttributionCookie(req: NextRequest, response: NextResponse) {
|
|||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
path: '/',
|
path: '/',
|
||||||
maxAge: 60 * 60 * 24 * 90,
|
maxAge: 60 * 60 * 24 * 90,
|
||||||
|
domain: getCookieDomain(),
|
||||||
});
|
});
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function middleware(req: NextRequest) {
|
/** Hostname of the app host, or null when marketing and app share one origin (dev). */
|
||||||
|
function getAppHostname(): string | null {
|
||||||
|
if (!isHostSplitEnabled()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(getAppOrigin()).hostname;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Absolute target on the other host, preserving path and query. */
|
||||||
|
function crossHostUrl(origin: string, req: NextRequest): string {
|
||||||
|
const url = new URL(req.nextUrl.pathname + req.nextUrl.search, origin);
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route a request that arrived on the app host (app.qrmaster.net).
|
||||||
|
*
|
||||||
|
* The app host serves only the logged-in app; everything else belongs to the marketing
|
||||||
|
* host and gets redirected so a stray link or an old bookmark still lands somewhere
|
||||||
|
* sensible. Returns null when the request is an app path and should continue through the
|
||||||
|
* normal auth handling below.
|
||||||
|
*/
|
||||||
|
function routeAppHost(req: NextRequest): NextResponse | null {
|
||||||
|
const path = req.nextUrl.pathname;
|
||||||
|
|
||||||
|
// Keep the app host out of search indexes entirely - the marketing host owns all SEO.
|
||||||
|
if (path === '/robots.txt') {
|
||||||
|
return NextResponse.rewrite(new URL('/robots-app.txt', req.url));
|
||||||
|
}
|
||||||
|
if (path === '/sitemap.xml') {
|
||||||
|
return NextResponse.redirect(wwwUrl('/sitemap.xml'), 301);
|
||||||
|
}
|
||||||
|
|
||||||
|
// API and framework internals must be served on both hosts: the app calls its own
|
||||||
|
// /api routes, and the Stripe webhook still points at the marketing host.
|
||||||
|
if (path.startsWith('/api/') || path.startsWith('/_next')) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// QR redirects belong to the marketing host. Redirecting instead of 404ing keeps any
|
||||||
|
// code that was generated with the wrong origin working.
|
||||||
|
if (path.startsWith('/r/')) {
|
||||||
|
return NextResponse.redirect(crossHostUrl(getWwwOrigin(), req), 301);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.includes('.')) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAppPath(path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.redirect(crossHostUrl(getWwwOrigin(), req), 301);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function routeRequest(req: NextRequest): Promise<NextResponse> {
|
||||||
const path = req.nextUrl.pathname;
|
const path = req.nextUrl.pathname;
|
||||||
const hostname = req.headers.get('host')?.split(':')[0] || req.nextUrl.hostname;
|
const hostname = req.headers.get('host')?.split(':')[0] || req.nextUrl.hostname;
|
||||||
|
|
||||||
@@ -54,6 +125,23 @@ export async function middleware(req: NextRequest) {
|
|||||||
return NextResponse.redirect(url, 301);
|
return NextResponse.redirect(url, 301);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const appHostname = getAppHostname();
|
||||||
|
|
||||||
|
if (appHostname) {
|
||||||
|
if (hostname === appHostname) {
|
||||||
|
const appHostResponse = routeAppHost(req);
|
||||||
|
|
||||||
|
if (appHostResponse) {
|
||||||
|
return appHostResponse;
|
||||||
|
}
|
||||||
|
// Falls through: app path on the app host, continue to the auth check below.
|
||||||
|
} else if (isAppPath(path)) {
|
||||||
|
// App path requested on the marketing host - move it to the app host. Keeps old
|
||||||
|
// bookmarks and the dashboard link in email footers working.
|
||||||
|
return NextResponse.redirect(crossHostUrl(getAppOrigin(), req), 301);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 301 Redirects for /guide -> /learn to avoid duplicate content and consolidate authority
|
// 301 Redirects for /guide -> /learn to avoid duplicate content and consolidate authority
|
||||||
if (path === '/guide/tracking-analytics') {
|
if (path === '/guide/tracking-analytics') {
|
||||||
return attachAttributionCookie(req, NextResponse.redirect(new URL('/learn/tracking', req.url), 301));
|
return attachAttributionCookie(req, NextResponse.redirect(new URL('/learn/tracking', req.url), 301));
|
||||||
@@ -159,11 +247,11 @@ export async function middleware(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For protected routes, require a validly signed userId cookie
|
// For protected routes, require a validly signed userId cookie
|
||||||
const userId = await verifySignedUserIdEdge(req.cookies.get('userId')?.value);
|
const userId = await verifySignedUserIdEdge(req.cookies.get(getAuthCookieName())?.value);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
// Not authenticated - redirect to signup
|
// Not authenticated - redirect to signup, which lives on the marketing host.
|
||||||
const signupUrl = new URL('/signup', req.url);
|
const signupUrl = new URL(wwwUrl('/signup'));
|
||||||
const redirectTarget = `${path}${req.nextUrl.search}`;
|
const redirectTarget = `${path}${req.nextUrl.search}`;
|
||||||
signupUrl.searchParams.set('redirect', redirectTarget);
|
signupUrl.searchParams.set('redirect', redirectTarget);
|
||||||
return attachAttributionCookie(req, NextResponse.redirect(signupUrl));
|
return attachAttributionCookie(req, NextResponse.redirect(signupUrl));
|
||||||
@@ -173,6 +261,20 @@ export async function middleware(req: NextRequest) {
|
|||||||
return attachAttributionCookie(req, NextResponse.next());
|
return attachAttributionCookie(req, NextResponse.next());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function middleware(req: NextRequest) {
|
||||||
|
const response = await routeRequest(req);
|
||||||
|
const appHostname = getAppHostname();
|
||||||
|
const hostname = req.headers.get('host')?.split(':')[0] || req.nextUrl.hostname;
|
||||||
|
|
||||||
|
// Belt and braces alongside robots-app.txt: the app host must never be indexed, and
|
||||||
|
// setting the header here covers every response the routing above can produce.
|
||||||
|
if (appHostname && hostname === appHostname) {
|
||||||
|
response.headers.set('X-Robots-Tag', 'noindex, nofollow');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
/*
|
/*
|
||||||
|
|||||||
Reference in New Issue
Block a user