19 Commits

Author SHA1 Message Date
5541c0553c refactor: derive email sender address dynamically from SMTP_USER 2026-08-13 19:33:05 +02:00
172730cf0f SMTP_USER 2026-08-13 18:30:34 +02:00
9ccce7cbfd info instead of timo 2026-08-13 17:44:55 +02:00
45f6c4d83b Add orphaned pages to sitemap and link review tool from industry pages 2026-08-13 10:04:49 +02:00
a9057b25dd Add a copy-paste command list to the staging runbook
The runbook explained every step but had no way to just work through it. Adds a checklist
and all commands in one block up front, with the prose below as the reference for what a
step does and how it fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:29:35 +02:00
769d06e04e Add runbook for setting up the staging environment
Standalone instructions for whoever sets up testmodul.qrmaster.net on the production
server. Written to be followed without prior context: explicit paths, an upfront list of
what must not be touched, and a stop condition in step 2 if the host URLs point at
production, which would send staging clicks into the live app.

Contains no credentials - .env.test is handed over separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:27:22 +02:00
d623f39c54 Give the staging database its own name
The staging database now runs as qrmaster_test instead of reusing the production name.
Container and volume already kept the two apart, but a hand-typed psql session against
two databases both called `qrmaster` looks identical on either side - the distinct name is
what makes the wrong window obvious before a DELETE lands in it.

The base compose file hardcodes `pg_isready -d qrmaster` in the db healthcheck, so the
overlay has to override the probe as well. Without it the container stays unhealthy and web
never starts, because it waits on service_healthy.

Verified against `docker compose config`: staging resolves to qrmaster_test in POSTGRES_DB,
DATABASE_URL and the healthcheck, while production still resolves to qrmaster.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:20:30 +02:00
40b73877b6 Ignore .env.test
The staging stack is configured through .env.test, which holds its own database password
and secrets. It was not covered by the existing .env rules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:14:01 +02:00
113acc073f Make the session cookie name configurable for a staging deployment
Groundwork for testmodul.qrmaster.net, a second stack running the `test` branch on a real
qrmaster.net subdomain.

Production scopes its session cookie to .qrmaster.net, so the browser sends it to every
subdomain including staging. With both environments naming the cookie `userId`, the
browser holds two cookies of the same name and cookies.get() picks one arbitrarily -
staging logins would look randomly signed-out. AUTH_COOKIE_NAME lets staging pick
`userId_test` instead. Production keeps the `userId` default; changing it there would
invalidate every existing session.

Wired getAuthCookieName() into the six places that named the cookie literally. The account
deletion route now expires both the host-only and the domain-scoped variant like the logout
route already does, instead of a single cookies().delete() that would leave the other one
behind.

NEXT_PUBLIC_WWW_URL and NEXT_PUBLIC_APP_URL become build ARGs so the same image can be
built pointing at the staging host - the defaults keep a plain production build byte
identical to before. Like COOKIE_DOMAIN these must exist at build time, because process.env
is inlined into the Edge middleware bundle.

robots.ts now serves Disallow-all unless NEXT_PUBLIC_INDEXABLE is true. Staging otherwise
returns the production robots.txt and invites crawlers to index a duplicate of www.

docker-compose.test.yml is the staging overlay. Two things it must get right, both verified
against `docker compose config`:

- db and redis need `networks: !override`. Compose MERGES the networks mapping from the base
  file, and since qrmaster-network is external and shared, a plain list left them attached
  to it - `db` would then resolve to two containers and staging could read and write the
  production database.
- The web entrypoint is replaced so `prisma migrate deploy` never runs. prisma/migrations
  stopped in April 2026 and the schema has moved on through manual SQL since, so applying
  them to a fresh database would build a stale schema. Staging gets its schema from
  `pg_dump --schema-only` against production instead.

Verified: tsc clean, production build succeeds, and the merged compose config confirms
staging keeps db/redis off the shared network while production resolves unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:09:56 +02:00
53ef4b3b91 Serve the app on app.qrmaster.net, marketing on www
Splits the two hostnames across one deployment. No files move: the Next app still
serves every route on both hosts, and the middleware decides per host which paths it
owns and 301s the rest. /login and /signup stay on www - all 82 marketing CTAs point
at /signup, which carries a hard canonical to www plus ad traffic.

src/lib/hosts.ts is the single source of truth for the boundary (APP_PATH_PREFIXES,
isAppPath, wwwUrl, appUrl, urlForPath). The middleware and every absolute-URL builder
read from it so they cannot drift apart.

- Split the overloaded NEXT_PUBLIC_APP_URL into a www and an app origin. It previously
  fed both public URLs and in-app URLs, so any single value was wrong somewhere. Most
  important: QRCodeCard encodes this origin into the QR code the user downloads and
  prints, so it must stay on www.
- Route Stripe return URLs, email links and OAuth redirects per path rather than
  against one origin, so /dashboard lands on app and /pricing on www.
- Cross the host boundary once, after a successful login: the router cannot push across
  origins, so that jump needs a full load. The user arrives signed in because the
  session cookie is scoped to COOKIE_DOMAIN.
- Keep the app host out of search indexes: X-Robots-Tag on every response plus a
  Disallow-all robots.txt via rewrite, and /sitemap.xml redirects to www.
- Point the TikTok callback fallback at www explicitly. It used to read
  NEXT_PUBLIC_APP_URL, whose meaning changed here, and only the apex domain is
  verified with TikTok.

Host splitting is inert while both origins are equal, so development is unaffected.

Verified: tsc clean, production build succeeds including the Edge middleware bundle,
and the path-to-host mapping is unit-checked (prefix traps like /created and
/settings-guide stay on www, query strings do not break matching).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:44:08 +02:00
35ea8cc3e9 Share session cookies across www and app subdomains
Groundwork for moving the app to app.qrmaster.net: the session has to survive the
host change from www.qrmaster.net to app.qrmaster.net.

- Add COOKIE_DOMAIN and apply it to the auth, CSRF, attribution and OAuth flow
  cookies. Honoured only in production, because browsers reject dotted domains on
  localhost - a prod .env copied into a dev environment would otherwise break
  every login instead of just ignoring the value.
- Expire both the host-only and the domain-scoped variant on logout. Next's
  ResponseCookies is keyed by cookie name and rewrites the entire set-cookie
  header from its internal map on every set(), so the two variants must be
  appended manually - otherwise one overwrites the other and the surviving stale
  cookie keeps the user signed in.
- Pass COOKIE_DOMAIN as both build arg and runtime env: 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.

No behaviour change while COOKIE_DOMAIN is unset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:01:14 +02:00
e3276f5943 retention 2026-08-11 23:35:57 +02:00
ca1e432f80 Clarity 2026-08-11 21:50:42 +02:00
bec48ab8e1 seo 2026-08-07 13:35:19 +02:00
68c531a1d5 umami bugfix 2026-08-06 09:33:53 -05:00
999ee79aca import script 2026-08-06 09:17:48 -05:00
87eb8c8883 bugfix 2026-08-06 09:14:01 -05:00
aac7283e59 Merge branch 'master' of git.bizmatch.net:tknuth/QR-master 2026-08-06 09:04:04 -05:00
50087f3c15 umami 2026-08-06 09:01:39 -05:00
54 changed files with 6692 additions and 5087 deletions

View File

@@ -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
} }
] ]
} }

4
.gitignore vendored
View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View 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`) |
| B2B7 | 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 B2B7.
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: A1A2 **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 (B2B7):** 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: ~46 h Code über zwei Deploys
- Keine DB-Änderung, kein Forced-Logout, kein SEO-Verlust

View 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

65
docker-compose.test.yml Normal file
View File

@@ -0,0 +1,65 @@
# 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.
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
adminer:
container_name: qrmaster-test-adminer
ports: !reset []
networks: !override
- test-internal
networks:
test-internal:
driver: bridge

View File

@@ -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,6 +58,9 @@ 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}
TIKTOK_CLIENT_KEY: ${TIKTOK_CLIENT_KEY:-} TIKTOK_CLIENT_KEY: ${TIKTOK_CLIENT_KEY:-}
TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-} TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-}
@@ -77,7 +85,7 @@ services:
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:-}
@@ -91,6 +99,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

View File

@@ -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,6 +70,9 @@ 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=

View File

@@ -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',

View File

@@ -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?

View File

@@ -20,7 +20,7 @@
## 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

5
public/robots-app.txt Normal file
View 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: /

View File

@@ -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 {

View File

@@ -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();
@@ -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');

View File

@@ -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>

View File

@@ -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,

View File

@@ -64,7 +64,7 @@ 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>
@@ -90,6 +90,7 @@ 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>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>

View File

@@ -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> = {

View File

@@ -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';

View File

@@ -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;
} }
} }

View File

@@ -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() { export async function POST() {
const response = NextResponse.json({ success: true }); const response = NextResponse.json({ success: true });
response.cookies.set('userId', '', { // Deliberately not using response.cookies.set() here: it is keyed by cookie name, so
httpOnly: true, // it can only ever emit one variant per cookie. Logout has to expire both the
secure: process.env.NODE_ENV === 'production', // host-only and the domain-scoped variant (see appendExpiredCookies).
sameSite: 'lax', appendExpiredCookies(response.headers, [
path: '/', { name: getAuthCookieName(), httpOnly: true },
maxAge: 0, { name: 'newsletter-admin', httpOnly: true },
}); { name: ATTRIBUTION_COOKIE_NAME, httpOnly: false },
response.cookies.set('newsletter-admin', '', { ]);
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 0,
});
response.cookies.set(ATTRIBUTION_COOKIE_NAME, '', {
httpOnly: false,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 0,
});
return response; return response;
} }

View File

@@ -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

View File

@@ -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;
} }

View File

@@ -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 {
@@ -60,8 +61,9 @@ export async function POST(request: NextRequest) {
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,

View File

@@ -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,

View File

@@ -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 });

View File

@@ -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/', {

View File

@@ -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();

View File

@@ -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(

View File

@@ -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>

View File

@@ -1,4 +1,7 @@
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';
@@ -47,7 +50,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 +61,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 +84,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':

View File

@@ -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()) }}

View File

@@ -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()) }}

View File

@@ -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/',

View File

@@ -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,

View File

@@ -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>

View 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>
);
}

View File

@@ -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 = '';

View 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);
});
}

View File

@@ -520,7 +520,7 @@ export const blogPosts: BlogPost[] = [
'Yes. The QR generator works for Teams work, school, and personal account meeting links.', 'Yes. The QR generator works for Teams work, school, and personal account meeting links.',
}, },
], ],
relatedSlugs: ['dynamic-vs-static-qr-codes', 'qr-code-small-business'], relatedSlugs: ['static-vs-dynamic-qr-code', 'qr-code-small-business'],
sources: [ sources: [
{ {
name: 'Microsoft Teams - Create and join meetings', name: 'Microsoft Teams - Create and join meetings',
@@ -692,7 +692,7 @@ export const blogPosts: BlogPost[] = [
}, },
], ],
relatedSlugs: [ relatedSlugs: [
'dynamic-vs-static-qr-codes', 'static-vs-dynamic-qr-code',
'qr-code-print-size-guide', 'qr-code-print-size-guide',
'qr-code-tracking-guide-2025', 'qr-code-tracking-guide-2025',
'qr-code-events', 'qr-code-events',
@@ -891,7 +891,7 @@ export const blogPosts: BlogPost[] = [
'business-card-qr-code', 'business-card-qr-code',
'qr-code-print-size-guide', 'qr-code-print-size-guide',
'qr-code-small-business', 'qr-code-small-business',
'dynamic-vs-static-qr-codes', 'static-vs-dynamic-qr-code',
], ],
authorName: 'Timo Knuth', authorName: 'Timo Knuth',
authorTitle: 'QR Code & Marketing Expert', authorTitle: 'QR Code & Marketing Expert',
@@ -1211,7 +1211,7 @@ END:VCARD</code></pre>
relatedSlugs: [ relatedSlugs: [
'qr-code-restaurant-menu', 'qr-code-restaurant-menu',
'business-card-qr-code', 'business-card-qr-code',
'dynamic-vs-static-qr-codes', 'static-vs-dynamic-qr-code',
'bulk-qr-code-generator-excel', 'bulk-qr-code-generator-excel',
], ],
authorName: 'Timo Knuth', authorName: 'Timo Knuth',
@@ -1392,7 +1392,7 @@ END:VCARD</code></pre>
'qr-code-analytics', 'qr-code-analytics',
'trackable-qr-codes', 'trackable-qr-codes',
'utm-parameter-qr-codes', 'utm-parameter-qr-codes',
'dynamic-vs-static-qr-codes', 'static-vs-dynamic-qr-code',
], ],
authorName: 'Timo Knuth', authorName: 'Timo Knuth',
authorTitle: 'QR Code & Marketing Expert', authorTitle: 'QR Code & Marketing Expert',
@@ -1430,7 +1430,7 @@ END:VCARD</code></pre>
<p>The partial workaround: encode a URL carrying UTM parameters. Your web analytics will attribute the session to that campaign. This tells you traffic arrived, but not that it arrived <em>by scan</em> — anyone typing or sharing that URL is counted identically. For a single poster it is adequate. Across several placements it falls apart, because you cannot separate them without a distinct URL per placement.</p> <p>The partial workaround: encode a URL carrying UTM parameters. Your web analytics will attribute the session to that campaign. This tells you traffic arrived, but not that it arrived <em>by scan</em> — anyone typing or sharing that URL is counted identically. For a single poster it is adequate. Across several placements it falls apart, because you cannot separate them without a distinct URL per placement.</p>
<p>Reliable scan-level tracking requires a dynamic code. See <a href="/blog/dynamic-vs-static-qr-codes">dynamic vs static QR codes</a>.</p> <p>Reliable scan-level tracking requires a dynamic code. See <a href="/blog/static-vs-dynamic-qr-code">static vs dynamic QR codes</a>.</p>
<h2>How a dynamic code produces data</h2> <h2>How a dynamic code produces data</h2>
<ol> <ol>
@@ -1752,7 +1752,7 @@ END:VCARD</code></pre>
relatedSlugs: [ relatedSlugs: [
'qr-code-api-documentation', 'qr-code-api-documentation',
'qr-code-tracking-guide-2025', 'qr-code-tracking-guide-2025',
'dynamic-vs-static-qr-codes', 'static-vs-dynamic-qr-code',
'qr-code-print-size-guide', 'qr-code-print-size-guide',
'barcode-generator-tool', 'barcode-generator-tool',
], ],
@@ -1824,7 +1824,7 @@ END:VCARD</code></pre>
<p>Dynamic codes encode a redirect, so the destination stays editable after printing and each scan can be counted. For a batch of 800 that is the difference between reprinting and editing a field.</p> <p>Dynamic codes encode a redirect, so the destination stays editable after printing and each scan can be counted. For a batch of 800 that is the difference between reprinting and editing a field.</p>
<p>The practical rule: if the printed item outlives the campaign, use dynamic. Packaging, signage and badges outlive almost every campaign. A one-week event flyer does not. More in <a href="/blog/dynamic-vs-static-qr-codes">dynamic vs static QR codes</a>.</p> <p>The practical rule: if the printed item outlives the campaign, use dynamic. Packaging, signage and badges outlive almost every campaign. A one-week event flyer does not. More in <a href="/blog/static-vs-dynamic-qr-code">static vs dynamic QR codes</a>.</p>
<h2>Five failures that break batches</h2> <h2>Five failures that break batches</h2>
<ul> <ul>
@@ -2085,7 +2085,7 @@ with open("codes.csv", newline="", encoding="utf-8") as f:
}, },
], ],
relatedSlugs: [ relatedSlugs: [
'dynamic-vs-static-qr-codes', 'static-vs-dynamic-qr-code',
'qr-code-print-size-guide', 'qr-code-print-size-guide',
'qr-code-small-business', 'qr-code-small-business',
'bulk-qr-code-generator-excel', 'bulk-qr-code-generator-excel',
@@ -2782,7 +2782,7 @@ with open("codes.csv", newline="", encoding="utf-8") as f:
'qr-code-tracking-guide-2025', 'qr-code-tracking-guide-2025',
'qr-code-analytics', 'qr-code-analytics',
'utm-parameter-qr-codes', 'utm-parameter-qr-codes',
'dynamic-vs-static-qr-codes', 'static-vs-dynamic-qr-code',
], ],
authorName: 'Timo Knuth', authorName: 'Timo Knuth',
authorTitle: 'QR Code & Marketing Expert', authorTitle: 'QR Code & Marketing Expert',
@@ -2876,7 +2876,7 @@ with open("codes.csv", newline="", encoding="utf-8") as f:
<p>Do not optimize every metric at once. First establish that a placement is scanned. Then improve the landing page or the offer if scans do not become useful visits. Separating those questions stops a weak poster from being blamed on a perfectly good destination, or the reverse.</p> <p>Do not optimize every metric at once. First establish that a placement is scanned. Then improve the landing page or the offer if scans do not become useful visits. Separating those questions stops a weak poster from being blamed on a perfectly good destination, or the reverse.</p>
<p>For the full measurement workflow including print campaigns, see the <a href="/blog/qr-code-tracking-guide-2025">QR code tracking guide</a>. For the underlying difference between the two code types, see <a href="/blog/dynamic-vs-static-qr-codes">dynamic vs static QR codes</a>.</p> <p>For the full measurement workflow including print campaigns, see the <a href="/blog/qr-code-tracking-guide-2025">QR code tracking guide</a>. For the underlying difference between the two code types, see <a href="/blog/static-vs-dynamic-qr-code">static vs dynamic QR codes</a>.</p>
</div>`, </div>`,
}, },
@@ -2962,7 +2962,7 @@ with open("codes.csv", newline="", encoding="utf-8") as f:
relatedSlugs: [ relatedSlugs: [
'trackable-qr-codes', 'trackable-qr-codes',
'qr-code-analytics', 'qr-code-analytics',
'dynamic-vs-static-qr-codes', 'static-vs-dynamic-qr-code',
'qr-code-tracking-guide-2025', 'qr-code-tracking-guide-2025',
], ],
authorName: 'Timo Knuth', authorName: 'Timo Knuth',
@@ -3554,7 +3554,7 @@ with open("codes.csv", newline="", encoding="utf-8") as f:
relatedSlugs: [ relatedSlugs: [
'vcard-qr-code-generator', 'vcard-qr-code-generator',
'qr-code-print-size-guide', 'qr-code-print-size-guide',
'dynamic-vs-static-qr-codes', 'static-vs-dynamic-qr-code',
], ],
authorName: 'Timo Knuth', authorName: 'Timo Knuth',
authorTitle: 'QR Code & Marketing Expert', authorTitle: 'QR Code & Marketing Expert',
@@ -3740,7 +3740,7 @@ with open("codes.csv", newline="", encoding="utf-8") as f:
'trackable-qr-codes', 'trackable-qr-codes',
'qr-code-analytics', 'qr-code-analytics',
'qr-code-events', 'qr-code-events',
'dynamic-vs-static-qr-codes', 'static-vs-dynamic-qr-code',
'qr-code-print-size-guide', 'qr-code-print-size-guide',
], ],
authorName: 'Timo Knuth', authorName: 'Timo Knuth',
@@ -3933,7 +3933,7 @@ with open("codes.csv", newline="", encoding="utf-8") as f:
], ],
relatedSlugs: [ relatedSlugs: [
'qr-code-tracking-guide-2025', 'qr-code-tracking-guide-2025',
'dynamic-vs-static-qr-codes', 'static-vs-dynamic-qr-code',
'qr-code-marketing', 'qr-code-marketing',
], ],
authorName: 'Timo Knuth', authorName: 'Timo Knuth',
@@ -4010,7 +4010,7 @@ with open("codes.csv", newline="", encoding="utf-8") as f:
<p><strong>Use your own domain.</strong> A redirect on a domain customers recognise is verifiable in the preview. A generic shortener domain is not.</p> <p><strong>Use your own domain.</strong> A redirect on a domain customers recognise is verifiable in the preview. A generic shortener domain is not.</p>
<p><strong>Prefer dynamic codes for anything long-lived.</strong> If a destination is compromised or has to change, you repoint it rather than replacing physical material. See <a href="/blog/dynamic-vs-static-qr-codes">dynamic vs static</a>.</p> <p><strong>Prefer dynamic codes for anything long-lived.</strong> If a destination is compromised or has to change, you repoint it rather than replacing physical material. See <a href="/blog/static-vs-dynamic-qr-code">static vs dynamic QR codes</a>.</p>
<p><strong>Never ask for credentials or card details after a scan.</strong> Beyond the direct risk, it trains your customers to do the exact thing that makes quishing work.</p> <p><strong>Never ask for credentials or card details after a scan.</strong> Beyond the direct risk, it trains your customers to do the exact thing that makes quishing work.</p>
@@ -4087,7 +4087,7 @@ with open("codes.csv", newline="", encoding="utf-8") as f:
relatedSlugs: [ relatedSlugs: [
'bulk-qr-code-generator-excel', 'bulk-qr-code-generator-excel',
'qr-code-tracking-guide-2025', 'qr-code-tracking-guide-2025',
'dynamic-vs-static-qr-codes', 'static-vs-dynamic-qr-code',
'qr-code-marketing', 'qr-code-marketing',
], ],
authorName: 'Timo Knuth', authorName: 'Timo Knuth',
@@ -4281,7 +4281,7 @@ with open("codes.csv", newline="", encoding="utf-8") as f:
relatedSlugs: [ relatedSlugs: [
'best-qr-code-generator-2026', 'best-qr-code-generator-2026',
'qr-code-small-business', 'qr-code-small-business',
'dynamic-vs-static-qr-codes', 'static-vs-dynamic-qr-code',
'trackable-qr-codes', 'trackable-qr-codes',
], ],
authorName: 'Timo Knuth', authorName: 'Timo Knuth',

View File

@@ -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 {

View File

@@ -5,6 +5,42 @@
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
/**
* Domain the session cookies are scoped to.
*
* Set `COOKIE_DOMAIN=.qrmaster.net` in production so one session is shared between
* www.qrmaster.net (marketing, login) and app.qrmaster.net (the app). Without it the
* cookie stays host-only and a user logged in on www would be anonymous on app.
*
* Only honoured in production on purpose: browsers reject dotted domains for
* `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) {
return undefined;
}
const domain = process.env.COOKIE_DOMAIN?.trim();
return domain ? domain : undefined;
}
/**
* 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
* `cookies.get()` ambiguous and staging logins flaky.
*
* Like COOKIE_DOMAIN this must be set at build time too, because process.env is inlined
* into the Edge middleware bundle.
*/
export function getAuthCookieName(): string {
return process.env.AUTH_COOKIE_NAME?.trim() || 'userId';
}
/** /**
* Get cookie options for authentication cookies * Get cookie options for authentication cookies
*/ */
@@ -13,7 +49,9 @@ export function getAuthCookieOptions() {
httpOnly: true, httpOnly: true,
secure: isProduction, // HTTPS only in production secure: isProduction, // HTTPS only in production
sameSite: 'lax' as const, sameSite: 'lax' as const,
path: '/', // Explicit so the expiry in buildExpiredCookieHeaders() matches
maxAge: 60 * 60 * 24 * 7, // 7 days maxAge: 60 * 60 * 24 * 7, // 7 days
domain: getCookieDomain(),
}; };
} }
@@ -28,9 +66,85 @@ export function getCsrfCookieOptions() {
sameSite: 'lax' as const, sameSite: 'lax' as const,
maxAge: 60 * 60 * 24, // 24 hours maxAge: 60 * 60 * 24, // 24 hours
path: '/', // Available on all paths 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 * Check if running in production
*/ */

View File

@@ -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>
&nbsp;·&nbsp; &nbsp;·&nbsp;
<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,

View File

@@ -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
View 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;
}
}

View File

@@ -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

View File

@@ -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." },

View File

@@ -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 {

View File

@@ -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 ?? {},

View File

@@ -31,7 +31,18 @@ export function organizationSchema() {
'@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
// and two unrelated Android apps ship as "QR Master". Listing the spellings
// people actually type (incl. the hyphenated form) helps Google tie those
// queries to this entity rather than a namesake.
alternateName: [
'QRMaster',
'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, url: SITE_URL,
logo: { logo: {
'@type': 'ImageObject', '@type': 'ImageObject',

View File

@@ -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());
} }

View File

@@ -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: [
/* /*