54 Commits

Author SHA1 Message Date
b278d275bb Polish social milestone chart curves 2026-08-17 11:19:41 +02:00
eb932ebdaa Publish milestones per channel and add Instagram
Consent is bound to the channel it was given for: approving a post on X says
nothing about Instagram. Publishing state moves from the SocialMilestone row
into SocialMilestonePost, one row per channel, where a missing row means no
consent. The dialog asks per channel, shows the text each one will publish and
keeps a separate handle for each; Instagram captions end in hashtags because a
link there is not clickable.

Also fixes three problems in the existing X path:

- A QR code already past several thresholds produced one prompt per threshold,
  and since the post quotes the current scan count, every one of them would
  have published the same number. Only the highest threshold is announced now.
- Detection ran after every unique scan and re-read the QR code's full scan
  history just to hit skipDuplicates. Known milestones are filtered first.
- A failed post stayed failed forever because the consent dialog only opens
  once. The queue now retries three times on its own, spaces first attempts by
  SOCIAL_MILESTONE_MIN_GAP_HOURS, and Settings lists every milestone per
  channel with restart and revoke.

The worker no longer renders the card itself; it downloads the image the app
renders at /s/m/<token>/og, which also serves the new square and portrait
formats. Instagram publishing stays off until SOCIAL_MILESTONE_CHANNELS and
SOCIAL_WORKER_CHANNELS both name it.

Schema changes are manual SQL, see sql/2026-08-16_*.sql. Run both before
deploying this version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 13:46:13 +02:00
55c04761ce Polish milestone dialog and one-time delivery 2026-08-14 23:43:20 +02:00
13879e3d3a Fix milestone publisher and social previews 2026-08-14 19:35:29 +02:00
4ec70ed30f Harden milestone sharing and X publishing 2026-08-14 18:19:55 +02:00
d8f7202bf6 Fix milestone sharing previews and publisher recovery 2026-08-14 14:33:29 +02:00
e7581e488d Fix milestone sharing and test worker routing 2026-08-14 14:03:05 +02:00
8ef5221f71 Show total scans in social milestones 2026-08-14 13:08:40 +02:00
8e34f97afb Align milestone charts and self-share flow 2026-08-14 13:04:09 +02:00
aa3b4d02ab Render milestone charts from real scan history 2026-08-14 12:59:37 +02:00
925540f3c6 Improve social milestone sharing flow 2026-08-14 12:31:10 +02:00
e0c32542f9 Detect social milestones when scans arrive 2026-08-14 11:48:34 +02:00
6081b9e6ae refactor: derive email sender address dynamically from SMTP_USER 2026-08-14 13:03:12 +02:00
9d1d3a2062 SMTP_USER 2026-08-14 13:02:59 +02:00
72392e8cec info instead of timo 2026-08-14 13:02:47 +02:00
f7d82aa5bd Add consented social milestone posting 2026-08-14 09:03:21 +02:00
14c429ff30 fix 2026-08-13 10:07:57 -05:00
d2c5f2848a network 0.0.0.0 2026-08-13 08:32:11 -05:00
31cba6d883 Anpassungen 2026-08-13 06:10:01 -05: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
6d29aa0be1 Mozzila extension 2026-08-06 10:58:19 +02:00
ca605f8852 SEO blog post V2 2026-08-05 22:28:34 +02:00
8c50bf71c5 SEO blog post 2026-08-05 19:32:52 +02:00
94bf162062 Extension 2026-08-03 11:38:29 +02:00
49c85288a1 Add developer ecosystem page and footer links 2026-08-02 00:11:29 +02:00
977fcdccf3 press 2026-07-30 10:00:35 +02:00
273182d32c signup code 2026-07-29 18:10:07 +02:00
8f29efaf50 seo + press V3 2026-07-29 17:40:26 +02:00
914e312a1b seo + press V2 2026-07-29 16:35:56 +02:00
e73075cdbc seo + press 2026-07-29 15:13:18 +02:00
e6fc428b15 Email marketing V3 2026-07-29 10:52:29 +02:00
6fd0ed8522 Email marketing V2 2026-07-29 10:42:21 +02:00
11fdec610f Email marketing 2026-07-29 00:02:50 +02:00
e1b6d5fcc1 email marketing 2026-07-28 13:29:35 +02:00
ab63d4b916 fix V2 2026-07-27 20:47:36 +02:00
90dfedf098 fix 2026-07-27 18:29:06 +02:00
70d97aa970 Copy overhaul + qr designs 2026-07-27 17:54:59 +02:00
033bc7e29d Copy audit: fix static-bulk disclosure, remove unsourced stat, soften overclaim, strengthen subheadlines for headline-checklist compliance 2026-07-26 22:20:53 +02:00
62ac1ad819 Copy updates: pricing, marketing-campaigns page, signup, tool pages 2026-07-26 21:57:06 +02:00
260 changed files with 42823 additions and 11500 deletions

View File

@@ -6,6 +6,12 @@
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3050
},
{
"name": "dev-node",
"runtimeExecutable": "node",
"runtimeArgs": ["node_modules/next/dist/bin/next", "dev", "-p", "3050"],
"port": 3050
}
]
}

View File

@@ -16,15 +16,43 @@ REDIS_URL=redis://redis:6379
IP_SALT=CHANGE_ME_SALT
ENABLE_DEMO=true
# SMTP (for welcome + retention emails via nodemailer)
# SMTP & Email Senders (for welcome + retention emails via nodemailer / resend)
SMTP_HOST=smtp.qrmaster.net
SMTP_PORT=465
SMTP_USER=timo@qrmaster.net
SMTP_PASS=
EMAIL_FROM="Timo from QR Master <timo@qrmaster.net>"
EMAIL_FROM_SECURITY="QR Master Security <noreply@qrmaster.net>"
EMAIL_REPLY_TO="support@qrmaster.net"
# Cron job protection — generate with: openssl rand -base64 32
CRON_SECRET=
# Leave empty in production for 1,000 / 10,000 unique scans. Test only, e.g. 1,2.
SOCIAL_MILESTONE_THRESHOLDS=
# Leave empty for immediate publishing after consent. Set 24 to enable a revocation window.
SOCIAL_MILESTONE_POST_DELAY_HOURS=
# Hours between two brand posts (default 24). Set 0 on test to publish back to back.
SOCIAL_MILESTONE_MIN_GAP_HOURS=
SOCIAL_MILESTONE_POSTING_ENABLED=false
SOCIAL_WORKER_INTERVAL_SECONDS=10
X_API_KEY=
X_API_SECRET=
X_ACCESS_TOKEN=
X_ACCESS_TOKEN_SECRET=
# Channels the consent dialog offers (app) and the worker publishes (worker).
# Keep both in sync: x / x,instagram
SOCIAL_MILESTONE_CHANNELS=x
SOCIAL_WORKER_CHANNELS=x
# Instagram Business account for QRMaster.net, see docs/automations/social-accounts-and-jobs.md
INSTAGRAM_USER_ID=
INSTAGRAM_ACCESS_TOKEN=
GRAPH_API_VERSION=v22.0
# Guards POST/DELETE on /api/social-assets, the public image host Instagram
# pulls from. Unrelated to TikTok posting; falls back to TIKTOK_ADMIN_KEY.
SOCIAL_ASSET_ADMIN_KEY=
# TikTok OAuth / posting (server-side only)
# Source of truth for cron posting: QRMaster server .env
# Production example: https://qrmaster.net/api/tiktok/callback

22
.gitignore vendored
View File

@@ -10,6 +10,7 @@
# next.js
/.next/
/.next-stale-module-cache/
/out/
# production
@@ -27,6 +28,7 @@ yarn-error.log*
# local env files
.env*.local
.env
.env.test
# vercel
.vercel
@@ -42,6 +44,9 @@ next-env.d.ts
docker-compose.override.yml
*.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/
# logs
@@ -75,3 +80,20 @@ test_email.py
meta-fix.js
read-inbox.mjs
quora_antwort_statisch_dynamisch.txt
# Local blog audit reports and temporary snapshots
scratch_blog_analysis.json
scratch_scored_blog_posts.json
src/lib/blog-data.snapshot-*.ts
# Local developer-package workspaces and unreferenced generated media
/packages/
/public/Events/
/public/Gyms/
/public/Hotels/
/public/Real Estate/
/public/restaurant/
/.qr-master-api-health-state
# Python worker bytecode
__pycache__/

177
BLOG-IDEEN-BACKLOG.md Normal file
View File

@@ -0,0 +1,177 @@
# Blog-Ideen-Backlog qrmaster.net
Erstellt: 2026-08-04 · Datenbasis: GSC-Export 2026-08-03 (1.001 Suchanfragen, letzte 3 Monate)
Alle Themen unten haben **belegte Nachfrage aus deinen eigenen Impressionen** — keine Keyword-Tool-Schätzungen. Die Impressionszahl ist, was qrmaster.net in drei Monaten bereits ausgespielt bekam, fast durchgehend bei 0 Klicks, weil die Positionen zwischen 35 und 70 liegen.
Nach Abzug der Marken-Suchanfragen (`qr master`, `qrmaster` etc.) und der bereits abgedeckten Themen bleiben **8.018 Impressionen über 263 Suchanfragen** ohne passenden Blogpost.
---
## Canonical & Zweitverwertung
Du wolltest die Posts auch anderswo veröffentlichen können. Die Reihenfolge entscheidet, ob das hilft oder schadet.
**Regel: qrmaster.net veröffentlicht zuerst und bleibt das Original.**
1. Post auf qrmaster.net veröffentlichen. Die Blogseiten setzen bereits einen korrekten Self-Canonical (`https://www.qrmaster.net/blog/<slug>`) — geprüft, nichts zu tun.
2. **Warten, bis Google die Seite indexiert hat.** In der Search Console unter „URL-Prüfung" nachsehen. Erst wenn die Seite im Index ist, syndizieren. Wer gleichzeitig veröffentlicht, riskiert, dass die stärkere Domain zuerst indexiert wird und als Original gilt.
3. Dann erst auf Drittplattformen stellen, jeweils mit Canonical zurück auf qrmaster.net.
| Plattform | Canonical setzen | Hinweis |
|---|---|---|
| DEV.to | Feld `canonical_url` im Frontmatter | Sauber unterstützt, beste Option für die technischen Posts |
| Hashnode | Einstellung „Original article URL" | Sauber unterstützt |
| Medium | „Import story" statt manuellem Einfügen | Setzt Canonical automatisch; manuelles Einfügen tut das **nicht** |
| LinkedIn Artikel | Nicht möglich | Kein Canonical-Support — dort nur Auszug plus Link posten, nie den Volltext |
| Reddit | Nicht möglich | Kein Volltext, sondern eigenständiger Beitrag mit Link |
**Wichtig:** Ein Canonical ist ein Hinweis, keine Anweisung. Wenn die Kopie deutlich mehr Links bekommt, kann Google sie trotzdem bevorzugen. Deshalb Schritt 2 nicht überspringen.
**Praktischer Hinweis zu den technischen Posts:** `qr-code-api-documentation`, `bulk-qr-code-generator-excel`, `custom-qr-code-design`, `qr-code-print-size-guide` und `location-qr-code` enthalten Code, Formate und Spezifikationsdetails — die funktionieren auf DEV.to. Die reinen Marketing-Posts eher nicht.
---
## Priorität 1 — Höchstes Volumen (Woche 18)
### 1. Twitter/X QR Codes
**591 Impressionen** · `twitter qr code generator` (140, Pos. 38,9), `twitter qr code` (135, Pos. 53), `qr code for twitter` (86), `how to scan twitter qr code` (43)
Winkel: X hat den eigenen QR-Code aus der App weitgehend entfernt — genau deshalb die Suchanfragen. Erklären, wie man stattdessen einen Profil-Link-QR baut, und `how to scan twitter qr code` als eigenes H2 beantworten.
### 2. Facebook QR Codes
**609 Impressionen** · `facebook qr code generator` (113, Pos. 49,9), `qr code for facebook page free` (33), `facebook page qr code generator` (29), `code generator facebook` (45)
Winkel: Unterscheidung Seite vs. Profil vs. Gruppe vs. Event — vier verschiedene URL-Formate, die unterschiedlich funktionieren. Achtung: `code generator facebook` meint teils Facebooks Login-Code-Generator, also andere Intention. Im Text abgrenzen.
### 3. URL- und Redirect-Grundlagen
**787 Impressionen** · `create a qr code for a url` (58), `url qr code` (53), `redirect qr code` (47), `create qr code from url` (30), `turn url into qr code` (26)
Winkel: Der größte Cluster überhaupt und rein instruktiv. Ein sauberer Grundlagen-Post, der auf alle Spezialfälle weiterverlinkt — wird zur internen Verteilerseite.
### 4. Beaconstac-Alternative
**718 Impressionen** · `beaconstac` (96), `beaconstac qr code generator` (75), `beaconstac vs popl` (67), `beaconstac vs mobilo` (57), `blinq vs beaconstac` (42), `beaconstac alternative` (34)
Winkel: Hohe Kaufintention. Deckt sich mit dem `/vs/`-Backlog in CLAUDE.md. Die `X vs Y`-Anfragen sind Vergleiche zwischen **Wettbewerbern untereinander** — dort taucht QR Master als dritte Option auf.
### 5. Krypto- und Wallet-QR-Codes
**441 Impressionen** · `usdt qr code generator` (85, **Pos. 13,1**), `blockchain secure qr code generator` (48), `bitcoin qr code generator` (46), `crypto qr code generator` (37), `trust wallet qr code generator` (6)
Winkel: Rankt bereits am besten von allen ungedeckten Themen. Substanz: Adressformate je Netzwerk, warum eine falsche Netzwerkwahl Geld vernichtet, Prüfsummen. Sicherheitskritisch — sorgfältig recherchieren.
### 6. PayPal- und Zahlungs-QR-Codes
**430 Impressionen** · `paypal qr code generator` (50), `qr-code zahlungen` (50), `qr code for payment generator` (19), `create paypal qr code` (19), `all in one qr code for payment` (7)
Winkel: PayPal.Me-Linkformat, Abgrenzung zu EPC/GiroCode im EU-Raum, was rechtlich zu beachten ist.
### 7. SMS-QR-Codes
**330 Impressionen** · `sms qr code generator` (51), `sms qr code` (48), `qr code sms generator` (27), `create sms qr code` (22)
Winkel: Das `SMSTO:`-Format und die vorbefüllte Nachricht — technisch dieselbe Präzision wie beim WhatsApp-Post, dieselbe Fehlerquelle bei der Nummernformatierung.
### 8. QR Codes für Immobilien
**326 Impressionen** · `qr code real estate signs` (41), `qr code generator for real estate` (41), `real estate signs with qr codes` (24), `qr for real estate` (21)
Winkel: Schilder im Freien — Größe für Lesung aus Autoentfernung, Wetterfestigkeit, dynamische Codes für Objektwechsel. Verweist auf den Print-Size-Guide.
---
## Priorität 2 — Mittleres Volumen (Woche 918)
### 9. Flowcode-Alternative
**246 Impressionen** · `flowcode alternative` (51, **Pos. 18,3**), `flowcode qr competitors` (47), `flowcode competitors` (38), `flowcode pricing` (21)
Steht bereits auf Position 18 — kürzester Weg auf Seite 1 im Wettbewerbsumfeld.
### 10. Bearbeitbare QR Codes / Ziel ändern
**217 Impressionen** · `changeable qr code` (63), `dynamic qr code editing` (51), `editable qr code` (25), `convert static qr code to dynamic` (59)
Winkel: Beantwortet die Frage „kann ich einen gedruckten Code noch ändern" direkt. Ehrliche Antwort: statisch nein, dynamisch ja — und was man tut, wenn schon statisch gedruckt wurde.
### 11. TikTok QR Codes
**209 Impressionen** · `tiktok qr code generator` (113, Pos. 25,4), `qr code generator tiktok` (27), `free tiktok qr code generator` (23)
Es gibt bereits `/tools/tiktok-qr-code` (465 Impr., 9 Klicks) — der Post wäre Support-Content dafür.
### 12. Zoom QR Codes
**200 Impressionen** · `zoom qr code generator` (56, Pos. 28,8), `qr code for zoom meeting` (23), `create qr code from zoom link` (12)
Winkel: Wie der Teams-Post — welcher Meeting-Link haltbar ist, Sicherheit bei sichtbaren Codes.
### 13. YouTube QR Codes
**196 Impressionen** · `youtube qr code generator` (67), `qr code youtube generator` (25), `youtube channel qr code generator` (14), `youtube playlist qr code` (7)
`youtube-qr-code-guide` existiert bereits (936 Wörter, 0 Impressionen) — **prüfen statt neu schreiben**, warum er nicht ausgespielt wird.
### 14. Verpackung und Produkt-QR
**196 Impressionen** · `qr code packaging` (38), `qr codes for wine labels` (25), `qr code for wine bottle` (25), `qr code retail` (19), `qr code for product tracking` (10)
Winkel: Gebogene Flächen, Materialeinflüsse, GS1 Digital Link als Zukunftsformat.
### 15. Hotel-QR-Codes
**159 Impressionen** · `hotel info via qr code` (63), `qr code for hotel` (23), `hotel qr code` (21), `hotel check in qr code` (5)
Auch `system qr dla hoteli` (72, Polnisch) — Nachfrage besteht international.
### 16. E-Mail-QR-Codes
**117 Impressionen** · `qr code generator for email` (18), `qr code email generator` (18), `free email qr code generator` (12), `qr code to email` (3)
Winkel: `mailto:`-Format mit vorbefülltem Betreff und Text, URL-Encoding — kurzer, technisch präziser Post.
### 17. Café- und Gastronomie-QR
**122 Impressionen** · `qr code cafe` (48), `cafe qr code` (25), `qr cafe` (21), `qr code holder for restaurant` (6)
Achtung: Überschneidung mit `/restaurants`. Abgrenzen oder dorthin verlinken statt Kannibalisierung erzeugen.
### 18. Flyer und Print-Kampagnen
**118 Impressionen** · `qr codes on flyers` (58), `how to track qr code scans from a print campaign` (43, **Pos. 9,9**), `how to put qr code on flyer` (4)
Die Print-Kampagnen-Anfrage steht schon auf Position 9,9 und ist bereits als H2 im Tracking-Guide. Ein eigener Post wäre die Vertiefung.
### 19. Kalender- und Termin-QR
**97 Impressionen** · `qr code for calendar event` (22), `free qr code generator calendar event` (13), `create qr code calendar event` (11)
Winkel: Das iCal/VEVENT-Format direkt im Code — funktioniert offline, deshalb der stärkste Event-Anwendungsfall.
---
## Priorität 3 — Nischen mit klarer Intention (Woche 1930)
### 20. Kirchen, Schulen, Bibliotheken
**77 Impressionen** · `qr for schools` (16), `qr code for church` (13), `qr code library` (10), `church donation qr code` (9), `campus qr code` (5)
### 21. Gesundheitswesen, Versicherung, Kanzleien
**50 Impressionen** · `qr code insurance industry` (18), `qr codes for legal services` (8), `qr code for dental clinics` (8), `qr codes for healthcare patient intake forms` (6)
Winkel: Datenschutz und Einwilligung bei Patientenformularen — Thema, das andere meiden.
### 22. Bitly als Alternative
**57 Impressionen** · `bitly qr code` (16), `bitly qr code alternative` (12), `bitly vs beaconstac qr codes` (18, **Pos. 14,9**), `bit.ly qr code generator` (6)
### 23. WLAN-QR-Codes
**11 Impressionen im Export**, aber `/tools/wifi-qr-code` hat 70 Impressionen bei Position 22,4
Winkel: Das `WIFI:`-Format, WPA vs. WPA2, Sonderzeichen im Passwort — kurzer technischer Post mit hoher Praxisrelevanz.
### 2430. Weitere belegte Einzelthemen
- **Stadien und Veranstaltungsorte** — `qr code in sports stadium` (17, Pos. 20,2), `qr codes for stadiums` (9)
- **Flughäfen** — `airport qr code` (10, Pos. 19,9), `qr code airport` (9)
- **Friseure und Barbershops** — `qr barber` (13, **Pos. 8,9**), `barber business cards with qr code` (4)
- **Fitness und Yoga** — `yoga qr code` (5), `short code for yoga studios` (7)
- **Foodtrucks** — `food truck qr code` (3, Pos. 46)
- **Schmuck und Inventar** — `jewelry inventory qr code solutions` (6), `bijuterii qr code` (8)
- **Messen und Networking** — `qr codes for event networking` (7, Pos. 15), `qr codes at trade shows` (7)
---
## Deutscher Markt — separate Entscheidung
**400 Impressionen**, alle bei 0 Klicks und Position 55103:
`qr code erstellen gratis` (109, Pos. 100,4), `kostenlos qr code erstellen` (66, Pos. 102,7), `qr-code zahlungen` (50), `dynamische qr-codes` (45), `qr visitenkarte` (11), `qr code visitenkarte` (9)
Positionen über 100 bedeuten: Google kennt die Seiten, hält sie aber für die schlechteste verfügbare Antwort. Die 13 `/de/`-Seiten haben zusammen 194 Impressionen und **0 Klicks**.
Das ist keine Content-Frage, sondern die offene Grundsatzentscheidung aus dem Umsetzungsplan: eine echte deutsche Sektion mit hreflang und eigenständigen Texten, oder deindexieren. Einzelne deutsche Blogposts ohne diese Entscheidung verschärfen das Problem nur.
---
## Reihenfolge-Empfehlung
Nicht streng nach Impressionen gehen. Diese vier zuerst, weil sie am nächsten an Seite 1 stehen:
1. **Flowcode-Alternative** — Position 18,3
2. **Krypto/USDT** — Position 13,1
3. **TikTok** — Position 25,4
4. **Zoom** — Position 28,8
Danach die Volumen-Themen aus Priorität 1. Ein Post auf Position 25 auf Seite 1 zu heben bringt kurzfristig mehr als ein Post auf Position 55 mit dreifachem Volumen.
## Format-Vorlage
Die 22 überarbeiteten Posts folgen einem Muster, das sich bewährt hat und für neue übernommen werden sollte:
- Direkte Antwort im ersten Absatz, keine Einleitung über die Geschichte des QR-Codes
- Ein konkretes Format, Feld oder Zahlenbeispiel, das man ohne Tool nachbauen kann
- Eine Tabelle, die zwei Optionen ehrlich gegenüberstellt — inklusive der Zeile, in der die kostenlose Variante gewinnt
- Ein Abschnitt „was schiefgeht" mit benannten Fehlermodi statt allgemeiner Tipps
- Interne Links auf die passende Tool- oder Money-Page
- Quellen, die keine Wettbewerber sind

View File

@@ -0,0 +1,131 @@
# Checkliste - Stand nach der Umsetzung
27. Juli 2026. 118 Dateien, 0 Syntaxfehler, Prisma-Schema valide, beide i18n-JSONs valide, keine langen Striche.
---
## FERTIG
### `/create` - Limit-Moment
- [x] Redirect auf `/pricing` entfernt, Formularzustand überlebt den 403
- [x] `UpgradeModal.tsx` mit drei Anlässen: `limit`, `logo`, `shapes`
- [x] Direkt-Checkout mit `returnPath` (pfadvalidiert gegen offene Redirects)
- [x] Option "bestehenden Code pausieren", danach Auto-Retry des Speicherns
- [x] Option "als statischen Code weitermachen"
- [x] Scan-Zahlen der letzten 30 Tage je Code, damit die Pausier-Entscheidung informiert ist
- [x] PostHog-Events für alle vier Ausgänge
### Limit-Logik und gestopfte Löcher
- [x] `POST /api/qrs` und `GET /api/user/stats` zählen nur noch `status: 'ACTIVE'`
- [x] `GET /api/qrs` liefert `scans30d` je Code
- [x] `PATCH /api/qrs/[id]` akzeptiert `status` (kannte es vorher nicht, Pausieren wäre wirkungslos gewesen)
- [x] Reaktivieren prüft gegen das Kontingent (sonst Limit-Umgehung durch pausieren/neu/reaktivieren)
- [x] `bulk-creation` lädt das Restkontingent serverseitig nach jedem Lauf
### `/bulk-creation`
- [x] Stille Fehler beendet: fehlgeschlagene Zeilen werden gesammelt statt verschluckt
- [x] Ergebnis-Panel mit Zeilennummer, Titel und Grund je Fehlzeile
- [x] CSV-Download der fehlenden Zeilen
- [x] Grüner Toast nur bei vollständigem Lauf
- [x] Drei deutsche Toasts auf Englisch
### Upgrade-Wege
- [x] Neue Seite `/upgrade` in der `(app)`-Route-Group, Sidebar bleibt
- [x] Alle 6 In-App-Links umgebogen: AppLayout, Dashboard, Settings (2x), Bulk (2x)
- [x] `reason`- und `from`-Parameter, `from` geht als `returnPath` in den Checkout
### QR-Design
- [x] Showcase von Canvas auf **SVG** portiert (`lib/qr-shapes.ts`)
- [x] `StyledQRCode.tsx` für die Live-Vorschau, `lib/render-qr-svg.ts` für Bulk - gemeinsame Shape-Funktionen, können nicht auseinanderlaufen
- [x] 11 Modulformen, plangestaffelt: Free `square`, Pro 4, Business alle
- [x] Eye-Frames und Eye-Balls getrennt wählbar
- [x] Farbverläufe linear und radial, Business
- [x] Gesperrte Formen sind klickbar: Vorschau zuerst, Modal danach
- [x] Automatische Anhebung der Fehlerkorrektur bei kritischen Formen und bei Logo, mit sichtbarer Begründung
- [x] Druckgrößen- und Test-Scan-Hinweis
- [x] Toter `toPng`-Aufruf im SVG-Download entfernt
### Scanbarkeit - gemessen, nicht geschätzt
Gerendert und mit einem echten Decoder zurückgelesen, 5 Inhalte x mehrere Auflösungen:
- [x] Alle 11 Modulformen 5/5
- [x] Eye-Balls square, rounded, circle, diamond, hexagon 5/5
- [x] **star als Eye-Ball 3/5 - entfernt**
- [x] **Eye-Frames circle, leaf, flower, hexagon 0/15 - entfernt**
- [x] **Rundungsradius 0.28 0/15, auf 0.10 korrigiert (15/15)**
- [x] Alle 10 verbleibenden Eye-Kombinationen 30/30
- [x] Bulk-Renderer separat geprüft, inklusive Verlauf: 12/12
### Design-Vorlagen
- [x] `QRDesignPreset`-Modell in Prisma, Relation am User
- [x] `GET/POST/DELETE /api/design-presets`, Business-gated, CSRF-geschützt
- [x] Gleicher Name überschreibt statt Duplikat, Obergrenze 50
- [x] Speichern und Anwenden in `/create`
- [x] Preset-Auswahl im Bulk-Flow: ein Design für den ganzen Upload
### Retention-Mails
- [x] Tag-3-Betreff entschärft
- [x] Tag-7 zur verhaltensbasierten Limit-Mail umgebaut
- [x] Tabellenzeile `CSV export ✓/✓` gestrichen, `Brand colors` ersetzt (war nach der Farbfreigabe falsch)
- [x] Neue Erster-Scan-Mail, einziger Trigger ohne Kalender
- [x] Tag-30 auf eigene Scan-Zahlen, unbelegte Testimonial-Behauptung gestrichen
- [x] Tag-30 entfällt bei null Scans
- [x] Cron-Route neu, vier Trigger
### Farben ab Free und Folge-Copy
- [x] `canCustomizeColors = true`, neue Gates `canUseShapes`, `canUseLogo`, `canUseFullDesign`
- [x] `en.json`, `PricingClient`, Dashboard-Pro-Karte
- [x] `/alternatives/flowcode`, 5 Stellen
- [x] `/pricing` FAQ: Bulk-Antwort war noch static-only
- [x] `competitor-data.ts`: Vergleichszeile führte Farben als Pro
### Formales
- [x] 729 lange Striche ersetzt, keine mehr im Quellcode
- [x] CRLF-Zeilenenden durchgehend erhalten
- [x] SQL-Datei aktualisiert: Block 4 ist jetzt Pflicht, nicht optional
---
## NICHT GEBAUT
### Vor dem Deploy zwingend
- [ ] **SQL ausführen** (`sql/2026-07-27_cro_retention_design.txt`), alle sechs Blöcke
- [ ] **`npx prisma generate`** - ohne das kennt der Client `QRDesignPreset` nicht und `/api/design-presets` wirft zur Laufzeit
- [ ] **Block 3.2** - sonst geht die Erster-Scan-Mail an die gesamte Bestandsbasis
- [ ] Entscheidung zu Block 3.1
### Nicht getestet
- [ ] Kein Browser-Durchlauf. Der Renderer ist gegen einen Decoder geprüft, nicht im echten DOM
- [ ] PNG-Download über `html-to-image` mit dem neuen SVG nicht verifiziert
- [ ] SVG-Download nicht verifiziert
- [ ] Logo-Overlay im neuen Renderer nicht visuell geprüft
- [ ] Stripe-Checkout mit `returnPath` nicht live durchlaufen
- [ ] Pausieren und Reaktivieren nicht gegen echte Daten
- [ ] Preset speichern, laden, auf Bulk anwenden nicht gegen echte Daten
### Bewusst weggelassen
- [ ] Rahmen mit Label als Vektor. Die bestehenden Rahmen sind HTML um den QR herum; der SVG-Download fällt dort weiterhin auf PNG zurück und sagt es
- [ ] Logo-Formen (Punkt, Herz, Blitz, WLAN) aus der Showcase
- [ ] Eye-Frames circle, leaf, flower, hexagon - Messergebnis 0/15
- [ ] star als Eye-Ball - 3/5
### Offene inhaltliche Frage
- [ ] Tag-30-Mail: gab es die Gespräche mit Pro-Nutzern über Branding? Bei Ja gehört ein wörtliches Zitat rein
### Vorbehalt zur Messung
Getestet wurde mit OpenCVs Decoder. Der ist strenger als iPhone- und Android-Kameras. Es ist möglich, dass ein Kreis-Eye-Frame auf echten Geräten funktioniert - aber 0 von 15 ist kein Rauschen, und das ist nichts, was man ungetestet auf Druckmaterial loslässt.

View File

@@ -259,6 +259,56 @@ ALTER TYPE "ContentType" ADD VALUE 'BARCODE';
- Sitemap generation via next-sitemap
- Google Indexing API + IndexNow submission scripts available
## AI SEO / AEO Tracking (AI Answer Engine Visibility)
Ongoing effort to increase QR Master's presence in AI-generated answers (ChatGPT, Perplexity, AI Overviews, etc.) for QR-code-generator-related queries. Update this section after each audit/build cycle so future sessions build on prior findings instead of re-deriving them.
### Baseline audit (2026-07-22)
Query-fanout data (what an AI model searches for while answering a prompt) showed QR Master already being cited in AI-generated answers for 3 of 9 tested query themes: "best QR code generator for marketing campaigns", "QR code generator with analytics and tracking", and "affordable dynamic QR code generator for small business" — positioned as the budget/SMB pick alongside Uniqode, QR TIGER, Bitly, Flowcode, and Hovercode.
No QR Master citation appeared for: "best dynamic QR code generator 2026", "best QR code generator for businesses", "best QR code generator for agencies", "alternatives to QR TIGER", "QR code generator with unlimited scans", "free dynamic QR code generator unlimited scans".
Existing on-site AEO infrastructure found during the audit:
- `public/llms.txt` present — lists core pages and cornerstone guides for AI retrieval
- `/alternatives/` pages exist only for: qr-code-generator.com, Flowcode, Beaconstac, Bitly
- `/vs/` pages exist only for: Beaconstac
- `/compare/[slug]` dynamic comparison route exists (e.g. `free-vs-paid-qr-code-generator`)
- Blog cornerstones: best-qr-code-generator-2026, free-vs-paid-qr-generator, qr-code-tracking-guide-2025, dynamic-vs-static-qr-codes, qr-code-small-business, qr-code-scan-statistics-2026, etc.
Pattern observed: QR Master gets cited by AI models only where first-party comparison/guide content already exists on the domain. The fanout queries repeatedly run `site:` searches against Uniqode, QR TIGER (qrcode-tiger.com), Hovercode, Scanova, and QRCodeChimp — none of which currently have a matching QR Master alternatives/vs page.
### Priority backlog (from this audit)
1. Build `/alternatives/uniqode`, `/alternatives/qr-code-tiger`, `/alternatives/hovercode` (highest fanout frequency, in that order)
2. Build matching `/vs/uniqode`, `/vs/qr-code-tiger`, `/vs/hovercode` (same format as `/vs/beaconstac`)
3. New cornerstone guide: "QR code generator for agencies" (white-label, bulk creation, API) — zero QR Master positioning currently exists for this query cluster
4. Update `public/llms.txt` once the new pages ship, so they enter the AI retrieval list
5. Re-run the same query-fanout test periodically to track citation-rate changes over time
### Additional confirmed win — "dynamic barcode generator" (2026-07-22, same session)
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 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
### Docker (Self-Hosted)

71
CTR_REWRITE_2026-07-27.md Normal file
View File

@@ -0,0 +1,71 @@
# SERP-CTR Rewrite — 27. Juli 2026
Grundlage: GSC-Export 28 Tage (8.944 Impressionen, 52 Klicks, 0,58% CTR, Ø-Position 33,0)
Methodik: `direct-response-copywriting` + Positioning Decision vom 26.07.2026 (Direction 1 als Positionierung, Direction 2 als Proof-Layer)
## Ehrliche Einordnung des 510%-Ziels
Site-weite CTR von 510% ist bei Ø-Position 33 **nicht durch Copy erreichbar**. CTR ist primär eine Funktion der Position:
| Position | Erwartete CTR |
|---|---|
| 1 | 2840% |
| 3 | 1011% |
| 5 | 56% |
| 10 | 23% |
| 15 | 12% |
| 30+ | <0,3% |
Die zwei größten Impression-Seiten (`/dynamic-qr-code-generator` 1.694 Imp @ Pos 33,5 und `/qr-code-tracking` 1.023 Imp @ Pos 34,0) haben 0% CTR. Das ist bei Position 33 **normal** — 31% aller Impressionen der Site liegen auf Seite 4 der SERP. Kein Title der Welt repariert das; das ist ein Ranking-Thema.
**Was Copy realistisch leisten kann:**
- Seiten auf Pos 115 auf ihren Positions-Benchmark heben → aus 52 Klicks werden ca. 90120
- Die großen Volumenseiten so vorbereiten, dass sie beim Aufstieg auf Seite 1 sofort auf Benchmark klicken statt bei 2%
510% site-weit wird erst realistisch, wenn die Ø-Position unter ~10 liegt. Dann trägt diese Copy den Unterschied.
## Die drei strukturellen Fehler, die behoben wurden
**1. Deutsche Halbsätze in englischen Descriptions.** 97% der Impressionen kommen aus USA/Indien/englischsprachigen Märkten. Trotzdem stand in den Tool-Descriptions „Teams QR Code erstellen", „Zoom QR Code erstellen", „TikTok QR Code erstellen", „Erstelle Bitcoin & Ethereum QR Codes", „Standort teilen leicht gemacht". Für einen englischen Sucher liest sich das wie maschinell übersetzter Spam. Das ist die wahrscheinlichste Ursache für 0,61% CTR auf Pos 10 (`barbershops`) und 0,65% auf Pos 17 (`zoom`).
**2. `| QR Master`-Suffix und 65+ Zeichen lange Titles.** Google schneidet bei ~580px ab. `Free Microsoft Teams QR Code Generator | Join Meetings | QR Master` (66 Zeichen) wurde in der SERP abgeschnitten — der Nutzen verschwand im „…". Alle neuen Titles liegen bei 4255 Zeichen.
**3. Null Pattern Interruption.** Jedes Ergebnis in dieser SERP sagt „Free QR Code Generator". Die alten Descriptions sagten „Instant and free", „Free & Easy" — die vagesten möglichen Claims. Neu: konkrete, falsifizierbare Spezifika (3 dynamische Codes gratis, 1.000 Codes aus einem Spreadsheet, 42 Zeichen Wallet-Adresse, EAN-13/UPC-A/Code 128).
## Wichtigste Änderungen
| Seite | Imp | CTR | Pos | Kernänderung |
|---|---|---|---|---|
| `/` | 1.046 | 4,88% | 4,6 | Title: Mechanismus statt Kategorie. Description: 3 verifizierte Zahlen statt Feature-Liste. H1: „The Link Doesn't Have to Stay Wrong Once It's Printed" |
| `/qr-code-tracking` | 1.023 | 0% | 34,0 | Title war „QR Code Tracking: Track QR Code Scans" — redundant, kein Nutzenversprechen. Neu: „See Which Placement Drove the Scan" (= Main-Headline-Idee aus Offer Brief §14) |
| `/dynamic-qr-code-generator` | 1.694 | 0% | 33,5 | Title war schon gut, blieb. Description auf Schmerz + Zahlen umgestellt |
| `/tools/google-review-qr-code` | 566 | 0,18% | 24,8 | Description führt jetzt mit dem Moment: „while the customer is still standing there" |
| `/tools/teams-qr-code` | 277 | 3,25% | 9,6 | Title von 66 auf 48 Zeichen, Deutsch raus |
| `/qr-code-for/barbershops` | 163 | 0,61% | 10,0 | metaTitle war 68 Zeichen → Fallback griff auf generisches „QR Codes for Barbershops \| QR Master". Jetzt 44 Zeichen mit Nutzen |
| `/learn` | 226 | 0,88% | 11,3 | Zielt auf „qr mastery" (130 Imp @ Pos 5,1). Description führt jetzt mit den Problemen statt mit „Learning Hub" |
| `/bulk-qr-code-generator` | 296 | 2,36% | 23,1 | Description nennt offen „Output is static — not dynamic or trackable" (Belief 5 / Proof-Layer) |
Zusätzlich überarbeitet: crypto, facebook, url, vcard, instagram, tiktok, twitter, zoom, geolocation, barcode-generator, custom-qr-code-generator, alternatives/beaconstac, blog/microsoft-teams-qr-code.
## Zwei Korrekturen an bestehenden Claims
- **`/alternatives/beaconstac`**: Erster Entwurf enthielt „Not €99+" über Uniqode. Der Offer Brief verifiziert Uniqodes Preis **nicht** (nur „G2-Muster: teuer für kleine Teams"). Behauptung entfernt — genannt werden nur die eigenen, verifizierten Preise €0/€9/€29.
- **Pricing-Card**: „Bulk QR Creation (up to 1,000)" → „(up to 1,000, static output)" in `en.json`. Das war Immediate Next Action #1 aus dem Offer Brief (A2) und die größte Transparenz-Lücke auf der Seite.
## Was als Nächstes mehr bringt als weitere Copy
1. **Ranking, nicht CTR.** 31% der Impressionen liegen auf Position 30+. Der Hebel dort ist Content/Links, nicht der Title.
2. **Die Kündigungs-Policy (Offer Brief A3).** ToS §4 sagt „may affect the availability of dynamic QR codes". Für genau diese Zielgruppe ist das das Erkennungszeichen der schlechten Anbieter. Blockiert den gesamten Proof-Layer.
3. **1015 unabhängige Reviews auf G2/Capterra.** Laut Offer Brief §8 höher priorisiert als jede Copy-Iteration.
## Messung
In 4 Wochen im GSC vergleichen — **pro Seite und positionsbereinigt**, nicht site-weit:
- `/` — Ziel 79% (von 4,88%, bei stabiler Pos ~4,6)
- `/learn` — Ziel 1,82,5% (von 0,88%)
- `/qr-code-for/barbershops` — Ziel 2,02,5% (von 0,61%)
- `/tools/zoom-qr-code` — Ziel 1,2% (von 0,65%)
- `/tools/teams-qr-code` — Ziel 45% (von 3,25%)
Wenn die Position sich gleichzeitig verändert, ist der Vergleich der Rohwerte wertlos — dann `gsc-ctr-by-position` verwenden.

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 STRIPE_SECRET_KEY="sk_test_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
ENV NEXT_PUBLIC_POSTHOG_KEY="phc_97JBJVVQlqqiZuTVRHuBnnG9HasOv3GSsdeVjossizJ"
ENV NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com"
ENV NEXT_PUBLIC_INDEXABLE="true"
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 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,421 @@
# Umsetzungsplan - CRO-Momente, Upgrade-Wege und Retention-Mails
Stand 27. Juli 2026. Alle Datei- und Zeilenangaben gegen den aktuellen Stand verifiziert.
**Getroffene Entscheidungen:**
- Das Dynamic-Limit zählt künftig nur noch Codes mit `status = ACTIVE`. Pausieren gibt einen Slot frei.
- Der Upgrade-Weg im Limit-Moment läuft per Direkt-Checkout aus dem Modal. Kein Redirect auf `/pricing`.
---
## Phase 1 - Der Limit-Moment in `/create`
### 1.1 Limit-Query auf ACTIVE umstellen
**Datei:** `src/app/(main)/api/qrs/route.ts`, Zeile 117-121
```ts
const dynamicQRCount = await db.qRCode.count({
where: { userId, type: 'DYNAMIC', status: 'ACTIVE' },
});
```
Dieselbe Änderung in `src/app/(main)/api/user/stats/route.ts`, sonst zeigt das Dashboard eine andere Zahl als die API durchlässt.
**Nebenwirkung, die vorher klar sein muss:** Free-Nutzer, die heute pausierte Codes haben, bekommen dadurch rückwirkend Slots frei. Das ist eine Lockerung, keine Verschärfung - es nimmt niemandem etwas weg. Vor dem Deploy einmal zählen, wie viele Nutzer betroffen sind (SQL unten in Abschnitt „Prüf-Queries").
### 1.2 Das Limit-Modal
**Neue Datei:** `src/components/app/DynamicLimitModal.tsx`
Der 403 aus `/api/qrs` liefert bereits `currentCount`, `limit` und `plan` mit. Es braucht keinen zusätzlichen Request.
**Datei:** `src/app/(main)/(app)/create/page.tsx`, Zeile 456-459 - der Redirect entfällt ersatzlos:
```ts
if (response.status === 403 && responseData.error === 'Limit reached') {
setLimitInfo({ current: responseData.currentCount, limit: responseData.limit, plan: responseData.plan });
setLimitModalOpen(true);
return;
}
```
Der Formular-State bleibt dadurch erhalten. Das ist der eigentliche Fix - alles andere ist Ausgestaltung.
**Copy des Modals:**
> ### Dein vierter Code ist fertig. Er braucht nur noch einen Platz.
>
> Du nutzt alle 3 dynamischen Codes deines kostenlosen Plans. Dieser hier ist gebaut und wartet - du kannst ihn behalten oder einen bestehenden freigeben.
>
> **[ Diesen Code mit Pro speichern - 9 € / Monat ]**
> [ Einen bestehenden Code pausieren ]
> [ Stattdessen als statischen Code herunterladen ]
>
> *Deine 3 aktiven Codes laufen weiter, egal wie du dich entscheidest.*
Die Zahlen (`vierter`, `3`) kommen aus `limitInfo`, damit das Modal auch für Pro bei 51 stimmt.
**Warum diese drei Optionen:**
Der Hauptbutton verkauft keinen Plan, sondern rettet einen konkreten Code, den der Nutzer gerade in der Hand hat. Option 2 ist die ehrliche Alternative innerhalb des Free-Plans - sie kostet ein paar Conversions und kauft dafür Belief 5 aus dem Necessary-Beliefs-Doc. Option 3 ist der Ausweg ohne Verlust: ein statischer Code löst das Problem zu einem guten Teil, kostet nichts, und die Zusage „läuft nie ab" ist verifiziert. Der Schlusssatz ist Risk Reversal genau an der Stelle, an der die Kategorie ihren schlechtesten Ruf hat.
### 1.3 Direkt-Checkout aus dem Modal
Der Pro-Button ruft `/api/stripe/checkout` direkt auf, mit `priceId`, `plan: 'PRO'` und `userEmail`.
**Datei:** `src/app/(main)/api/stripe/checkout/route.ts`, Zeile 63. Die `success_url` ist aktuell fest auf `/dashboard?success=true`. Sie muss eine optionale `returnPath` aus dem Request-Body akzeptieren, damit der Nutzer nach dem Kauf dorthin zurückkommt, wo er war.
Vor dem Öffnen von Stripe wird der Formularzustand nach `localStorage` geschrieben (`qrm_pending_qr`, mit Zeitstempel). Beim Zurückkommen auf `/create?restored=1` liest die Seite ihn aus, füllt das Formular und zeigt: *„Willkommen zurück. Dein Code steht noch genau so da - jetzt mit Platz."* Danach den Key löschen. Einträge älter als 24 Stunden werden verworfen.
Kein Draft in der Datenbank. Der Zustand ist ohnehin nur im Browser relevant, und eine Draft-Tabelle wäre Infrastruktur für ein Problem, das `localStorage` löst.
### 1.4 Option „Code pausieren" im Modal
Zeigt die aktiven dynamischen Codes des Nutzers mit Titel und Scan-Zahl der letzten 30 Tage. Ein Klick setzt `status = PAUSED` über das bestehende `PATCH /api/qrs/[id]`, danach wird der ursprüngliche POST automatisch wiederholt.
Die Scan-Zahl daneben ist wichtig: sie macht die Entscheidung informiert statt willkürlich. Wer sieht, dass ein Code seit vier Wochen null Scans hat, pausiert ihn ohne schlechtes Gefühl - und wer sieht, dass alle drei laufen, versteht ohne Verkaufstext, warum Pro sinnvoll ist. Das ist Pointing statt Talking.
---
## Phase 2 - `/bulk-creation`
**Datei:** `src/app/(main)/(app)/bulk-creation/page.tsx`, Zeile 232-247
Die Schleife hat kein `else` zu `if (res.ok)`. Fehlgeschlagene Zeilen verschwinden still, und danach meldet ein grüner Toast `${results.length} dynamische QR-Codes erstellt!` - eine Zahl, die kleiner sein kann als das Hochgeladene, ohne jeden Hinweis.
**Fix:**
```ts
const failed: { row: number; title: string; reason: string }[] = [];
// im else-Zweig: failed.push({ row: i + 1, title, reason: (await res.json()).error })
```
Danach, wenn `failed.length > 0`, statt des Erfolgs-Toasts ein Ergebnis-Panel:
> **180 von 200 Codes erstellt.**
> 20 Zeilen konnten nicht angelegt werden, weil dein Kontingent an dynamischen Codes erschöpft ist. Hier sind sie - du kannst sie als statische Codes erzeugen oder dein Kontingent erhöhen.
>
> [ Fehlende Zeilen als CSV ] [ Kontingent erhöhen ]
Zusätzlich in derselben Datei:
- `remainingDynamic` nach dem Lauf vom Server neu laden statt nur lokal herunterzuzählen (Zeile 251). Das ist die Ursache der Race Condition.
- Die Toasts sind auf Deutsch (`'Du hast keine dynamischen QR-Codes mehr übrig...'`, Zeile 215 und 221), während die restliche App-Oberfläche Englisch ist. Auf Englisch umstellen.
---
## Phase 3 - Upgrade-Wege aus der App
`/pricing` liegt in der `(marketing)`-Route-Group. Wer im Dashboard auf Upgrade klickt, verliert die Sidebar und landet in der Marketing-Site. Zusätzlich liest `PricingClient.tsx` `searchParams` überhaupt nicht - das `?reason=limit_reached`, das `/create` heute anhängt, wird vollständig ignoriert.
Nach der getroffenen Entscheidung läuft der Limit-Fall künftig über das Modal, damit ist der wichtigste Fall gelöst. Es bleiben drei In-App-Links auf `/pricing`:
| Datei | Zeile | Kontext |
|---|---|---|
| `dashboard/page.tsx` | 340 | Upgrade-Badge im Header |
| `create/page.tsx` | 977 | Hinweis „Upgrade to PRO to customize colors" |
| `create/page.tsx` | 1106 | Hinweis „Upgrade to PRO to add logos" |
Die beiden Hinweise in `/create` sollten dasselbe Modal öffnen wie der Limit-Fall, nur mit anderem Aufhänger („Dein Logo gehört in diesen Code"). Der Nutzer ist mitten im Gestalten - ihn dafür aus der Seite zu werfen ist derselbe Fehler wie beim Limit, nur weniger sichtbar.
Der Dashboard-Badge kann auf `/pricing` zeigen bleiben. Dort ist der Nutzer nicht mitten in einer Aufgabe, es gibt nichts zu verlieren. Nur ein `?from=dashboard` anhängen und in `PricingClient` einen Zurück-Link rendern, damit der Weg zurück nicht über den Browser-Button läuft.
---
## Phase 4 - Post-Download-Popup auf den Tool-Seiten
**Datei:** `src/components/marketing/PostDownloadPopup.tsx`
Timing und die Ablehn-Option `No thanks, keep it static` bleiben unverändert - beides ist richtig gebaut.
Ersetzt werden Headline und Bullet-Liste:
> ### Dieser Code zeigt jetzt für immer auf diese URL.
>
> Bei einem dauerhaften Link ist das genau richtig. Falls sich das Ziel je ändert, brauchst du einen neuen Code und neues Druckmaterial.
>
> Ein kostenloses Konto gibt dir 3 dynamische Codes: gleiches Bild, Ziel jederzeit änderbar, jeder Scan gezählt.
>
> **[ Kostenloses Konto anlegen - keine Karte ]**
> *Nein danke, statisch reicht*
Die vier Bullets entfallen. Drei gleichrangige Vorteile sind schwächer als ein Satz, der den einen benennt - in einer Liste aus vier gleich formatierten Punkten ist keiner davon wichtig.
Neues Prop `variant`, das nur die erste Zeile austauscht:
| Tool | Erste Zeile |
|---|---|
| Google Review | Dieser Code zeigt jetzt für immer auf dieses Google-Profil. |
| WiFi | Dieser Code enthält jetzt dauerhaft dieses WLAN-Passwort. |
| vCard | Dieser Code enthält jetzt dauerhaft diese Kontaktdaten. |
| Crypto | Dieser Code enthält jetzt dauerhaft diese Wallet-Adresse. |
| Standard | Dieser Code zeigt jetzt für immer auf diese URL. |
Die WiFi-Variante ist die stärkste, weil sie einen Umstand benennt, den fast niemand vorher bedenkt: Wer das Passwort ändert, hat wertloses Druckmaterial.
Betroffen sind die 10+ Generator-Komponenten, die `PostDownloadPopup` einbinden - dort jeweils nur das `variant`-Prop ergänzen.
**Zusätzlich:** `shouldShowDownloadPopup()` prüft einen einzigen `localStorage`-Key. Wer das Popup einmal gesehen hat, sieht es auf keiner anderen Tool-Seite je wieder - auch nicht Monate später in einem anderen Kontext. Vorschlag: Key mit Zeitstempel, Wiedervorlage nach 30 Tagen.
---
## Phase 5 - Retention-Mails
**Datei:** `src/lib/email.ts` und `src/app/(main)/api/cron/retention-emails/route.ts`
### 5.1 Tag-7-Mail: Trigger vom Kalender aufs Verhalten
Aktuell feuert sie bei `createdAt < 7 Tage` und `qrCount > 0`. Ein Nutzer mit einem einzigen Code bekommt „You're 2 away from the free limit" - eine Verkaufsmail über ein Limit, das ihn nicht drückt, abgeschickt unter deinem Namen.
Neue Logik:
| Zustand | Auslöser | Mail |
|---|---|---|
| aktive dynamische Codes = Limit | sobald erreicht | Limit-Mail, Marker `limitReachedNudgeSentAt` |
| 1-2 von 3 belegt, Tag 7 | Tag 7 | keine Upgrade-Mail |
| erster Scan liegt vor | 1 Tag danach | Erster-Scan-Mail, Marker `firstScanNudgeSentAt` |
In der Vergleichstabelle der Limit-Mail entfällt die Zeile `CSV export: Free ✓ / Pro ✓`. Eine Zeile, in der beide Spalten identisch sind, gehört nicht in eine Upgrade-Tabelle - sie verwässert die drei, die einen Unterschied zeigen.
### 5.2 Neue Mail: erster Scan
`User.firstScanAt` existiert bereits und wird in `src/app/(main)/r/[slug]/route.ts` (Zeile 150-153 und 263-266) gesetzt. Es fehlt nur ein Versand-Marker.
Betreff: **Dein Code wurde gerade zum ersten Mal gescannt**
> Um {Uhrzeit}, auf einem {Gerät}, aus {Land}. Dein Code „{Titel}" ist im Einsatz.
>
> Ab jetzt zählt jeder weitere Scan mit. In ein paar Tagen siehst du, wann die meisten kommen - und ob sich der Ort lohnt, an dem du den Code platziert hast.
>
> [ Scans ansehen ]
Kein Verkaufsargument. Diese Mail hat einen Anlass, der nicht konstruiert ist, und ist der Moment, in dem die Positionierung zum ersten Mal einlöst. Sie ist die einzige in der Sequenz, deren Anlass nicht vom Kalender kommt.
### 5.3 Tag-30-Mail auf Scan-Daten umbauen
Zwei Probleme mit der jetzigen Fassung:
Der Satz *„The one thing I hear most from Pro users who switched after a few weeks: they wish they'd added their brand sooner"* behauptet ein Muster aus Kundengesprächen. Wenn es die gab: echtes Zitat rein. Wenn nicht: **streichen** - das ist ein erfundenes Testimonial in indirekter Rede und verstößt gegen die Beweisregel im Product Context. Bei einer Zielgruppe, die Bewertungsportale liest, ist das die teuerste Art von Satz.
Und Branding ist der schwächere von zwei verfügbaren Aufhängern. Nach 30 Tagen hat der Nutzer Scan-Daten. Neuer Aufbau:
> Deine Codes wurden diesen Monat {n}-mal gescannt, {Vorname}.
>
> Die meisten davon {Wochentag}s. Was du noch nicht sehen kannst: von welchen Geräten sie kamen und aus welchen Orten - und damit, welche deiner Platzierungen die Scans wirklich gebracht hat.
>
> [ Vollständige Auswertung freischalten ]
Damit ist der Kaufgrund aus dem hergeleitet, was der Nutzer selbst erlebt hat. Deckt sich mit Offer Brief §10, Option 1 - auf Analytics-Tiefe metern statt auf Code-Anzahl -, und ist die erste Stelle, an der man das testen kann, ohne das Pricing anzufassen.
### 5.4 Tag-3-Mail: kleine Korrektur
Betreff `You haven't made one yet` kann als Vorwurf gelesen werden - im Body löst die Headline es auf, im Posteingang steht der Betreff allein. Alternative gleicher Länge: **`Your 3 free codes are still sitting there`**.
---
## Phase 6 - QR-Design nach Plan gestaffelt
Neue Staffelung: Farben ab Free, Formen ab Pro, alles ab Business.
### 6.0 Der technische Blocker, der vorher geklärt sein muss
`/create` rendert über `QRCodeSVG` aus `qrcode.react` (Zeile 6 und 1233). **Diese Bibliothek kann ausschließlich quadratische Module.** Es gibt heute überhaupt keine Formauswahl im Produkt - nicht weil sie gesperrt wäre, sondern weil der Renderer sie nicht kann.
Für Formen muss der Renderer also getauscht werden. Es gibt zwei Kandidaten, und sie decken unterschiedlich viel ab:
| Renderer | Kann | Kann nicht |
|---|---|---|
| `qr-code-styling` (liegt bereits als Dependency in `package.json`, Zeile 64, ungenutzt) | square, dots, rounded, extra-rounded, classy, classy-rounded, Eye-Styles getrennt, Verläufe, Logo | star, plus, hexagon, diamond, mosaic, liquid |
| Canvas-Renderer aus `qr-design-showcase.html` | alle 11 Formen inklusive star, plus, hexagon, diamond, mosaic, liquid, Rahmen mit Label, Logo-Formen | ist eigener Code, muss portiert und gepflegt werden |
Das trifft sich gut mit der gewünschten Staffelung: **Pro läuft komplett über `qr-code-styling`** - vier Formen, geringer Aufwand, Bibliothek ist schon da. **Business braucht den portierten Showcase-Renderer** für die exotischen Formen. Damit ist der Mehraufwand genau dort, wo auch der höhere Preis ist.
### 6.1 Free - Farben
**Datei:** `src/app/(main)/(app)/create/page.tsx`, Zeile 167
```ts
const canCustomizeColors = true; // war: PRO || BUSINESS
const canUseShapes = userPlan === 'PRO' || userPlan === 'BUSINESS';
const canUseFullDesign = userPlan === 'BUSINESS';
const canUseLogo = userPlan === 'PRO' || userPlan === 'BUSINESS';
```
Damit fallen die beiden Zwangsüberschreibungen in Zeile 408-409 weg, die heute für Free-Nutzer stumpf `#000000` und `#FFFFFF` einsetzen. Der Upgrade-Hinweis-Block ab Zeile 972 („Upgrade to PRO to customize colors, add logos, and brand your QR codes") entfällt komplett - er stimmt danach nicht mehr.
Der Logo-Block ab Zeile 1104 bleibt, wird aber auf `canUseLogo` umgestellt statt auf `canCustomizeColors`, und öffnet künftig das Modal aus Phase 3 statt auf `/pricing` zu verlinken.
**Der Kontrast-Check bleibt aktiv.** `calculateContrast` gibt es bereits (Zeile 221). Sobald Farben für alle offen sind, wird er wichtiger, nicht unwichtiger - Free-Nutzer sind die Gruppe mit der geringsten Erfahrung darin, was noch scannbar ist. Bei zu geringem Kontrast eine Warnung, kein Verbot: *„Dieser Kontrast ist grenzwertig. Auf gedrucktem Material scannen helle Codes auf hellem Grund oft nicht."*
### 6.2 Pro - vier Modulformen
Umsetzung über `qr-code-styling`. Bewusst nur vier, nicht sechs:
| Auswahl | `dotsOptions.type` | Wofür |
|---|---|---|
| Eckig (klassisch) | `square` | Standard, höchste Scan-Sicherheit |
| Abgerundet | `rounded` | weicher, ohne Lesbarkeit zu kosten |
| Punkte | `dots` | modern, deutlich sichtbarer Unterschied |
| Fließend | `classy-rounded` | markantester der vier |
Dazu die Eye-Styles (`cornersSquareOptions`, `cornersDotOptions`) - eckig, abgerundet, Kreis. Die Augen machen optisch mehr aus als die Module selbst und kosten nichts extra, weil dieselbe Bibliothek sie mitbringt.
Vier Optionen statt aller sechs, weil die Auswahl selbst ein Verkaufsargument ist: Pro fühlt sich vollständig an, Business hat sichtbar mehr. Sechs fast identische Varianten wirken dagegen wie eine lange Liste ohne Unterschied.
### 6.3 Business - vollständiger Designer
Portierung des Renderers aus `qr-design-showcase.html` in eine Komponente `src/components/generator/AdvancedQRRenderer.tsx`. Was damit dazukommt:
- **Modulformen:** diamond, star, hexagon, plus, mosaic, liquid zusätzlich zu den vier aus Pro
- **Eye-Frames getrennt:** eckig, abgerundet, Kreis, Blatt, Flower/Squircle, Hexagon
- **Eye-Balls getrennt:** eckig, abgerundet, Kreis, Diamant, Stern, Hexagon
- **Farbverläufe:** linear und radial statt einfarbig
- **Rahmen mit Label:** weiße Karte mit Schatten, Label oben oder unten (z. B. „Speisekarte", „Jetzt bewerten")
- **Logo-Formen:** Punkt, Quadrat, Herz, Blitz, WLAN-Symbol
- **Fehlerkorrektur-Stufe** frei wählbar (L/M/Q/H)
**Erweiterungsvorschläge über die Showcase hinaus**, weil beim Business-Kunden die Wiederholbarkeit zählt und nicht die einzelne Spielerei:
1. **Design-Vorlagen speichern.** Ein einmal gebautes Design als benanntes Preset sichern und auf neue Codes anwenden. Das ist für eine Agentur mit mehreren Kunden der eigentliche Wert - nicht die Sternform, sondern dass Kunde A immer gleich aussieht.
2. **Preset auf den Bulk-Flow anwenden.** Ein Preset auswählen und alle 500 Codes eines Uploads tragen es. Das verbindet Phase 2 mit diesem Feature und ist das erste Argument für Business, das nicht nur eine Zahl ist.
3. **Print-Vorschau in Originalgröße.** Der Code in 2×2 cm, 4×4 cm und 10×10 cm dargestellt, mit Hinweis ab wann es kritisch wird. Passt zu den Druckangaben, die im Offer Brief bereits als verifizierter Fakt geführt werden.
### 6.4 Scanbarkeit - der Teil, der nicht optional ist
Exotische Formen füllen weniger Fläche pro Modul. Star, plus und mosaic reduzieren die Kontrastfläche spürbar, und in Kombination mit einem Logo in der Mitte und einer kleinen Druckgröße kippt die Lesbarkeit. Die Showcase weist bei „H (30%)" schon auf „empfohlen bei Logo" hin - das gehört ins Produkt übernommen, und zwar strenger:
- Bei star, plus, mosaic oder liquid automatisch auf Fehlerkorrektur **H** hochsetzen und das sichtbar begründen
- Bei Logo plus exotischer Form eine Warnung mit Mindestdruckgröße
- Ein **Test-Scan-Hinweis** vor dem Download: *„Scanne den Code einmal mit deinem eigenen Handy, bevor du ihn in Druck gibst."*
Das ist kein Beiwerk, sondern der Proof-Layer aus der Positionierung an einer Stelle, an der es konkret wird. Ein Anbieter, der vor seinem eigenen Feature warnt, wenn es kritisch wird, belegt Belief 5 besser als jede Aussage über Transparenz.
### 6.5 Was die Umstellung beim Pricing kostet
Ehrlich gesagt: Farben waren laut Offer Brief §10 einer von genau **drei** Gründen, heute auf Pro zu wechseln (die anderen zwei: mehr als 3 aktive Codes, Device- und Location-Breakdown). Diesen Grund gibt man mit dieser Änderung auf.
Das ist meiner Einschätzung nach trotzdem richtig, aber aus einem anderen Grund als „großzügiger sein":
Farben sind kein guter Upgrade-Anlass, weil man sie schon im kostenlosen Zustand *sehen* will, um zu verstehen, ob das Produkt etwas taugt. Ein Free-Nutzer, der nur schwarze Codes bauen kann, hält das Produkt für ein Basiswerkzeug - und bewertet es entsprechend, auch in Vergleichen. Formen sind der bessere Verkäufer, weil der Unterschied größer aussieht und man ihn in der Vorschau zeigen kann, bevor man ihn freischaltet: der Nutzer wählt „Punkte", sieht das Ergebnis live, und erst der Download fragt nach Pro.
**Folgeänderungen an der Preis-Copy** in `src/i18n/en.json` und `src/app/(main)/(marketing)/pricing/PricingClient.tsx`:
| Plan | vorher | nachher |
|---|---|---|
| Free | „Standard QR design templates" | „Deine Farben - Vorder- und Hintergrund frei wählbar" |
| Pro | „Custom branding (colors & logos)" | „4 Modulformen, eigene Eye-Styles und dein Logo im Code" |
| Business | (kein Design-Punkt) | „Voller Designer: 10 Formen, Verläufe, Rahmen mit Label, speicherbare Design-Vorlagen" |
Dieselbe Anpassung auf `/custom-qr-code-generator`, dessen Meta-Description aktuell „Put your logo and brand colors into the code itself" sagt - das bleibt korrekt, weil Logo weiterhin Pro ist, aber die Seite sollte künftig zeigen, was auf welcher Stufe geht.
### 6.6 Keine Datenbankänderung nötig
`QRCode.style` ist bereits `Json` (siehe `prisma/schema.prisma`). Alle neuen Felder - `dotType`, `eyeFrameType`, `eyeBallType`, `gradient`, `frameLabel`, `logoShape`, `ecLevel` - passen ohne Schemaänderung hinein. Bestehende Codes haben die Felder schlicht nicht und fallen auf die Defaults zurück.
**Ausnahme:** Wenn die speicherbaren Design-Vorlagen aus 6.3 kommen sollen, braucht es eine eigene Tabelle. SQL dafür steht unten, ist aber optional und kann später nachgezogen werden.
---
## SQL - alle nötigen Datenbankänderungen
Nach der Policy in `CLAUDE.md`: keine Prisma-Migrationen, nur direkte Statements.
```sql
-- Marker für die neuen verhaltensbasierten Retention-Mails
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "limitReachedNudgeSentAt" TIMESTAMP(3);
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "firstScanNudgeSentAt" TIMESTAMP(3);
-- Index für den Cron: sucht Nutzer mit erstem Scan, die die Mail noch nicht haben
CREATE INDEX IF NOT EXISTS "User_firstScanAt_firstScanNudgeSentAt_idx"
ON "User" ("firstScanAt", "firstScanNudgeSentAt");
-- Index für die neue Limit-Query (zählt nur noch ACTIVE)
CREATE INDEX IF NOT EXISTS "QRCode_userId_type_status_idx"
ON "QRCode" ("userId", "type", "status");
```
Für Phase 6 ist **nichts** davon nötig - `QRCode.style` ist bereits `Json`. Optional, nur falls die speicherbaren Design-Vorlagen aus 6.3 gebaut werden:
```sql
CREATE TABLE IF NOT EXISTS "QRDesignPreset" (
"id" TEXT PRIMARY KEY,
"userId" TEXT NOT NULL REFERENCES "User"("id") ON DELETE CASCADE,
"name" TEXT NOT NULL,
"style" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "QRDesignPreset_userId_idx" ON "QRDesignPreset" ("userId");
CREATE UNIQUE INDEX IF NOT EXISTS "QRDesignPreset_userId_name_key" ON "QRDesignPreset" ("userId", "name");
```
Ausführen über `npm run docker:db` oder:
```bash
docker-compose exec db psql -U postgres -d qrmaster -c 'ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "limitReachedNudgeSentAt" TIMESTAMP(3);'
```
Danach `prisma/schema.prisma` im Block `// Retention email tracking` ergänzen:
```prisma
limitReachedNudgeSentAt DateTime?
firstScanNudgeSentAt DateTime?
```
und beim Model `QRCode` den Index:
```prisma
@@index([userId, type, status])
```
Abschließend `npx prisma generate` - kein `migrate`.
### Prüf-Queries vor dem Deploy
```sql
-- Wie viele Free-Nutzer bekommen durch die ACTIVE-Umstellung Slots frei?
SELECT COUNT(DISTINCT u.id)
FROM "User" u
JOIN "QRCode" q ON q."userId" = u.id
WHERE u.plan = 'FREE' AND q.type = 'DYNAMIC' AND q.status = 'PAUSED';
-- Wie viele Nutzer bekämen die Erster-Scan-Mail beim ersten Cron-Lauf?
SELECT COUNT(*) FROM "User"
WHERE "firstScanAt" IS NOT NULL AND "firstScanNudgeSentAt" IS NULL;
```
Die zweite Zahl ist wichtig: Beim ersten Lauf würde die neue Mail an **alle** Bestandsnutzer mit Scan-Historie gehen - auch an solche, deren erster Scan Monate zurückliegt. Das wäre kein Anlass mehr, sondern Spam. Der Cron braucht deshalb ein Zeitfenster, etwa `firstScanAt > now() - interval '7 days'`, oder man setzt die Spalte bei Bestandsnutzern einmalig vor:
```sql
UPDATE "User" SET "firstScanNudgeSentAt" = now()
WHERE "firstScanAt" IS NOT NULL AND "firstScanAt" < now() - interval '7 days';
```
---
## Reihenfolge
| # | Was | Aufwand | DB |
|---|---|---|---|
| 1 | Limit-Query auf ACTIVE, beide Endpoints | klein | Index |
| 2 | Limit-Modal ohne Redirect | mittel | nein |
| 3 | Direkt-Checkout plus `returnPath` und localStorage-Wiederherstellung | mittel | nein |
| 4 | `bulk-creation`: Fehlerbehandlung und Ergebnis-Panel | klein | nein |
| 5 | Popup-Copy plus `variant` pro Tool-Seite | klein | nein |
| 6 | Retention: Trigger umstellen, Tabellen-Zeile raus, Betreff Tag 3 | klein | 1 Spalte |
| 7 | Erster-Scan-Mail | mittel | 1 Spalte |
| 8 | Tag-30 auf Scan-Daten, unbelegten Satz klären | klein | nein |
| 9 | Farben ab Free freischalten, Pricing-Copy nachziehen | klein | nein |
| 10 | Pro-Formen über `qr-code-styling`, Renderer-Tausch in `/create` | mittel | nein |
| 11 | Business-Designer, Showcase-Renderer portieren | groß | nein |
| 12 | Design-Vorlagen speichern und auf Bulk anwenden | mittel | Tabelle |
1 bis 3 gehören zusammen und sollten gemeinsam live gehen. 4 und 5 sind unabhängig und können jederzeit dazwischen. 6 bis 8 brauchen die SQL-Statements und den einmaligen `UPDATE` für Bestandsnutzer.
**9 ist der schnellste Gewinn im ganzen Dokument** - eine Zeile (`canCustomizeColors = true`) plus Copy-Anpassung, und das Produkt wirkt für jeden neuen Nutzer sofort weniger nach Basiswerkzeug. Sie sollte nicht auf 10 warten.
10 ist der Renderer-Tausch und damit der Punkt, an dem `/create` einmal gründlich getestet werden muss - Vorschau, Download PNG, Download SVG, Logo-Overlay und die Frame-Optionen hängen alle daran.
11 und 12 sind der eigentliche Business-Ausbau. 12 ist dabei wertvoller als 11: eine Agentur kauft nicht die Sternform, sondern dass Kunde A auf 500 Codes gleich aussieht.

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

View File

@@ -0,0 +1,169 @@
# Retention-Mails & CRO-Momente - Analyse
Stand 27. Juli 2026. Grundlage: `src/lib/email.ts`, `src/app/(main)/api/cron/retention-emails/route.ts`, `src/components/marketing/PostDownloadPopup.tsx`, `src/app/(main)/(app)/create/page.tsx` sowie Offer Brief §10 und der Necessary-Beliefs-Doc.
---
## Teil 1 - Der 3/3-Moment: es gibt kein Popup
Das Wichtigste zuerst, weil es kein Copy-Problem ist.
Wenn ein Free-Nutzer den vierten dynamischen Code anlegen will, passiert laut `create/page.tsx` Zeile 456-458 Folgendes:
```
if (response.status === 403 && responseData.error === 'Limit reached') {
showToast(responseData.message || 'You have reached your plan limit.', 'error');
router.push('/pricing?reason=limit_reached');
}
```
Drei Dinge gehen hier schief, in aufsteigender Schwere:
**1. Roter Fehler-Toast.** Der Nutzer hat nichts falsch gemacht. Er hat das Produkt so benutzt, wie es gedacht ist, und bekommt dafür eine Fehlermeldung im gleichen Stil wie eine kaputte URL. Das ist keine Upgrade-Aufforderung, das ist eine Zurechtweisung.
**2. Die Arbeit ist weg.** Der Nutzer hat Ziel-URL, Typ, Farben, vielleicht ein Logo eingegeben. Beim Redirect auf `/pricing` ist all das verloren. Wer danach upgradet, muss von vorne anfangen. Das ist der teuerste denkbare Zeitpunkt für Datenverlust: exakt in dem Moment, in dem die Kaufabsicht am höchsten ist.
**3. Der Redirect ist ein Rauswurf.** Der Nutzer wollte einen QR-Code bauen und landet auf einer Preistabelle ohne Bezug zu dem, was er gerade tat. Kein Weg zurück, kein „so sieht dein Code aus, er ist nur noch nicht gespeichert".
Aus dem Offer Brief §7: der Engpass ist **Perceived Likelihood**, nicht Preis und nicht Effort. Dieser Ablauf senkt genau die - er zeigt, dass das Produkt in einem kritischen Moment die eigene Arbeit wegwirft.
### Was stattdessen hin muss
Ein Modal, das **über** dem fertigen Code aufgeht, nicht statt ihm. Der Code bleibt im Hintergrund sichtbar. Vorschlag:
> **Dein vierter Code ist fertig. Er braucht nur noch einen Platz.**
>
> Du nutzt alle 3 dynamischen Codes des Free-Plans. Dieser hier ist gebaut und wartet - du kannst ihn behalten oder einen bestehenden freigeben.
>
> [ Diesen Code mit Pro speichern - 9 € / Monat ]
> [ Einen bestehenden Code ersetzen ]
> [ Stattdessen als statischen Code herunterladen - kostenlos, läuft nie ab ]
>
> Deine 3 aktiven Codes laufen weiter, egal wie du dich entscheidest.
Warum diese drei Optionen:
- **Option 1** ist der Kauf, aber formuliert als „diesen konkreten Code retten", nicht als „Plan kaufen". Der Nutzer kauft eine Sache, die er gerade in der Hand hat.
- **Option 2** ist die ehrliche Alternative innerhalb des Free-Plans. Sie kostet ein paar Conversions und kauft dafür Glaubwürdigkeit - Belief 5 aus dem Necessary-Beliefs-Doc: der sicherste Anbieter ist der, der offenlegt, was er kann und was nicht.
- **Option 3** ist der Ausweg ohne Verlust. Ein statischer Code löst das Problem des Nutzers zu 60 % und kostet nichts, und die Zusage „läuft nie ab" ist verifiziert. Wer sie nimmt, ist nicht verloren - er hat gerade erlebt, dass das Produkt ihm nichts wegnimmt.
Der letzte Satz ist Risk Reversal an genau der Stelle, wo die Kategorie ihren schlechtesten Ruf hat: das Abschalten bereits gedruckter Codes.
---
## Teil 2 - Die Retention-Mails
Die Copy ist besser als der Durchschnitt. Absenderpersona ist da (Timo, Founder, Reply geht an einen Menschen), keine Ausrufezeichen-Orgie, konkrete Zahlen. Was fehlt, liegt eine Ebene tiefer.
### Mail 1 - Tag 3, Aktivierung
Betreff: `You haven't made one yet`
Headline: `You haven't made one yet, {Name}. 3 days since signup · 0 QR codes created`
Das ist die stärkste der drei. Der Betreff ist ein Pattern Interrupt in einem Posteingang voller „Tips & Tricks", und die Zeile `Your 3 free dynamic QR codes are still there. Unused.` ist Pointing statt Talking.
Zwei Schwächen:
**Der Betreff kann als Vorwurf gelesen werden.** „You haven't made one yet" mit Doppeldeutigkeit - ist das eine Beobachtung oder eine Rüge? Die Headline im Body löst es auf, der Betreff allein nicht. Alternative, gleiche Länge, ohne Vorwurfsrisiko: `Your 3 free codes are still sitting there`.
**Es fehlt der Anlass.** Die Mail erklärt, *wie* man einen Code macht (drei Schritte), aber nicht, *wofür der Empfänger sich damals angemeldet hat*. Wenn beim Signup die Quelle bekannt ist - kam er über `/tools/google-review-qr-code`, über die Restaurant-Seite, über Bulk? -, dann gehört Schritt 01 personalisiert. „Paste your URL" ist die generischste denkbare Aufforderung an jemanden, der wegen Google-Reviews kam.
### Mail 2 - Tag 7, Upgrade
Betreff: `You're at the free limit` bzw. `You're 2 away from the free limit`
Headline: `{n} of 3 free codes used, {Name}.`
Hier ist der Trigger das Problem, nicht die Worte.
**Die Mail feuert am Tag 7, unabhängig vom Verhalten.** Die Bedingung im Cron ist `createdAt < 7 Tage` und `qrCount > 0` und `plan = FREE`. Ein Nutzer mit **einem** Code bekommt eine Mail mit dem Betreff „You're 2 away from the free limit" - über ein Limit, das ihn nicht drückt und vielleicht nie drücken wird. Das ist eine Upgrade-Aufforderung an jemanden ohne Schmerz. Solche Mails trainieren Empfänger darauf, den Absender zu ignorieren, und der Absender ist hier der Gründer persönlich - das ist teurer Kredit, den man nicht für eine Fehlzündung ausgeben sollte.
**Empfehlung: den Trigger vom Kalender aufs Verhalten umstellen.**
| Zustand | Auslöser | Inhalt |
|---|---|---|
| 3 von 3 belegt | sobald erreicht, nicht Tag 7 | Limit-Mail wie jetzt, aber sofort im Moment der Relevanz |
| 1-2 von 3 belegt | Tag 7 | keine Upgrade-Mail. Stattdessen: was die Scan-Daten des ersten Codes zeigen |
| 3 von 3 und erster Code hat Scans | 3 Tage nach Limit | Upgrade mit den eigenen Zahlen des Nutzers als Beweis |
Das dritte Szenario ist die eigentlich fehlende Mail. Nichts überzeugt einen Marketing-Manager so wie sein eigener erster Datenpunkt: „Dein Code auf dem Flyer hatte 47 Scans, die meisten dienstags zwischen 11 und 14 Uhr." Das ist Pointing in Reinform, und es kostet nichts außer der Query.
**Und ein inhaltlicher Fehler.** Die Vergleichstabelle listet `CSV export: Free ✓ / Pro ✓`. Eine Zeile, in der beide Spalten identisch sind, ist in einer Upgrade-Tabelle wertlos - sie füllt Platz und verwässert die drei Zeilen, die tatsächlich einen Unterschied zeigen. Streichen.
### Mail 3 - Tag 30
Betreff: `{Name}, a month of QR codes - one upgrade worth making`
Argument: Pro-Nutzer bereuen, das Branding nicht früher hinzugefügt zu haben.
**Das Argument ist gut gebaut und nicht belegt.** Der Satz `The one thing I hear most from Pro users who switched after a few weeks: they wish they'd added their brand sooner` behauptet ein Muster aus Kundengesprächen. Wenn diese Gespräche stattgefunden haben: rein damit, am besten mit einem echten Zitat. Wenn nicht, ist das ein erfundenes Testimonial in indirekter Rede und verstößt gegen die Beweisregel aus dem Product Context („keine erfundenen Testimonials"). Bei einer Zielgruppe, die Bewertungsportale liest, ist das die teuerste Art von Satz.
**Der Aufhänger ist außerdem der schwächere von zwei verfügbaren.** Branding ist ein Ästhetik-Argument. Nach 30 Tagen mit mehreren Codes hat der Nutzer etwas viel Stärkeres in der Hand: Scan-Daten. Der Tag-30-Anlass sollte der erste echte Report sein, nicht ein Logo-Feature. „Deine Codes wurden diesen Monat X-mal gescannt. Hier ist, was du noch nicht sehen kannst" - und dann Device- und Location-Breakdown als das, was Pro freischaltet. Das ist derselbe Kaufgrund, aber hergeleitet aus dem, was der Nutzer selbst erlebt hat.
Das deckt sich mit Offer Brief §10, Option 1: auf Analytics-Tiefe metern statt auf Code-Anzahl. Diese Mail wäre die erste Stelle, an der man das testen kann, ohne das Pricing anzufassen.
### Übergreifend: es fehlt die Mail nach dem ersten Scan
Die Sequenz ist Tag 3, Tag 7, Tag 30 - drei Kalendertermine. Der wichtigste Moment im Lebenszyklus dieses Produkts kommt in keinem davon vor: **der erste Scan eines Codes.** Das ist der Augenblick, in dem aus einem Tool eine Messung wird und in dem das Versprechen der Positionierung zum ersten Mal einlöst. Eine Mail „Dein Code wurde gerade zum ersten Mal gescannt" hat einen Anlass, der nicht konstruiert ist, und braucht überhaupt kein Verkaufsargument.
---
## Teil 3 - Das Post-Download-Popup auf den Tool-Seiten
Aktuell:
> **Your QR code is downloading!**
> Want to make it smarter - for free?
>
> - Edit the link anytime - QR stays the same
> - See who scans, when & where
> - Custom colors, logo & frames
> - Free plan included - upgrade anytime for more
>
> [ Create Free Account ] / *No thanks, keep it static*
### Was funktioniert
Das Timing ist richtig: nach dem Download, nicht davor. Der Nutzer hat bekommen, wofür er kam - das Popup nimmt ihm nichts weg. Und `No thanks, keep it static` ist eine ehrliche Ablehn-Option ohne Beschämung, was in dieser Kategorie selten ist. Nicht anfassen.
### Was nicht funktioniert
**Die Headline sagt nichts.** `Your QR code is downloading!` ist eine Statusmeldung. Der Nutzer sieht den Download in seinem Browser, er braucht keine Bestätigung. Diese Zeile ist die prominenteste Fläche des Popups und verbraucht sie für eine Information, die der Nutzer schon hat.
**Der Kernnutzen ist die dritte Zeile in einer Bullet-Liste.** `Edit the link anytime - QR stays the same` ist das gesamte Argument des Produkts, versteckt zwischen einem Icon und drei gleichgewichtigen Geschwistern. In einer Liste von vier gleich formatierten Punkten ist keiner davon wichtig.
**Es fehlt der Anlass in diesem Moment.** Der Nutzer hat gerade einen **statischen** Code heruntergeladen. Der interessante Satz ist nicht „mach ihn schlauer", sondern die Konsequenz dessen, was er gerade getan hat: dieser Code ist ab jetzt festgelegt. Das ist kein Vorwurf und keine Drohung, das ist eine Tatsache über die Datei in seinem Download-Ordner - und exakt Belief 1 aus dem Necessary-Beliefs-Doc.
**Es ist auf allen Tool-Seiten identisch.** Wer auf `/tools/google-review-qr-code` war, will Bewertungen. Wer auf `/tools/wifi-qr-code` war, will Gäste ins WLAN. Ein Popup, das beiden dasselbe sagt, spricht keinem von beiden.
### Vorschlag
> **Dieser Code zeigt jetzt für immer auf diese URL.**
>
> Das ist bei einem dauerhaften Link genau richtig. Falls sich das Ziel je ändert, brauchst du einen neuen Code und neues Druckmaterial.
>
> Ein kostenloses Konto gibt dir 3 dynamische Codes: gleiches Bild, Ziel jederzeit änderbar, jeder Scan gezählt.
>
> [ Kostenloses Konto anlegen - keine Karte ]
> *Nein danke, statisch reicht*
Struktur dahinter: P.I.G.-Opening auf eine Tatsache statt auf ein Feature, dann die Zugeständnis-Zeile („bei einem dauerhaften Link genau richtig") - das ist Offer Brief §9, die Konzession als Glaubwürdigkeits-Move -, dann erst der Mechanismus. Die Bullet-Liste entfällt komplett; drei gleichrangige Vorteile sind schwächer als ein Satz, der den einen benennt.
Pro Tool-Seite variiert nur die erste Zeile:
| Tool | Erste Zeile |
|---|---|
| Google Review | Dieser Code zeigt jetzt für immer auf dieses Google-Profil. |
| WiFi | Dieser Code enthält jetzt dauerhaft dieses WLAN-Passwort. |
| vCard | Dieser Code enthält jetzt dauerhaft diese Kontaktdaten. |
| URL / Standard | Dieser Code zeigt jetzt für immer auf diese URL. |
Die WiFi-Variante ist die stärkste der vier, weil sie einen Umstand benennt, den fast niemand vorher bedenkt: das Passwort steht in der gedruckten Datei. Wer es ändert, hat wertloses Material.
---
## Reihenfolge nach Wirkung pro Aufwand
1. **Das 3/3-Modal bauen.** Momentan verliert das Produkt an der Stelle mit der höchsten Kaufabsicht die Arbeit des Nutzers. Das ist der einzige Punkt hier, der auch Entwicklungsarbeit ist und nicht nur Text.
2. **Trigger der Tag-7-Mail auf Verhalten umstellen.** Query-Änderung im Cron, keine neue Infrastruktur.
3. **Popup-Copy und Headline umstellen**, mit Variante pro Tool-Seite.
4. **Die „erster Scan"-Mail** ergänzen - der einzige Anlass in der ganzen Sequenz, der nicht vom Kalender kommt.
5. **Tag 30 auf Scan-Daten umbauen** statt Branding, und den unbelegten Pro-Nutzer-Satz entweder belegen oder streichen.

139
SEO-BLOG-PLAN-2026-08.md Normal file
View File

@@ -0,0 +1,139 @@
# Blog-Keyword-Plan aus GSC (letzte 3 Monate, Stand 2026-08-03)
Datenbasis: 1.001 Suchanfragen, 163 Seiten. Gesamt ~34k Impressionen, ~150 Klicks außerhalb der Homepage.
## Kernbefund
Das Problem ist **nicht fehlender Content, sondern Positionen 4070**. Fast alle großen Cluster haben 0 Klicks bei hunderten Impressionen, weil sie auf Seite 47 stehen. Dazu kommt **Kannibalisierung**: mehrere Blogposts konkurrieren mit der Money-Page um dasselbe Keyword — und alle verlieren.
Beispiel Tracking:
| URL | Impr. | Position |
|---|---|---|
| /qr-code-tracking | 2.148 | 48,4 |
| /blog/qr-code-tracking-guide-2025 | 67 | 79,4 |
| /blog/trackable-qr-codes | 204 | 81,5 |
| /blog/qr-code-analytics | 93 | 85,1 |
| /guide/tracking-analytics | 1 | 91,0 |
Fünf Seiten für ein Thema → keine rankt. Gleiches Muster bei Dynamic QR und Instagram.
---
## A) Zuerst verbessern (bestehende Posts, Position 2045 = erreichbar)
Diese sind am nächsten an Seite 1. Höchster ROI.
**1. `/blog/free-vs-paid-qr-generator` — Position 31, 446 Impr., 1 Klick**
Beste Blog-Position der Site. Keywords, die schon auf ~26 stehen:
- `free vs paid qr code generator` (97 Impr., Pos. 26,7)
- `free vs paid qr code generators` (78, Pos. 26,6)
- `do you have to pay for qr codes` (42, Pos. 49) + 4 Varianten
- `do qr codes cost money` (20)
→ Ergänzen: FAQ-Block mit exakt diesen Frageformulierungen ("Do you have to pay for QR codes?", "Do QR codes cost money?"), Preisvergleichstabelle mit echten Zahlen der Wettbewerber, Update-Datum 2026.
**2. `/blog/microsoft-teams-qr-code` — Position 14,4, 287 Impr., 0 Klicks**
Position 14 mit 0 % CTR = Title/Meta-Problem, kein Ranking-Problem.
- `microsoft teams qr code generator` (18 Impr., Pos. 9,6 — bereits Seite 1!)
- `teams qr code generator` (12, Pos. 6,6), `teams qr code` (17, Pos. 27), `qr code for teams meeting` (6, Pos. 11)
→ Title-Tag und Meta-Description neu schreiben (Jahreszahl, Nutzenversprechen), interne Verlinkung von /tools/teams-qr-code. Kein neuer Content nötig.
**3. `/blog/best-qr-code-generator-2026` — Position 41, 55 Impr.**
Wettbewerber-Cluster hat 1.173 Impr. gesamt, alles bei 0 Klicks:
- `beaconstac` (96), `beaconstac qr code generator` (75), `beaconstac vs popl` (67), `beaconstac vs mobilo` (57), `popl vs beaconstac` (56), `blinq vs beaconstac` (42), `beaconstac alternative` (34)
- `flowcode alternative` (51, Pos. 18,3 — nah dran), `flowcode competitors` (38, Pos. 29), `flowcode pricing` (21), `uniqode alternative` (24, Pos. 26)
→ Vergleichstabelle mit Preisen pro Anbieter einbauen. Zusätzlich: die `X vs Y`-Queries (Beaconstac vs Popl/Mobilo/Blinq) sind reine Vergleichsintention ohne passende Seite — dafür Punkt B4.
**4. `/blog/whatsapp-qr-code-generator` — Position 10,2, nur 33 Impr.**
Steht auf Seite 1, bekommt aber kaum Impressionen — während `/tools/whatsapp-qr-code` bei 441 Impr. auf Position 55 hängt.
- `whatsapp qr code generator` (211 Impr., Pos. 59,5), `whatsapp qr generator` (107, Pos. 66,5)
→ Klassische Kannibalisierung. Blogpost auf Tool-Seite verlinken (Canonical prüfen), Blogpost als Ratgeber positionieren statt als Generator.
---
## B) Neue Blogposts (echte Nachfrage, keine passende Seite)
**B1. Google Reviews — 1.331 Impr., 0 Klicks, kein einziger Blogpost**
Größte inhaltliche Lücke. `/tools/google-review-qr-code` hat 1.752 Impr. bei Pos. 37,8 und 0,11 % CTR — komplett ohne Content-Support.
- `google review qr code generator` (222), `free qr code for google review` (121), `qr code generator for google reviews` (93), `free google review qr code` (89), `review qr code` (83, Pos. 35), `qr code for feedback` (93)
- Long-Tail: `how to generate google review qr code` (40), `ask for a review by qr code` (6, Pos. 19), `review us on google qr code` (14, Pos. 29)
**Post: "How to Create a Google Review QR Code (Free, 2026)"** — Schritt-für-Schritt inkl. Place-ID finden, Aufsteller-Templates, rechtliche Hinweise zum Review-Gating. Verlinkt hart auf das Tool.
**B2. Location / Geo QR — 923 Impr., 0 Klicks, kein Blogpost**
`/tools/geolocation-qr-code`: 1.087 Impr. bei Position 62,8.
- `location qr code generator` (129), `qr code for location` (69), `qr code generator location` (53), `location qr code` (41), `qr code generator for map location` (19), `gps qr code generator` (21), `qr code generator gps coordinates` (10)
**Post: "Location QR Codes: Google Maps, GPS-Koordinaten & Apple Maps richtig verlinken"** — geo:-URI vs. Maps-Link, welches Format auf iOS/Android funktioniert. Dieses technische Detail sucht sonst niemand ordentlich.
**B3. Coupon / Promo QR — 495 Impr., 0 Klicks**
`/use-cases/coupon-qr-codes` steht auf Pos. 47,3 (545 Impr.).
- `qr code coupon` (66), `qr code coupons` (56), `qr coupon` (50), `coupon qr code` (49), `qr coupons` (36), `qr code coupon redemption` (35), `qr code discount coupon` (22), `qr code coupon system` (5)
**Post: "QR Code Coupons: Einlösung tracken & Missbrauch verhindern"** — Einmal-Codes, Redemption-Tracking, Ablaufdaten. `qr code coupon redemption` und `qr code coupon system` zeigen Intention über "erstellen" hinaus.
**B4. Wettbewerbsvergleiche `X vs Y` — ~350 Impr., 0 Klicks**
- `beaconstac vs popl` (67), `beaconstac vs mobilo` (57), `popl vs beaconstac` (56), `blinq vs beaconstac` (42), `bitly vs beaconstac qr codes` (18, Pos. 14,9!), `uniqode vs flowcode` (4), `flowcode vs qr code` (15)
→ Deckt sich mit dem CLAUDE.md-Backlog (`/vs/`-Seiten). Ein Blogpost **"Beaconstac vs Popl vs Blinq vs QR Master: Digital Business Card & QR Vergleich 2026"** fängt mehrere dieser Queries gleichzeitig ab.
**B5. Custom Design / Logo — 465 Impr., 0 Klicks**
`/custom-qr-code-generator`: 634 Impr., Pos. 42,8.
- `custom color qr code` (49), `custom design qr code` (44), `custom qr codes with logo` (42), `custom qr code designs` (33), `custom qr code with logo` (31), `how to make custom qr codes` (23), `how to make a custom qr code with logo` (13)
**Post: "Custom QR Codes mit Logo: Design-Regeln, die die Scanbarkeit nicht kaputt machen"** — Fehlerkorrektur-Level, Kontrastminimum, Logo-Größe max. 30 %, Farbkombis die scheitern. Klarer How-to-Intent, den die Tool-Seite nicht bedient.
**B6. Bulk / Batch aus Excel & CSV — 402 Impr., aber bereits 2 Klicks + gute Positionen**
- `bulk qr code generator in google sheets` (23, Pos. 38, **1 Klick**), `batch qr code generator from excel` (32, Pos. 41,8), `bulk qr code generator from excel` (3, Pos. 22, **1 Klick**), `bulk qr code generator excel` (18), `csv qr code generator` (13), `free bulk qr code generator excel` (14)
**Post: "QR Codes aus Excel oder Google Sheets erzeugen (Schritt für Schritt)"** — sehr konkreter Workflow-Intent, konvertiert nachweislich schon jetzt.
**B7. Feedback QR — 349 Impr., 0 Klicks**
`/use-cases/feedback-qr-codes`: 385 Impr., Pos. 60,3.
- `qr code feedback` (165), `qr code for feedback` (93), `feedback qr code` (62), `qr code for customer feedback` (23), `create qr code for feedback` (21)
**Post: "Kundenfeedback per QR Code sammeln: Formulare, Response-Raten, Platzierung"**
---
## C) Konsolidieren statt neu schreiben
**Tracking-Cluster (2.371 Impr., 0 Klicks)** — 5 Seiten kannibalisieren sich (Tabelle oben).
`/blog/trackable-qr-codes`, `/blog/qr-code-analytics`, `/guide/tracking-analytics` per 301 auf `/blog/qr-code-tracking-guide-2025` zusammenlegen. Dieser eine Post wird der Support-Content für `/qr-code-tracking`.
Zielkeywords: `qr code tracking` (190), `tracking qr code` (158), `qr tracking` (145), `how to track qr code` (118), `track qr code scans` (92), `qr code scan tracking` (88)
Bereits stark: `how to track qr code scans from a print campaign` (43 Impr., **Pos. 9,9**) — als eigenes H2 ausbauen.
**Dynamic-QR-Cluster (3.598 Impr., 0 Klicks — größtes Volumen der Site)**
`/dynamic-qr-code-generator` hat 4.147 Impr. bei Pos. 45,5 und **0,02 % CTR**.
Support-Posts existieren, ranken aber ohne Impressionen (Indexierungsproblem):
- `/blog/static-vs-dynamic-qr-code` (Pos. 3, aber nur 1 Impr.)
- `/blog/dynamic-vs-static-qr-codes` (Pos. 5, 1 Impr.) ← Duplikat des vorigen
- `/blog/convert-static-to-dynamic-qr-code` (Pos. 3,5, 2 Impr.)
- `/guide/dynamic-qr-code-best-practices` (Pos. 50)
→ Die beiden static-vs-dynamic-Posts sind faktisch dieselbe Seite. Zusammenlegen, dann alle drei prominent von der Money-Page verlinken. Kein neuer Post nötig.
**Instagram**`/tools/instagram-qr-code` (794 Impr., Pos. 33,7) vs. `/blog/instagram-qr-code-generator` (11 Impr., Pos. 73,4). Blogpost zu How-to umschreiben oder konsolidieren.
---
## Priorisierung
| # | Maßnahme | Aufwand | Potenzial |
|---|---|---|---|
| 1 | Tracking-Cluster konsolidieren (C) | mittel | 2.371 Impr. |
| 2 | Title/Meta Teams-Post fixen (A2) | 15 Min | Pos. 9,6 ohne Klicks |
| 3 | Google-Review-Post neu (B1) | hoch | 1.331 Impr. |
| 4 | Free-vs-Paid FAQ ergänzen (A1) | niedrig | Pos. 26 → Seite 1 möglich |
| 5 | Static-vs-Dynamic-Duplikate mergen (C) | niedrig | entsperrt 3.598 Impr. |
| 6 | Excel/Sheets-Bulk-Post (B6) | mittel | konvertiert bereits |
| 7 | Location/Geo-Post (B2) | mittel | 923 Impr. |
| 8 | Wettbewerbsvergleich vs-Post (B4) | mittel | 350 Impr., hohe Kaufintention |
## Nicht priorisieren
`usdt qr code generator` (85 Impr., Pos. 13, 2 Klicks) und der Crypto-Cluster (765 Impr.) ranken vergleichsweise gut, haben aber schwache Monetarisierung. `system qr dla hoteli` (PL), `qr kütüphane` (TR), `куар мастер` (RU) — internationale Streuung ohne lokalisierte Seiten, aktuell ignorieren.

View File

@@ -0,0 +1,226 @@
# SEO-Umsetzungsplan qrmaster.net
Erstellt: 2026-08-04 · Datenbasis: GSC-Export 2026-08-03 (letzte 3 Monate)
Zugeschnitten auf **wenige Stunden pro Woche** — jeder Schritt ist einzeln abschließbar.
Ausgangslage: ~34.000 Impressionen, ~150 Klicks außerhalb der Homepage. Kein Sichtbarkeits-, sondern ein Positions- und Konsolidierungsproblem.
---
## REVISION 2026-08-04 — nach Code- und Live-Prüfung
Beim Umsetzen von Phase 1 hat sich die Ausgangslage als anders herausgestellt als aus den reinen GSC-Daten ableitbar. Drei Befunde, die den Plan verändern.
### Befund 1: Kaputter Title (behoben)
`/blog/qr-code-tracking-guide-2025` hatte rohes HTML im `title`-Feld:
```
title: '<a href="/qr-code-tracking" class="...">QR Code Tracking</a>: Complete Guide 2026'
```
Live ausgeliefert wurde daraus im `<title>`-Tag, in der H1, in `og:title` und `twitter:title`:
```
&lt;a href="/qr-code-tracking" class="text-blue-600 underline font-semibold"&gt;QR Code Tracking&lt;/a&gt;: Complete Guide 2026 | QR Master
```
**Das erklärt Position 79,4 vollständig.** Kein Content-, kein Kannibalisierungsproblem — ein unbrauchbares Snippet. Einzelvorkommen, alle anderen Titles sind sauber. Behoben in `src/lib/blog-data.ts:1160`.
### Befund 2: Konsolidierungsrunde lief bereits (Commit 671c1a1 / 2026-07-10)
Live und funktionierend:
| Quelle | Ziel |
|---|---|
| `/blog/qr-code-analytics` | `/qr-code-analytics` |
| `/blog/qr-code-restaurant-menu` | `/restaurants` |
| `/guide/tracking-analytics` | `/learn/tracking` |
| `/guide/qr-code-best-practices` | `/learn/basics` |
| `/guide/bulk-qr-code-generation` | `/learn/developer` |
Die GSC-Positionen dieser URLs sind damit **historisch** — die Redirects waren nur die letzten ~3,5 Wochen des Auswertungsfensters aktiv. Die Phase-1.1-Tabelle weiter unten ist insoweit überholt.
**Konsequenz:** Nicht `/blog/qr-code-tracking-guide-2025` zum Ziel machen, wie ursprünglich geplant. Die bestehende Architektur ist `/learn/[pillar]` als Hub. Dieser Struktur folgen, keine dritte parallel aufmachen.
### Befund 3: Der Learn-Hub hält die Kannibalisierung am Leben
`/learn/tracking` listet und verlinkt **alle fünf** konkurrierenden Tracking-Artikel gleichzeitig — inklusive `/blog/qr-code-analytics`, das eine 301-Quelle ist. Der Hub ist damit nicht die Lösung, sondern der Motor: Er hält jeden Artikel im Index und intern verlinkt, statt Signale zu bündeln.
Zusätzlich behoben:
- **Footer** verlinkte sitewide auf `/guide/tracking-analytics` und `/guide/qr-code-best-practices` — beides 301-Quellen. Jede Seite der Site leitete Linkkraft durch eine Weiterleitung. → jetzt `/learn/tracking` und `/learn/basics` (`src/components/ui/Footer.tsx:88-89`)
- **IndexNow** meldete aktiv die drei `/guide/*`-URLs an Suchmaschinen, also Redirect-Quellen. → jetzt die `/learn/*`-Pillars (`src/lib/indexnow.ts:125-130`)
### Offen aus dieser Runde
- [ ] `/learn/tracking` entrümpeln: nur noch auf die eine überlebende Tracking-Seite verlinken, nicht auf alle fünf
- [ ] `/blog/trackable-qr-codes` (204 Impr., Pos. 81,5) ist **nicht** weitergeleitet und lebt weiter → 301 auf den Guide
- [ ] `src/app/(main)/guide/*/page.tsx` existieren noch als Komponenten mit Self-Canonical, obwohl der Redirect greift → Dead Code entfernen
- [ ] Drei FAQ-Antworten in `industry-pages.ts` (Zeilen 102, 375, 735) und `growth-pages.ts:473` verlinken auf `/blog/qr-code-restaurant-menu` → auf `/restaurants` umhängen
**Erst diese Punkte, dann Phase 2.** Die Reihenfolge im Plan bleibt sonst gültig.
---
## Wichtige Korrektur vorab: Intent-Check vor Snippet-Rewrites
Ich hatte empfohlen, die `/qr-code-for/`-Seiten mit Position < 20 und 0 % CTR per Title-Rewrite zu fixen. Beim Prüfen der Seiten stimmt das **nur zum Teil**. Die Meta-Titles sind bereits gut geschrieben:
> `barbershops` → „QR Codes for Barbershops: Bookings & Reviews"
> `cinemas` → „QR Codes for Cinemas: Tickets & Loyalty"
Das Problem liegt woanders. Beispiel `/qr-code-for/barbershops` (327 Impr., Position 9,8, 4 Klicks) — die tatsächlichen Suchanfragen:
| Query | Impr. | Pos. |
|---|---|---|
| short code for barbershops | 16 | 21,8 |
| qr barber | 13 | 8,9 |
| short code for barber shops | 6 | 16,3 |
| barber+scan | 5 | 9,6 |
| barcode grooming | 4 | 11,0 |
| short code for barber shop | 4 | 26,0 |
Das Muster `short code for X` zieht sich durch: `short code for barbers`, `for yoga studios`, `for cinemas`, `for bars`, `for nail bars`, `for theaters`, `for barbershop` — zusammen rund 60 Impressionen. Wer „short code for barbershops" sucht, will mit hoher Wahrscheinlichkeit einen **SMS-Short-Code**, keinen QR-Code. Diese Impressionen sind mit keinem Title der Welt klickbar.
**Konsequenz für den Plan:** Vor jedem Rewrite die Queries der Einzelseite prüfen. Nur Seiten anfassen, deren Top-Queries echte QR-Intention haben. Das kostet pro Seite zwei Minuten und verhindert, dass Phase 2 zu Beschäftigungstherapie wird.
---
## Phase 0 — Messpunkt setzen (30 Min, einmalig)
Ohne Baseline lässt sich später nicht sagen, ob etwas gewirkt hat.
- [ ] GSC-Export von heute unter `/seo-baseline/2026-08-03/` im Repo ablegen
- [ ] Vier Zahlen notieren: Gesamtklicks, Gesamtimpressionen, Ø-Position, Anzahl Keywords auf Position < 10
- [ ] Kalendereintrag: gleicher Export am **2026-11-03** (Google braucht 610 Wochen, vorher ist jede Bewertung Rauschen)
**Erfolgskriterium:** Datei liegt im Repo.
---
## Phase 1 — Kannibalisierung auflösen (Wochen 12)
Höchster Hebel im ganzen Plan, weil kein neuer Content nötig ist. Fünf Seiten konkurrieren um das Tracking-Thema, keine rankt.
### 1.1 Tracking-Cluster zusammenlegen (~2 Std.)
| URL | Impr. | Pos. | Aktion |
|---|---|---|---|
| `/qr-code-tracking` | 2.148 | 48,4 | **bleibt** — Money-Page |
| `/blog/qr-code-tracking-guide-2025` | 67 | 79,4 | **bleibt** — wird der eine Ratgeber |
| `/blog/trackable-qr-codes` | 204 | 81,5 | 301 → Guide |
| `/blog/qr-code-analytics` | 93 | 85,1 | 301 → Guide |
| `/guide/tracking-analytics` | 1 | 91,0 | 301 → Guide |
- [ ] Die besten Absätze aus den drei Seiten in den Guide übernehmen, bevor umgeleitet wird
- [ ] Redirects in `next.config.mjs` eintragen
- [ ] Guide umbenennen: `qr-code-tracking-guide-2025` → Jahreszahl raus oder auf 2026 (Slug-Änderung nur mit Redirect)
- [ ] Eigenes H2 für `how to track qr code scans from a print campaign` — steht bereits auf **Position 9,9** bei 43 Impressionen
- [ ] Wechselseitige Verlinkung Guide ↔ `/qr-code-tracking`
**Erfolgskriterium:** Eine URL pro Suchintention. `/qr-code-tracking` unter Position 30 bis November.
### 1.2 Static-vs-Dynamic-Duplikate mergen (~1 Std.)
`/blog/static-vs-dynamic-qr-code` (Pos. 3, 1 Impr.) und `/blog/dynamic-vs-static-qr-codes` (Pos. 5, 1 Impr.) sind faktisch dieselbe Seite. Beide ranken top und bekommen zusammen 2 Impressionen — klassisches Zeichen dafür, dass Google beide kennt und keiner traut.
- [ ] Inhalte in **eine** URL zusammenführen, die andere per 301 darauf
- [ ] Zusammen mit `/blog/convert-static-to-dynamic-qr-code` (Pos. 3,5) und `/guide/dynamic-qr-code-best-practices` (Pos. 50) prominent von `/dynamic-qr-code-generator` verlinken
**Warum das zählt:** `/dynamic-qr-code-generator` hat mit 4.147 Impressionen das größte Volumen der Site — bei Position 45,5 und 0,02 % CTR.
### 1.3 Instagram entzerren (~30 Min)
`/tools/instagram-qr-code` (794 Impr., Pos. 33,7) vs. `/blog/instagram-qr-code-generator` (11 Impr., Pos. 73,4).
- [ ] Blogpost auf How-to-Intent umschreiben („So erstellst du…") oder konsolidieren, damit er nicht mehr um dasselbe Keyword kämpft
---
## Phase 2 — Snippets mit Intent-Check (Woche 3, ~2 Std.)
Nur Seiten mit Position < 20, CTR < 3 % **und** passender Query-Intention.
**Kandidaten mit belegbar richtiger Intention:**
- [ ] `/blog/microsoft-teams-qr-code` — 287 Impr., **Position 14,4, 0 Klicks**. Queries sind sauber: `microsoft teams qr code generator` (Pos. 9,6), `teams qr code generator` (Pos. 6,6), `qr code for teams meeting` (Pos. 11). Klarster Einzelfall der Site.
- [ ] `/tools/teams-qr-code` — 489 Impr., Pos. 13,6, CTR 2,45 %
- [ ] `/tools/call-qr-code-generator` — 119 Impr., Pos. 18,9, 0 Klicks
- [ ] `/learn` — 486 Impr., Pos. 13,3, CTR 1,65 %
**Erst nach Query-Prüfung anfassen:** `/qr-code-for/cinemas` (168 Impr., Pos. 16), `/qr-code-for/airports` (162, Pos. 10,6), `/qr-code-for/yoga-studios`, `/qr-code-for/art-galleries`, `/qr-code-for/catering`, `/qr-code-for/car-dealerships`.
Prüfweg: GSC → Seiten → URL wählen → Tab „Suchanfragen". Enthalten die Top-3-Queries `short code`, `barcode` oder Branchenbegriffe ohne QR-Bezug, ist die Seite kein Snippet-Fall.
Titles und Descriptions liegen zentral in `src/lib/industry-pages.ts` (Felder `metaTitle`, `metaDescription`) — kein Anfassen einzelner Routen nötig.
**Erfolgskriterium:** Teams-Post von 0 auf messbare Klicks. Alles andere ist Bonus.
---
## Phase 3 — Aufräumen (Woche 4, ~1 Std.)
Nicht die 53 Branchenseiten löschen — die ranken mit Ø Position 20,4 am besten von allen Content-Typen. Tot ist etwas anderes:
| Bereich | Seiten | Impr. | Klicks | Ø Pos. | Vorschlag |
|---|---|---|---|---|---|
| `/de/*` | 13 | 194 | 0 | 31,0 | Entscheiden: ausbauen oder deindexieren |
| `/guide/*` | 2 | 15 | 0 | 70,6 | In `/blog/` überführen (siehe 1.1) |
| `/compare/*` | 1 | 31 | 0 | 50,6 | Prüfen, ob es die Route noch braucht |
| `/qr-code-erstellen` | 1 | 264 | 2 | 72,5 | Deutsche Seite ohne DE-Strategie — bündeln mit `/de/*` |
Die DE-Frage ist eine Entscheidung, keine Aufgabe: 13 Seiten mit 0 Klicks binden Crawl-Budget. Entweder eine echte deutsche Sektion mit hreflang, oder weg.
---
## Phase 4 — Neue Posts, einer pro Woche (ab Woche 5)
Reihenfolge nach Impressionen ohne passende Seite. Jeweils ein Post, dann weiter.
| Woche | Post | Nachfrage | Zielseite, die profitiert |
|---|---|---|---|
| 5 | Google Review QR Code erstellen (inkl. Place-ID) | 1.331 Impr. | `/tools/google-review-qr-code` (1.752 Impr., Pos. 37,8) |
| 6 | QR Codes aus Excel & Google Sheets | 402 Impr., **konvertiert bereits** | `/bulk-qr-code-generator` |
| 7 | Location-QR: Maps, GPS, geo:-URI auf iOS vs. Android | 923 Impr. | `/tools/geolocation-qr-code` (Pos. 62,8) |
| 8 | Custom QR mit Logo ohne Scanbarkeitsverlust | 465 Impr. | `/custom-qr-code-generator` |
| 9 | QR-Coupons: Einlösung tracken, Missbrauch verhindern | 495 Impr. | `/use-cases/coupon-qr-codes` |
| 10 | Beaconstac vs Popl vs Blinq vs QR Master | ~350 Impr., hohe Kaufintention | `/alternatives/*` |
| 11 | Kundenfeedback per QR sammeln | 349 Impr. | `/use-cases/feedback-qr-codes` |
Jeder Post: Verlinkung zur Money-Page rein **und** raus, Aufnahme in `public/llms.txt`, Eintrag im Sitemap-Lauf.
**Woche 6 zuerst ziehen, falls Zeit knapp wird**`bulk qr code generator in google sheets` und `bulk qr code generator from excel` haben als einzige Nicht-Brand-Keywords bereits Klicks geliefert. Bewiesene Konversion schlägt großes Volumen.
---
## Phase 5 — Daten-Asset (ab Woche 12, größerer Block)
Aggregierte, anonymisierte Scan-Daten aus QRMaster als Studie: Scan-Zeitpunkte über den Tag, Device-Splits, wie oft dynamische Codes nach dem Druck tatsächlich geändert werden, Abbruchraten.
**Realitätscheck vorab:** `/blog/qr-code-scan-statistics-2026` existiert bereits — Position 38,7, **3 Impressionen**. Ein Daten-Asset rankt nicht, weil es eines ist. Ohne Verteilung passiert nichts.
- [ ] Bestehende Statistik-Seite als Basis nehmen statt neu anzulegen
- [ ] Echte Zahlen aus der DB, Methodik sichtbar dokumentieren, Stichprobengröße nennen
- [ ] Eigene Grafiken statt Fremdquellen
- [ ] Erst danach: aktiv verteilen (Reddit, Branchennewsletter, HARO-artige Anfragen)
Der Wert liegt im Zitiertwerden durch LLMs und in Backlinks, nicht im direkten Ranking. Entsprechend bewerten.
---
## Was dieser Plan bewusst nicht enthält
**Crypto-Cluster** (765 Impr.) — rankt vergleichsweise gut (`usdt qr code generator` Pos. 13, 2 Klicks), monetarisiert aber schwach.
**Internationale Streuung**`system qr dla hoteli` (PL), `qr kütüphane` (TR), `куар мастер` (RU). Ohne lokalisierte Seiten nicht adressierbar, siehe DE-Entscheidung in Phase 3.
**E-E-A-T-Maßnahmen**`/authors/timo` existiert bereits (Position 6,4) und hat für sich genommen nichts bewegt. Kein weiterer Aufwand nötig.
**Alles, was „schnell rankt"** — Phase 1 ist der schnellste Effekt im Plan, und auch der braucht 610 Wochen bis zur Messbarkeit.
---
## Nächster Schritt
Phase 1.1 (Tracking-Konsolidierung). Zwei Stunden, kein neuer Text, betrifft 2.371 Impressionen.

View File

@@ -0,0 +1,203 @@
---
title: "Barcode Encoding Algorithms: EAN-13 & Code 128 Checksum Math from Scratch in JavaScript"
description: "A deep computer science exploration of 1D barcode encoding algorithms, covering Modulo 10 and Modulo 103 checksum calculations, building a free barcode generator and a code 128 barcode generator in TypeScript."
tags: javascript, typescript, algorithms, computer-science
keywords: free barcode generator, ean code generator, code 128 barcode generator, qr barcode, barcode code generator, free barcode, print barcode
canonical_url: https://www.qrmaster.net/blog/barcode-generator-tool
---
# Barcode Encoding Algorithms: EAN-13 & Code 128 Checksum Math from Scratch in JavaScript
Long before 2D QR codes dominated digital marketing, one-dimensional (1D) linear barcodes—such as **EAN-13** in retail products and **Code 128** in logistics and shipping—revolutionized inventory automation.
Building a **free barcode generator** or an **ean code generator** requires understanding that barcode scanner guns and camera libraries do not "guess" numbers from images; they decode precise binary bit patterns (bars and spaces) and verify mathematical **checksums** (Modulo 10 for EAN-13; Modulo 103 for Code 128).
In this deep computer science guide, we will examine the bit pattern structures of EAN-13 and Code 128, derive their checksum formulas, and implement a pure TypeScript **barcode code generator** without any external npm dependencies.
---
## 1. Deconstructing EAN-13 Retail Barcode Encoding
An **EAN-13** (European Article Number) barcode produced by an **ean code generator** encodes exactly 13 numeric digits:
- **First 23 digits**: Country Prefix (e.g., `400440` for Germany, `000019` for US/Canada).
- **Next 45 digits**: Manufacturer Identification Code.
- **Next 45 digits**: Unique Item / Product Code.
- **13th Digit**: Mathematical **Modulo 10 Checksum Digit**.
```
Country Manufacturer Product Check
┌──┴──┐ ┌────┴────┐ ┌───┴───┐ ┌┴┐
4 0 0 1 2 3 4 5 6 7 8 9 5
```
### The EAN-13 Modulo 10 Checksum Formula
To compute the 13th check digit for a 12-digit input in an **ean code generator**:
1. Sum all digits in **odd-numbered positions** (1st, 3rd, 5th, 7th, 9th, 11th).
2. Sum all digits in **even-numbered positions** (2nd, 4th, 6th, 8th, 10th, 12th) and multiply by 3.
3. Add the two sums together.
4. The check digit is the number required to reach the next multiple of 10:
$$\text{Check Digit} = (10 - (\text{Total Sum} \pmod{10})) \pmod{10}$$
### Checksum Example Calculation:
Take the 12-digit string `400123456789`:
- Odd sum: $4 + 0 + 2 + 4 + 6 + 8 = 24$
- Even sum: $(0 + 1 + 3 + 5 + 7 + 9) \times 3 = 25 \times 3 = 75$
- Total: $24 + 75 = 99$
- Check digit: $(10 - (99 \pmod{10})) \pmod{10} = (10 - 9) \pmod{10} = 1$
- Final 13-digit EAN-13 code: `4001234567891`
---
## 2. Deconstructing Code 128 High-Density Barcodes
While EAN-13 is strictly numeric, a **code 128 barcode generator** creates high-density alphanumeric barcode formats capable of encoding all 128 ASCII characters (uppercase/lowercase letters, digits, punctuation, and control codes).
### Code 128 Structure
A Code 128 **qr barcode** structure consists of:
1. **Start Character**: `Start A` (103), `Start B` (104), or `Start C` (105).
2. **Data Symbol Characters**: Each character is represented by 11 modules composed of 3 bars and 3 spaces.
3. **Check Character**: Modulo 103 checksum value.
4. **Stop Character**: 13-module pattern (`1100011101011`).
### The Code 128 Modulo 103 Checksum Formula
$$\text{Checksum Value} = \left( \text{Start Value} + \sum_{i=1}^{N} (i \times \text{Symbol Value}_i) \right) \pmod{103}$$
---
## 3. Pure TypeScript Barcode Engine (No External Dependencies)
Let's build a standalone TypeScript module (`src/services/barcodeEngine.ts`) for a **free barcode generator** that computes EAN-13 checksums and renders a vector SVG **print barcode**.
### `src/services/barcodeEngine.ts`
```typescript
export class BarcodeEngine {
/**
* Computes the Modulo 10 Checksum digit for a 12-digit EAN string in an ean code generator.
*/
public static calculateEAN13Checksum(digits12: string): number {
if (!/^\d{12}$/.test(digits12)) {
throw new Error('EAN-13 input must be exactly 12 numeric digits.');
}
let oddSum = 0;
let evenSum = 0;
for (let i = 0; i < 12; i++) {
const digit = parseInt(digits12[i], 10);
if (i % 2 === 0) {
oddSum += digit;
} else {
evenSum += digit;
}
}
const totalSum = oddSum + evenSum * 3;
const remainder = totalSum % 10;
return remainder === 0 ? 0 : 10 - remainder;
}
/**
* EAN-13 Binary Bit Patterns for L, G, and R encodings.
*/
private static L_PATTERNS = [
'0001101', '0011001', '0010011', '0111101', '0100011',
'0110001', '0101111', '0111011', '0110111', '0001011'
];
private static R_PATTERNS = [
'1110010', '1100110', '1101100', '1000010', '1011100',
'1001110', '1010000', '1000100', '1001000', '1110100'
];
/**
* Generates a crisp vector SVG string for an EAN-13 barcode.
*/
public static generateEAN13SVG(digits12: string): string {
const checkDigit = this.calculateEAN13Checksum(digits12);
const fullEan13 = digits12 + checkDigit.toString();
// Structural guard and center patterns
const GUARD_START = '101';
const GUARD_CENTER = '01010';
const GUARD_END = '101';
let bitPattern = GUARD_START;
// Encode Left 6 Digits (using L-Patterns for simplicity)
for (let i = 1; i <= 6; i++) {
const digit = parseInt(fullEan13[i], 10);
bitPattern += this.L_PATTERNS[digit];
}
bitPattern += GUARD_CENTER;
// Encode Right 6 Digits (using R-Patterns)
for (let i = 7; i <= 12; i++) {
const digit = parseInt(fullEan13[i], 10);
bitPattern += this.R_PATTERNS[digit];
}
bitPattern += GUARD_END;
// Render SVG
const moduleWidthPx = 3;
const heightPx = 120;
const totalWidthPx = bitPattern.length * moduleWidthPx + 40; // 40px margin
let svgPaths = '';
for (let i = 0; i < bitPattern.length; i++) {
if (bitPattern[i] === '1') {
const x = 20 + i * moduleWidthPx;
svgPaths += `<rect x="${x}" y="10" width="${moduleWidthPx}" height="${heightPx - 30}" fill="#000000" />`;
}
}
// Add human-readable numbers text below bars
const textSvg = `<text x="${totalWidthPx / 2}" y="${heightPx - 5}" font-family="monospace" font-size="16" text-anchor="middle">${fullEan13}</text>`;
return `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${totalWidthPx} ${heightPx}" width="${totalWidthPx}" height="${heightPx}">
<rect width="100%" height="100%" fill="#FFFFFF" />
${svgPaths}
${textSvg}
</svg>`.trim();
}
}
```
---
## 4. Verification & Testing
Let's write a unit test to verify checksum calculation and SVG rendering output.
```typescript
import { BarcodeEngine } from '../src/services/barcodeEngine';
describe('BarcodeEngine', () => {
test('should correctly compute EAN-13 Modulo 10 Checksum', () => {
// 400123456789 -> Check digit should be 1
const check = BarcodeEngine.calculateEAN13Checksum('400123456789');
expect(check).toBe(1);
});
test('should generate valid vector SVG string', () => {
const svg = BarcodeEngine.generateEAN13SVG('400123456789');
expect(svg).toContain('<svg');
expect(svg).toContain('4001234567891'); // Includes computed check digit
expect(svg).toContain('</svg>');
});
});
```
---
## Conclusion
Understanding the binary bit patterns and mathematical checksum algorithms behind 1D barcodes allows developers to build a fast **free barcode generator** without relying on heavy external dependencies.
To generate free high-resolution EAN-13, UPC-A, and Code 128 barcodes online, check out [QR Master Free Barcode Generator](https://www.qrmaster.net/blog/barcode-generator-tool).

View File

@@ -0,0 +1,359 @@
---
title: "Building a High-Performance Custom QR Code Generator API with Node.js & Vector SVG"
description: "A complete step-by-step developer guide to building a custom QR code generator API in Node.js, covering vector SVG rendering, Reed-Solomon error correction, and creating QR codes from links."
tags: nodejs, javascript, webdev, api
keywords: custom qr code generator, create qr code from link, qr code generator online, custom qr code generator free, qr code link generator
canonical_url: https://www.qrmaster.net/blog/qr-code-api-documentation
---
# Building a High-Performance Custom QR Code Generator API with Node.js & Vector SVG
QR codes have evolved from simple black-and-white square grids into essential digital-to-physical bridges. Whether you are building a **custom qr code generator** for an application, creating a **qr code generator online** for ticket barcodes, or building an internal microservice to **create a qr code from a link**, building your own API gives you total control over styling, performance, data privacy, and branding.
In this deep-dive guide, we will build a production-ready, high-performance REST API in Node.js and Express that generates vector SVG and high-density PNG QR codes on the fly. We will also explore the math behind Reed-Solomon error correction, quiet zones, color contrast ratios, and how to optimize a **free custom qr code generator** for crisp printing.
---
## 1. Understanding QR Code Architecture & Error Correction
Before writing any code, it is critical to understand how a **custom qr code generator** stores data and why vector graphics (SVG) are vastly superior to raster images (PNG/JPEG) for print media.
### The QR Code Grid Structure
A QR code is a two-dimensional matrix barcode consisting of:
1. **Finder Patterns**: The three large squares located at the top-left, top-right, and bottom-left corners. Cameras use these to detect the barcode's orientation and scale.
2. **Alignment Patterns**: Smaller squares (found in Version 2 and larger) that correct for non-linear distortion when a camera scans a curved surface.
3. **Timing Patterns**: Alternating black and white modules connecting the finder patterns to establish the matrix coordinate grid size.
4. **Format Information**: Modules storing the error correction level and the mask pattern used.
5. **Data & Error Correction Codewords**: The actual payload (URL link, text, JSON) mixed with Reed-Solomon redundancy blocks.
### Reed-Solomon Error Correction Levels
QR codes use **Reed-Solomon Error Correction**, allowing damaged, dirty, or obscured codes to remain fully scannable:
| Level | Error Recovery Capacity | Recommended Use Case |
|---|---|---|
| **L (Low)** | ~7% of codewords restored | Minimal data size, clean digital screens |
| **M (Medium)** | ~15% of codewords restored | Standard marketing URLs, digital displays |
| **Q (Quartile)** | ~25% of codewords restored | Industrial packaging, outdoor signage |
| **H (High)** | ~30% of codewords restored | Embedding brand logos in a **custom qr code generator** |
*Rule of thumb:* When embedding custom logos or high-contrast graphics in the center of a QR code, always enforce **Level H** so the remaining 70% of un-obscured modules provide 100% data integrity.
---
## 2. Why SVG Vector Output Matters for Developers
Raster formats like PNG or JPEG store pixels. If a 300x300 pixel PNG QR code is printed on a large 2-meter billboard, the square modules become blurry and pixelated, leading to scanner camera read failures.
Vector SVG (`Scalable Vector Graphics`) defines QR modules as crisp mathematical paths (`<path d="M..."/>` or `<rect x="..." y="..."/>`). SVG files:
- Scale infinitely to any print dimension (from business cards to stadium billboards) without loss of crispness.
- Have a tiny file footprint (typically < 2 KB per code).
- Allow programmatic CSS styling of foreground, background, and finder pattern colors.
---
## 3. Step-by-Step API Implementation
Let's build a Node.js API with Express that accepts JSON payloads or URL query parameters and streams vector SVG or PNG outputs to **create a qr code from a link**.
### Step 3.1: Project Setup & Dependencies
Initialize a new Node.js project and install the required dependencies:
```bash
mkdir qr-code-api
cd qr-code-api
npm init -y
npm install express qrcode cors helmet express-rate-limit dotenv
npm install --save-dev typescript @types/node @types/express @types/cors ts-node-dev
```
Initialize TypeScript configuration (`tsconfig.json`):
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
```
---
### Step 3.2: Creating the QR Generator Core Engine
Create `src/services/qrEngine.ts`. This service handles matrix generation, error correction mapping, and SVG DOM construction.
```typescript
import QRCode, { QRCodeRenderersOptions } from 'qrcode';
export interface QROptions {
text: string;
errorCorrectionLevel?: 'L' | 'M' | 'Q' | 'H';
width?: number;
margin?: number;
colorDark?: string;
colorLight?: string;
format?: 'svg' | 'png' | 'utf8';
}
export class QREngine {
/**
* Generates a scalable vector SVG QR code string.
*/
public static async generateSVG(options: QROptions): Promise<string> {
const {
text,
errorCorrectionLevel = 'M',
margin = 4,
colorDark = '#000000',
colorLight = '#FFFFFF'
} = options;
const qrOptions: QRCodeRenderersOptions = {
errorCorrectionLevel,
margin,
color: {
dark: colorDark,
light: colorLight
}
};
try {
const svgString = await QRCode.toString(text, {
...qrOptions,
type: 'svg'
});
return svgString;
} catch (err) {
throw new Error(`Failed to generate SVG QR code: ${(err as Error).message}`);
}
}
/**
* Generates a high-density PNG buffer for binary image response.
*/
public static async generatePNGBuffer(options: QROptions): Promise<Buffer> {
const {
text,
errorCorrectionLevel = 'H',
width = 600,
margin = 4,
colorDark = '#000000',
colorLight = '#FFFFFF'
} = options;
const qrOptions: QRCodeRenderersOptions = {
errorCorrectionLevel,
width,
margin,
color: {
dark: colorDark,
light: colorLight
}
};
try {
const buffer = await QRCode.toBuffer(text, {
...qrOptions,
type: 'png'
});
return buffer;
} catch (err) {
throw new Error(`Failed to generate PNG QR buffer: ${(err as Error).message}`);
}
}
}
```
---
### Step 3.3: Building the Express REST Controller & API Endpoints
Create `src/app.ts` to set up rate limiting, CORS, input validation, and REST route handlers.
```typescript
import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import { QREngine, QROptions } from './services/qrEngine.js';
const app = express();
// Security Middlewares
app.use(helmet());
app.use(cors());
app.use(express.json({ limit: '1mb' }));
// Rate Limiter: Prevent API abuse (max 100 requests per minute per IP)
const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000,
max: 100,
message: { error: 'Too many QR generation requests. Please try again later.' }
});
app.use('/api/', apiLimiter);
/**
* GET /api/v1/qr
* Query Params: text, ecLevel, margin, colorDark, colorLight, format
*/
app.get('/api/v1/qr', async (req: Request, res: Response, next: NextFunction) => {
try {
const text = req.query.text as string;
if (!text) {
return res.status(400).json({ error: 'Query parameter "text" is required to create qr code from link.' });
}
const format = ((req.query.format as string) || 'svg').toLowerCase();
const ecLevel = ((req.query.ecLevel as string) || 'M').toUpperCase() as 'L' | 'M' | 'Q' | 'H';
const margin = parseInt(req.query.margin as string, 10) || 4;
const colorDark = (req.query.colorDark as string) || '#000000';
const colorLight = (req.query.colorLight as string) || '#FFFFFF';
const options: QROptions = {
text,
errorCorrectionLevel: ecLevel,
margin,
colorDark,
colorLight
};
if (format === 'png') {
const width = parseInt(req.query.width as string, 10) || 600;
const pngBuffer = await QREngine.generatePNGBuffer({ ...options, width });
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'public, max-age=86400'); // Cache for 24 hours
return res.send(pngBuffer);
}
// Default: Vector SVG
const svgString = await QREngine.generateSVG(options);
res.setHeader('Content-Type', 'image/svg+xml');
res.setHeader('Cache-Control', 'public, max-age=86400');
return res.send(svgString);
} catch (error) {
next(error);
}
});
/**
* POST /api/v1/qr/batch
* JSON Body: { items: Array<QROptions> }
*/
app.post('/api/v1/qr/batch', async (req: Request, res: Response, next: NextFunction) => {
try {
const { items } = req.body;
if (!Array.isArray(items) || items.length === 0) {
return res.status(400).json({ error: 'JSON payload must contain an array "items" with at least one element.' });
}
if (items.length > 50) {
return res.status(400).json({ error: 'Batch limit exceeded. Maximum 50 QR codes allowed per request.' });
}
const results = await Promise.all(
items.map(async (item: QROptions) => {
const svg = await QREngine.generateSVG({
text: item.text,
errorCorrectionLevel: item.errorCorrectionLevel || 'M',
colorDark: item.colorDark || '#000000',
colorLight: item.colorLight || '#FFFFFF'
});
return { text: item.text, svg };
})
);
return res.json({ count: results.length, data: results });
} catch (error) {
next(error);
}
});
// Central Error Handler
app.use((err: Error, req: Request, res: Response, _next: NextFunction) => {
console.error('[QR-API Error]:', err.message);
res.status(500).json({ error: 'Internal Server Error', message: err.message });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`🚀 Custom QR Code Generator API running on http://localhost:${PORT}`);
});
```
---
## 4. Testing Your API with cURL & Examples
Start the development server:
```bash
npx ts-node-dev src/app.ts
```
### Example 1: Requesting a Vector SVG QR Code
Run the following cURL command to fetch an SVG QR code from a link:
```bash
curl -X GET "http://localhost:3000/api/v1/qr?text=https://www.qrmaster.net&ecLevel=H&colorDark=%231E293B&colorLight=%23F8FAFC" \
-H "Accept: image/svg+xml" \
--output qrcode.svg
```
### Example 2: Requesting a High-Resolution PNG for Print
Fetch a 1000px high-density PNG QR code:
```bash
curl -X GET "http://localhost:3000/api/v1/qr?text=https://www.qrmaster.net&format=png&width=1000&ecLevel=Q" \
--output qrcode.png
```
### Example 3: Batch API Request
Send a POST request with multiple items:
```bash
curl -X POST "http://localhost:3000/api/v1/qr/batch" \
-H "Content-Type: application/json" \
-d '{
"items": [
{ "text": "https://www.qrmaster.net/docs", "colorDark": "#0284C7" },
{ "text": "https://www.qrmaster.net/pricing", "colorDark": "#059669" }
]
}'
```
---
## 5. Production Best Practices & Design Pitfalls
When deploying a production-grade **custom qr code generator free** service, keep these crucial guidelines in mind:
### 1. Maintain Contrast Ratios
Camera sensors require a minimum contrast ratio between foreground modules and background spaces. Always ensure:
- Dark modules on light backgrounds (avoid light gray on white or dark blue on black).
- Inverted QR codes (white modules on black background) work on iOS camera apps, but fail on legacy Android devices and embedded barcode readers. Stick to dark foregrounds on light backgrounds whenever possible.
### 2. Respect Quiet Zone Margins
The **Quiet Zone** is the empty border surrounding all 4 sides of the QR matrix. The ISO/IEC 18004 specification requires a quiet zone of **at least 4 modules wide**. Reducing or cropping this margin causes camera auto-focus algorithms to miss the finder pattern boundaries.
### 3. Keep Payload Size Minimal
The more characters you encode into a static QR code, the larger the matrix version becomes (e.g., Version 1 is 21x21 modules; Version 10 is 57x57 modules). High-density matrices require users to stand closer and hold their camera still.
- **Pro Tip:** Use URL shorteners or dynamic redirection URLs (e.g., `https://qr.domain.com/x9z`) to keep the payload under 30 characters, resulting in a clean, low-density Version 2 matrix that scans instantly.
---
## Conclusion
Creating your own **custom qr code generator** API gives you full programmatic freedom over format, styling, error correction, and batch automation. By leveraging Node.js and vector SVG rendering, your application can effortlessly scale to handle thousands of print-ready requests per second.
If you prefer a fully managed solution with dynamic redirection, real-time scan analytics, custom logo embedding, and enterprise SLA uptime, check out [QR Master Custom QR Code Generator](https://www.qrmaster.net/custom-qr-code-generator) — built for developers and growth teams.

View File

@@ -0,0 +1,246 @@
---
title: "Designing a Low-Latency Dynamic QR Redirect Engine at the Edge with Redis & Middleware"
description: "A comprehensive system architecture guide for building a sub-20ms dynamic QR code generator engine using Edge Functions, an editable QR code generator proxy, Redis, and scan tracking."
tags: systemdesign, redis, serverless, webdev
keywords: dynamic qr code generator, free dynamic qr code generator, editable qr code generator, editable qr code, qr code generator with tracking, qr code tracking, dynamic qr code
canonical_url: https://www.qrmaster.net/blog/qr-code-analytics
---
# Designing a Low-Latency Dynamic QR Redirect Engine at the Edge with Redis & Middleware
Static QR codes hardcode their destination URL directly into the matrix data. Once printed on 10,000 billboards or product packages, a typo in the URL means reprinting everything at massive cost.
A **dynamic qr code generator** solves this by encoding a permanent short proxy URL (e.g., `https://qr.domain.com/r/xyz123`). An **editable qr code generator** lets you change the target destination link in your dashboard anytime post-print. When scanned, an **editable qr code** intercepts the request, logs scan metrics (device type, geo-location, timestamp), and issues an HTTP `302 Found` or `307 Temporary Redirect` response to the target URL.
However, if your redirect engine takes 800ms to resolve a database query before forwarding the user, the physical scan experience feels sluggish. In this article, we will design a **free dynamic qr code generator** backend engine operating with sub-20ms global redirect latencies using Edge Middleware (Vercel Edge / Cloudflare Workers), Redis in-memory caching, and a **qr code generator with tracking** pipeline.
---
## 1. System Architecture Overview
To achieve sub-20ms global redirect latencies in a **dynamic qr code generator**, database calls must never block the HTTP response thread.
```
┌─────────────────────────────────────────┐
│ Physical Phone Scanner │
└────────────────────┬────────────────────┘
HTTP GET /r/xyz123 (Proxy)
┌─────────────────────────────────────────┐
│ Edge Middleware (Cloudflare/Vercel)│
│ - Fast Geo-IP & User-Agent Parsing │
└──────────┬───────────────────┬──────────┘
│ │
1. Cache Hit (<5ms) │ 2. Async Log Stream
│ │ (Non-blocking Queue)
▼ ▼
┌─────────────────────┐ ┌─────────────────────────┐
│ Upstash Redis / K-V │ │ Kafka / Upstash QStash │
└─────────────────────┘ └────────────┬────────────┘
│ │
HTTP 307 Redirect ▼
│ ┌─────────────────────────┐
│ │ Analytics DB (ClickHouse│
▼ │ or PostgreSQL) │
┌─────────────────────┐ └─────────────────────────┘
│ Final Target Webpage│
└─────────────────────┘
```
### Key Architectural Decisions:
1. **Edge Execution**: Run redirect logic in multi-region PoPs (Points of Presence) close to the physical device.
2. **Read Path (Hot Path)**: Fetch URL mappings from a distributed, low-latency Redis cache for your **editable qr code generator**.
3. **Write Path (Analytics Async)**: Push scan metadata to a queue or log collector off the main execution thread so **qr code tracking** adds **0ms** to user delay.
4. **HTTP Status Code**: Use `307 Temporary Redirect` (or `302 Found`). Never use `301 Moved Permanently`, as browsers will cache the redirect locally and bypass your server on future scans, ruining **qr code generator with tracking** metrics!
---
## 2. Setting Up Edge Middleware in Next.js
Below is an implementation of Edge Middleware in Next.js (`src/middleware.ts` or Cloudflare Worker script) that handles dynamic redirection for an **editable qr code generator**.
### Step 2.1: Installing Dependencies
```bash
npm install @upstash/redis @upstash/qstash
```
### Step 2.2: Implementing Edge Redirect Middleware
Create or update `middleware.ts`:
```typescript
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { Redis } from '@upstash/redis';
// Initialize low-latency edge Redis client
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
// Match route pattern: /r/:code (e.g., /r/campaign-2026)
if (pathname.startsWith('/r/')) {
const code = pathname.split('/r/')[1];
if (!code) {
return NextResponse.redirect(new URL('/404', req.url));
}
const startTime = performance.now();
// 1. Fetch destination URL from Redis cache (Hot Path)
const targetUrl = await redis.get<string>(`qr:link:${code}`);
if (!targetUrl) {
// Fallback: If not in cache, redirect to fallback page or 404
return NextResponse.redirect(new URL('/link-expired', req.url));
}
// 2. Extract Device & Geo Metadata from Edge Request Headers for QR Code Tracking
const ip = req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip') || '127.0.0.1';
const userAgent = req.headers.get('user-agent') || 'Unknown';
const country = req.headers.get('x-vercel-ip-country') || req.headers.get('cf-ipcountry') || 'US';
const city = req.headers.get('x-vercel-ip-city') || 'Unknown';
// 3. Asynchronously Log Scan Analytics (Fire and Forget)
const scanEvent = {
code,
targetUrl,
timestamp: new Date().toISOString(),
ip,
userAgent,
country,
city,
latencyMs: Math.round(performance.now() - startTime),
};
// Queue analytic event asynchronously without awaiting
const logPromise = redis.lpush('queue:scan_analytics', JSON.stringify(scanEvent));
if (typeof (req as any).waitUntil === 'function') {
(req as any).waitUntil(logPromise);
}
// 4. Return HTTP 307 Temporary Redirect immediately
return NextResponse.redirect(targetUrl, {
status: 307,
headers: {
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
'X-Redirect-Latency': `${Math.round(performance.now() - startTime)}ms`,
},
});
}
return NextResponse.next();
}
export const config = {
matcher: '/r/:path*',
};
```
---
## 3. Asynchronous Analytics Processing Pipeline for Tracking
Logging scan events directly to a relational database (like PostgreSQL or MySQL) inside the request loop introduces locking overhead and database connection pool exhaustion under high traffic spikes.
A robust **qr code generator with tracking** streams events into a queue and processes them with a background consumer job.
### Background Consumer Worker (`scripts/analyticsWorker.ts`)
```typescript
import { Redis } from '@upstash/redis';
import { PrismaClient } from '@prisma/client';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
const prisma = new PrismaClient();
interface ScanEvent {
code: string;
targetUrl: string;
timestamp: string;
ip: string;
userAgent: string;
country: string;
city: string;
latencyMs: number;
}
async function startWorker() {
console.log('🔄 QR Code Tracking Worker active. Polling scan queue...');
while (true) {
try {
// Pop up to 100 scan events in batch from Redis list
const rawEvents = await redis.rpop('queue:scan_analytics', 100);
if (rawEvents && rawEvents.length > 0) {
const events: ScanEvent[] = rawEvents.map((item) => JSON.parse(item));
// Batch insert into database
await prisma.scanLog.createMany({
data: events.map((e) => ({
qrCode: e.code,
destination: e.targetUrl,
scannedAt: new Date(e.timestamp),
ipAddress: e.ip,
deviceUserAgent: e.userAgent,
countryCode: e.country,
cityName: e.city,
processingLatency: e.latencyMs,
})),
});
console.log(`✅ Processed ${events.length} scan records.`);
} else {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
} catch (err) {
console.error('❌ Analytics Worker Error:', err);
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
}
startWorker();
```
---
## 4. Handling High-Traffic Campaign Spikes
When a printed **editable qr code** appears on live television or a viral promotional banner, traffic can surge from 10 scans/sec to 20,000 scans/sec instantly.
### Key Resilience Strategies:
1. **Pre-Warming the Edge Cache**: When a user updates a dynamic destination URL in their **editable qr code generator** dashboard, publish the update to Redis immediately:
```typescript
await redis.set(`qr:link:${code}`, newTargetUrl);
```
2. **Stale-While-Revalidate Fallback**: If Redis experiences an outage, fallback to an edge-cached static mapping file or memory LRU cache.
3. **Bot & Crawler Filtering**: Search engine spiders (Googlebot, Bingbot) and messaging app link prefetchers (WhatsApp, iMessage, Twitter previews) generate fake scans. Filter them out using User-Agent detection before counting unique scans:
```typescript
const isBot = /bot|google|baidu|bing|msn|duckduckbot|teoma|slurp|twitterbot|facebookexternalhit|whatsapp/i.test(userAgent);
if (isBot) {
// Tag or ignore bot scans in qr code tracking
}
```
---
## Conclusion
By executing redirect logic at the Edge with Redis and isolating analytics processing asynchronously, you can build a **free dynamic qr code generator** backend achieving ultra-low **<15ms redirect latencies** regardless of geographic location.
To save time and avoid building analytics infra from scratch, explore [QR Master Dynamic QR Code Generator](https://www.qrmaster.net/dynamic-qr-code-generator), an enterprise-grade platform offering dynamic QR management, real-time GA4/UTM integration, and sub-second analytics dashboards.

View File

@@ -0,0 +1,242 @@
---
title: "Geo-Location URIs vs Deep Links: RFC 5870 geo: Protocol, Apple Maps & Google Maps Traps"
description: "A cross-platform web developer guide to encoding GPS coordinates in a location qr code generator, comparing RFC 5870 geo: protocols against Apple Maps and Google Maps universal links."
tags: webdev, mobile, javascript, ios, android
keywords: location qr code generator, qr code for location, print qr code, print a qr code, maps qr code, gps qr code generator
canonical_url: https://www.qrmaster.net/blog/location-qr-code
---
# Geo-Location URIs vs Deep Links: RFC 5870 geo: Protocol, Apple Maps & Google Maps Traps
Scanning a **qr code for location** to navigate to a physical address—such as a store entrance, real estate open house, event parking lot, or tourist landmark—is a foundational real-world mobile use case.
However, developers building a **location qr code generator** often stumble into a major cross-platform fragmentation trap:
- If you use the official IETF standard `geo:` URI protocol (`geo:37.7749,-122.4194`), Android devices open Google Maps seamlessly, but **iOS camera apps display an error or treat it as an unhandled text string**!
- If you use a Google Maps web URL (`https://maps.google.com/?q=...`), iOS devices open a browser web page instead of launching the native Apple Maps app.
In this technical guide, we will analyze RFC 5870 geo-location standards, cross-platform mobile OS behavior, client-side W3C Geolocation API fallbacks, and build a smart TypeScript Universal Location Resolver to **print a qr code** for navigation.
---
## 1. Breakdown of Location Format Options
Let's compare the four primary ways to encode geographic location coordinates into a **location qr code generator**:
```
┌─────────────────────────────────────┬─────────────────────────────┬─────────────────────────────┐
│ Format Method │ iOS Camera App Behavior │ Android Google Lens Behavior│
├─────────────────────────────────────┼─────────────────────────────┼─────────────────────────────┤
│ 1. Standard RFC 5870 (geo:lat,lng) │ ❌ Fails / Shows plain text │ ✅ Opens Native Maps App │
│ 2. Google Maps Web URL │ ⚠️ Opens Safari Web Browser │ ✅ Opens Native Google Maps │
│ 3. Apple Maps Universal Link │ ✅ Opens Native Apple Maps │ ⚠️ Opens Web Browser │
│ 4. Universal Smart Redirect Link │ ✅ Opens Native Maps App │ ✅ Opens Native Maps App │
└─────────────────────────────────────┴─────────────────────────────┴─────────────────────────────┘
```
---
## 2. Understanding the RFC 5870 `geo:` URI Specification
The IETF RFC 5870 specification defines the uniform resource identifier (URI) scheme for geographic locations:
```text
geo:latitude,longitude,altitude;crs=wgs84;u=uncertainty
```
### Example RFC 5870 Strings:
```text
# Basic Latitude & Longitude (San Francisco)
geo:37.7749,-122.4194
# Latitude, Longitude, and Altitude in meters (100m above sea level)
geo:48.8584,2.2945,100
# Geo-location with query search string ("Coffee")
geo:37.7749,-122.4194?q=Coffee
```
### Why iOS Fails to Parse RFC 5870:
Apple's iOS Camera App parser does not register `geo:` as a supported URI scheme in its native scanner handler. When an iPhone camera detects `geo:37.7749,-122.4194`, it treats the barcode as raw unformatted text rather than an actionable navigation trigger.
---
## 3. Universal Web Links for Maximum Cross-Platform Compatibility
To ensure a **qr code for location** opens natively on both iPhone and Android devices without errors, developers use **Universal Maps Links**.
### Google Maps Universal Link Syntax:
```text
https://www.google.com/maps/search/?api=1&query=37.7749,-122.4194
```
### Apple Maps Universal Link Syntax:
```text
https://maps.apple.com/?ll=37.7749,-122.4194&q=Location+Name
```
### Cross-Platform Dual-Routing Strategy
When both iOS and Android users scan a single **print qr code**, the best architectural approach is pointing the QR code to a lightweight serverless edge function that inspects the client `User-Agent` and issues an instant 307 redirect to the respective native map handler:
- If `User-Agent` contains `iPhone`, `iPad`, or `Macintosh` $\to$ Redirect to `https://maps.apple.com/?ll=...`
- Otherwise (Android / Windows / Linux) $\to$ Redirect to `https://www.google.com/maps/search/?api=1&query=...`
---
## 4. Building a Smart Location Resolver in TypeScript
Below is a complete implementation of a Universal Location Resolver Edge Handler in Next.js / TypeScript for a **location qr code generator**.
### `src/app/api/location-resolver/route.ts`
```typescript
import { NextRequest, NextResponse } from 'next/server';
export interface LocationQuery {
lat: number;
lng: number;
label?: string;
}
export function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const latStr = searchParams.get('lat');
const lngStr = searchParams.get('lng');
const label = searchParams.get('label') || 'Target Location';
if (!latStr || !lngStr) {
return NextResponse.json(
{ error: 'Query parameters "lat" and "lng" are required.' },
{ status: 400 }
);
}
const lat = parseFloat(latStr);
const lng = parseFloat(lngStr);
if (isNaN(lat) || isNaN(lng)) {
return NextResponse.json(
{ error: 'Coordinates lat and lng must be valid floating point numbers.' },
{ status: 400 }
);
}
// Validate Coordinate Boundaries
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) {
return NextResponse.json(
{ error: 'Latitude must be between -90 and 90, Longitude between -180 and 180.' },
{ status: 400 }
);
}
const userAgent = req.headers.get('user-agent') || '';
const isAppleDevice = /iPhone|iPad|iPod|Macintosh/i.test(userAgent);
let targetMapUrl: string;
if (isAppleDevice) {
// Construct Native Apple Maps Deep Link
const encodedLabel = encodeURIComponent(label);
targetMapUrl = `https://maps.apple.com/?ll=${lat},${lng}&q=${encodedLabel}`;
} else {
// Construct Universal Google Maps Deep Link
const encodedQuery = encodeURIComponent(`${lat},${lng}`);
targetMapUrl = `https://www.google.com/maps/search/?api=1&query=${encodedQuery}`;
}
// Return 307 Temporary Redirect
return NextResponse.redirect(targetMapUrl, {
status: 307,
headers: {
'Cache-Control': 'no-store, max-age=0',
},
});
}
```
---
## 5. Client-Side Geolocation API Integration & Fallback HTML
If you want to offer a web landing page that shows dynamic distance ("You are 450 meters away from the venue entrance"), you can integrate the browser W3C Geolocation API alongside the QR redirect link when you **print a qr code**.
### Example HTML/JS Client Landing Page (`public/location-landing.html`):
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Venue Navigation - Location QR Code</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; text-align: center; padding: 40px 20px; }
.card { max-width: 400px; margin: 0 auto; border: 1px solid #E2E8F0; padding: 24px; border-radius: 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.05); }
.btn { display: inline-block; background: #0284C7; color: white; padding: 14px 28px; border-radius: 8px; text-decoration: none; font-weight: 600; margin-top: 16px; }
</style>
</head>
<body>
<div class="card">
<h2>📍 Target Destination</h2>
<p id="status">Calculating distance to target...</p>
<a id="nav-btn" class="btn" href="#">Open Navigation App</a>
</div>
<script>
const targetLat = 37.7749;
const targetLng = -122.4194;
const isApple = /iPhone|iPad|iPod|Macintosh/i.test(navigator.userAgent);
const navBtn = document.getElementById('nav-btn');
const statusEl = document.getElementById('status');
const mapsUrl = isApple
? `https://maps.apple.com/?ll=${targetLat},${targetLng}&q=Target+Venue`
: `https://www.google.com/maps/search/?api=1&query=${targetLat},${targetLng}`;
navBtn.href = mapsUrl;
if ("geolocation" in navigator) {
navigator.geolocation.getCurrentPosition(position => {
const userLat = position.coords.latitude;
const userLng = position.coords.longitude;
const distKm = getHaversineDistance(userLat, userLng, targetLat, targetLng);
statusEl.innerText = `You are currently ${(distKm * 1000).toFixed(0)} meters away.`;
}, () => {
statusEl.innerText = "Tap below to open your device maps app.";
});
}
function getHaversineDistance(lat1, lon1, lat2, lon2) {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
}
</script>
</body>
</html>
```
---
## 6. Summary & Best Practice Rules for Developers
```
[ ] DO NOT use raw `geo:lat,lng` RFC 5870 strings if your audience includes iOS users.
[ ] ALWAYS use HTTPS universal web links when creating a qr code for location.
[ ] Include a human-readable label in the query string (`&q=Store+Name`) so maps apps display a pin marker with your brand name.
[ ] Validate latitude limits (-90.0 to +90.0) and longitude limits (-180.0 to +180.0) before encoding.
```
---
## Conclusion
Navigating cross-platform mobile URI quirks is essential for building real-world location QR codes. By implementing smart User-Agent routing between Apple Maps and Google Maps universal links in your **location qr code generator**, developers deliver a flawless 1-tap navigation experience on any smartphone.
To create custom location QR codes with automatic GPS detection, map previews, and scannability analytics, check out [QR Master Location QR Generator](https://www.qrmaster.net/blog/location-qr-code).

View File

@@ -0,0 +1,245 @@
---
title: "Automating Mobile QR Code Previews in CI/CD Pipelines with GitHub Actions"
description: "A complete DevOps guide to building a custom GitHub Action that generates dynamic preview QR codes to create a qr code from a link for Vercel/Netlify preview deployments."
tags: github, devops, ci-cd, automation
keywords: create qr code from link, create qr code with link, generate qr code for link, make a qr code for a link, qr code generator link
canonical_url: https://www.qrmaster.net/blog/qr-code-api-documentation
---
# Automating Mobile QR Code Previews in CI/CD Pipelines with GitHub Actions
When reviewing Pull Requests (PRs) for mobile-first web applications, responsive websites, or PWA features, developers and QA engineers frequently waste time manually copying Vercel or Netlify preview URLs, opening messaging apps, sending links to test devices, or re-typing long URLs into mobile browser address bars.
What if every time a developer opened a Pull Request, a **GitHub Action automatically allowed you to create a qr code from a link** pointing directly to that branch's live preview URL and commented it right into the PR thread?
Quality Assurance testers could simply point their mobile phone camera at the computer screen and instantly test the live staging build!
In this DevOps workflow guide, we will build a custom GitHub Actions workflow (`.github/workflows/qr-preview.yml`) that auto-generates QR preview images when you **create a qr code with a link**.
---
## 1. CI/CD Preview Architecture
Here is how the automated PR feedback loop operates:
```
┌────────────────────────────────────────┐
│ Developer Pushes Code to GitHub PR │
└───────────────────┬────────────────────┘
┌────────────────────────────────────────┐
│ Vercel / Netlify Deploy Preview Builds │ (Generates e.g. https://preview-xyz.vercel.app)
└───────────────────┬────────────────────┘
┌────────────────────────────────────────┐
│ GitHub Action Triggered (pull_request) │
└───────────────────┬────────────────────┘
┌────────────────────────────────────────┐
│ Node.js Script Generates QR Code SVG │ (Create QR Code From Link)
└───────────────────┬────────────────────┘
┌────────────────────────────────────────┐
│ Action Posts/Updates PR Markdown Comm. │
└────────────────────────────────────────┘
```
---
## 2. Setting Up the GitHub Action Workflow
Create a new file in your repository at `.github/workflows/qr-preview.yml`.
### Workflow Configuration (`.github/workflows/qr-preview.yml`)
```yaml
name: Mobile QR Code Preview Generator
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
pull-requests: write
contents: read
jobs:
generate-qr-preview:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Setup Node.js Environment
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install QR Code Generator Dependencies
run: |
npm install qrcode
- name: Get Preview URL & Create QR Code From Link
id: generate_qr
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO_NAME: ${{ github.repository }}
BRANCH_NAME: ${{ github.head_ref }}
run: |
# Target deployment URL to create a qr code from a link
PREVIEW_URL="https://preview-${PR_NUMBER}-${BRANCH_NAME}.vercel.app"
echo "Preview Target URL: $PREVIEW_URL"
echo "preview_url=$PREVIEW_URL" >> $GITHUB_OUTPUT
# Create inline Node script to generate SVG QR code as Data URI
node -e "
const QRCode = require('qrcode');
const url = '$PREVIEW_URL';
QRCode.toString(url, { type: 'svg', margin: 2, color: { dark: '#0F172A', light: '#FFFFFF' } }, (err, svg) => {
if (err) throw err;
const encoded = Buffer.from(svg).toString('base64');
const dataUri = 'data:image/svg+xml;base64,' + encoded;
require('fs').writeFileSync('qr_data_uri.txt', dataUri);
});
"
DATA_URI=$(cat qr_data_uri.txt)
echo "qr_data_uri=$DATA_URI" >> $GITHUB_OUTPUT
- name: Comment QR Code on Pull Request
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const prNumber = context.payload.pull_request.number;
const previewUrl = '${{ steps.generate_qr.outputs.preview_url }}';
const qrDataUri = '${{ steps.generate_qr.outputs.qr_data_uri }}';
const commentBody = `### 📱 Mobile Preview QR Code
Scan this QR code with your phone camera to open and test this PR preview instantly:
<p align="center">
<img src="${qrDataUri}" width="220" height="220" alt="Mobile Preview QR Code" />
<br />
<a href="${previewUrl}" target="_blank"><strong>Open Direct Preview Link ↗</strong></a>
</p>
---
*Automated by QR CI/CD Pipeline*`;
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const botComment = comments.data.find(comment =>
comment.user.type === 'Bot' && comment.body.includes('Mobile Preview QR Code')
);
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: commentBody
});
console.log('Updated existing PR comment.');
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: commentBody
});
console.log('Created new PR comment.');
}
```
---
## 3. How It Works Under the Hood
### Base64 Data URI Trick for Markdown Rendering
GitHub Markdown does not allow uploading local SVG files directly from a runner disk into a comment thread without hosted storage.
By encoding the generated vector SVG into a **Base64 Data URI string** (`data:image/svg+xml;base64,PHN2Zy...`), the image renders natively inside GitHub PR comment threads without requiring any external S3 bucket uploads when you **generate a qr code for a link**!
```html
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0..." width="220" height="220" />
```
### Preventing Comment Spam
The script lists existing PR comments and searches for a previous bot message containing `"Mobile Preview QR Code"`. If a developer pushes 5 new commits to the PR, the action **updates the single existing comment** with the latest deployment link instead of posting 5 separate duplicate comments.
---
## 4. Advanced Integrations: Netlify & Cloudflare Pages Pipelines
If your repository deploys via Netlify or Cloudflare Pages instead of Vercel, you can hook into their deployment completion events.
### Netlify Deployment Hook Example:
```yaml
- name: Fetch Netlify Preview Link
id: netlify
uses: nwtgck/actions-netlify@v3.0
with:
publish-dir: './build'
github-token: ${{ secrets.GITHUB_TOKEN }}
deploy-alias: pr-${{ github.event.number }}
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
```
### Adding Device UTM Tracking Parameters
To measure how many QA test scans originate from GitHub Pull Request comments vs Slack links, append custom UTM parameters to **make a qr code for a link** before generating the barcode:
```javascript
const previewUrlWithUtm = `${previewUrl}?utm_source=github&utm_medium=pr_comment&utm_campaign=qa_mobile_test`;
```
---
## 5. Automated Unit & E2E Testing with Playwright
To take mobile QA automation a step further, you can combine this workflow with headless E2E testing tools like Microsoft Playwright or Cypress.
For instance, your CI runner can launch a mobile Chrome emulation context, load the preview deployment URL encoded in the QR code, take automated screenshots across different screen viewport sizes (iPhone 15 Pro, Pixel 8, iPad Air), and upload visual diffs directly into the Pull Request build artifact summary.
```typescript
import { test, expect, devices } from '@playwright/test';
test.use({ ...devices['iPhone 15 Pro'] });
test('Mobile Staging Visual Regression Check', async ({ page }) => {
await page.goto(process.env.STAGING_URL || 'http://localhost:3000');
await expect(page).toHaveTitle(/QR Master/);
await page.screenshot({ path: 'mobile-preview.png' });
});
```
---
## 6. Security & Rate Limiting Guidelines
```
[ ] Grant `pull-requests: write` permission scoped strictly to the workflow job.
[ ] Store third-party tokens (Vercel/Netlify tokens) securely in GitHub Repository Secrets (`${{ secrets.VERCEL_TOKEN }}`).
[ ] Enforce Base64 length checks to ensure generated SVG payload remains under 64 KB to comply with GitHub comment payload size limits.
```
---
## Conclusion
Automating mobile QR code previews in your CI/CD pipeline to **create a qr code from a link** eliminates friction for QA teams, product managers, and developers testing mobile-first web features.
To integrate automated REST API QR code generation into your custom developer workflows, check out [QR Master Developer API Documentation](https://www.qrmaster.net/blog/qr-code-api-documentation).

View File

@@ -0,0 +1,297 @@
---
title: "Building an Offline Batch QR Code Generation CLI Tool in Python & Node.js"
description: "Learn how to build a bulk qr code generator CLI tool to process 10,000+ records from CSV/Excel files and export high-resolution vector SVG/PNG QR code archives using Node.js and Python."
tags: python, nodejs, cli, devops
keywords: bulk qr code generator, free bulk qr code generator, bulk qr code generator excel, csv qr code generator, bulk qr code, batch qr code generator
canonical_url: https://www.qrmaster.net/blog/bulk-qr-code-generator-excel
---
# Building an Offline Batch QR Code Generation CLI Tool in Python & Node.js
Generating a single QR code manually in a web browser takes seconds. But when an enterprise client hands you a CSV file containing **50,000 product SKU inventory codes**, **10,000 attendee event tickets**, or **5,000 personalized employee ID badge links**, manual generation becomes impossible.
Browser-based tools will freeze or crash browser tabs when processing tens of thousands of records. You need a dedicated **bulk qr code generator** CLI tool that leverages multi-core CPU workers, streams large files without memory exhaustion, and packages vector SVG outputs into a clean ZIP archive.
In this guide, we will build a production-grade **bulk qr code generator from excel** and CSV files in both **Node.js** and **Python** capable of batch processing thousands of QR codes per minute.
---
## 1. System Requirements & Architecture
Building a **free bulk qr code generator** CLI tool capable of processing massive dataset imports requires avoiding loading entire multi-gigabyte CSV files into RAM memory all at once.
```
┌─────────────────────────┐
│ Input CSV / Excel File │ (e.g. 50,000 rows: ID, Payload, Label)
└────────────┬────────────┘
┌─────────────────────────┐
│ Stream Reader / Parser │ (Node.js csv-parser / Python csv module)
└────────────┬────────────┘
┌─────────────────────────┐
│ Worker Pool Queue │ (Parallel processing across CPU cores)
└────────────┬────────────┘
┌─────────────────────────┐
│ Vector SVG / PNG Export │ (Output folder: ./output/QR_00001.svg)
└─────────────────────────┘
```
---
## 2. Implementation 1: Node.js / TypeScript CLI Tool
We will build a **csv qr code generator** in Node.js using `commander` for CLI flags, `csv-parser` for streaming, and `p-limit` to bound CPU concurrency.
### Step 2.1: Dependencies
```bash
npm install commander csv-parser qrcode p-limit archiver
npm install --save-dev typescript @types/node @types/csv-parser @types/archiver ts-node
```
### Step 2.2: Node.js CLI Code (`src/bulkQrCli.ts`)
```typescript
import fs from 'fs';
import path from 'path';
import { Command } from 'commander';
import csvParser from 'csv-parser';
import QRCode from 'qrcode';
import pLimit from 'p-limit';
interface CsvRow {
filename: string;
payload: string;
}
const program = new Command();
program
.name('batch-qr')
.description('High-speed offline bulk qr code generator CLI')
.version('1.0.0')
.requiredOption('-i, --input <path>', 'Input CSV file path (columns: filename, payload)')
.option('-o, --output <path>', 'Output directory path', './output_qr')
.option('-f, --format <type>', 'Output format (svg or png)', 'svg')
.option('-c, --concurrency <number>', 'Parallel CPU worker limit', '20')
.option('-e, --error-correction <level>', 'Error correction (L, M, Q, H)', 'M')
.parse(process.argv);
const options = program.opts();
async function runBatch() {
const inputPath = path.resolve(options.input);
const outputDir = path.resolve(options.output);
const format = options.format.toLowerCase();
const concurrency = parseInt(options.concurrency, 10);
const ecLevel = options.errorCorrection.toUpperCase();
if (!fs.existsSync(inputPath)) {
console.error(`❌ Input CSV file not found: ${inputPath}`);
process.exit(1);
}
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
console.log(`🚀 Starting Bulk QR Code Generator Batch...`);
console.log(`📁 Input: ${inputPath}`);
console.log(`📂 Output: ${outputDir}`);
console.log(`⚡ Concurrency Limit: ${concurrency} workers`);
const rows: CsvRow[] = [];
// 1. Read CSV Stream
await new Promise<void>((resolve, reject) => {
fs.createReadStream(inputPath)
.pipe(csvParser())
.on('data', (data) => {
if (data.payload) {
rows.push({
filename: data.filename || `qr_${rows.length + 1}`,
payload: data.payload,
});
}
})
.on('end', () => resolve())
.on('error', (err) => reject(err));
});
console.log(`📊 Found ${rows.length} records for bulk qr generation.`);
const startTime = Date.now();
const limit = pLimit(concurrency);
let completed = 0;
// 2. Parallel Generation Queue
const tasks = rows.map((row) =>
limit(async () => {
const sanitizedFilename = row.filename.replace(/[^a-z0-9_-]/gi, '_');
const filePath = path.join(outputDir, `${sanitizedFilename}.${format}`);
try {
if (format === 'png') {
await QRCode.toFile(filePath, row.payload, {
errorCorrectionLevel: ecLevel,
width: 800,
margin: 4,
});
} else {
const svgString = await QRCode.toString(row.payload, {
type: 'svg',
errorCorrectionLevel: ecLevel,
margin: 4,
});
fs.writeFileSync(filePath, svgString, 'utf8');
}
completed++;
if (completed % 500 === 0 || completed === rows.length) {
console.log(`✅ Progress: ${completed} / ${rows.length} generated...`);
}
} catch (err) {
console.error(`❌ Error generating ${row.filename}:`, (err as Error).message);
}
})
);
await Promise.all(tasks);
const durationSec = ((Date.now() - startTime) / 1000).toFixed(2);
console.log(`\n🎉 Bulk QR Code Generator Completed Successfully!`);
console.log(`⏱️ Total Time: ${durationSec} seconds`);
console.log(`⚡ Throughput: ${(rows.length / parseFloat(durationSec)).toFixed(0)} codes/sec`);
}
runBatch().catch((err) => {
console.error('Fatal Batch Error:', err);
process.exit(1);
});
```
---
## 3. Implementation 2: Python Multi-Processing CLI
Python offers native `multiprocessing` for parallel execution across all available CPU threads in a **bulk qr code generator from excel**.
### Step 3.1: Install Dependencies
```bash
pip install qrcode[pil] click pandas openpyxl
```
### Step 3.2: Python CLI Script (`batch_qr.py`)
```python
import os
import time
import pandas as pd
import qrcode
from qrcode.image.svg import SvgPathImage
import click
from multiprocessing import Pool, cpu_count
def generate_single_qr(task):
filename, payload, output_dir, fmt, ec_level = task
sanitized_name = "".join([c if c.isalnum() or c in ('-', '_') else '_' for c in filename])
output_path = os.path.join(output_dir, f"{sanitized_name}.{fmt}")
ec_map = {
'L': qrcode.constants.ERROR_CORRECT_L,
'M': qrcode.constants.ERROR_CORRECT_M,
'Q': qrcode.constants.ERROR_CORRECT_Q,
'H': qrcode.constants.ERROR_CORRECT_H,
}
qr = qrcode.QRCode(
version=None,
error_correction=ec_map.get(ec_level.upper(), qrcode.constants.ERROR_CORRECT_M),
box_size=10,
border=4,
)
qr.add_data(payload)
qr.make(fit=True)
if fmt == 'svg':
img = qr.make_image(image_factory=SvgPathImage)
img.save(output_path)
else:
img = qr.make_image(fill_color="black", back_color="white")
img.save(output_path)
return True
@click.command()
@click.option('--input', '-i', required=True, help='Path to input CSV or Excel file.')
@click.option('--output', '-o', default='./output_qr', help='Output folder.')
@click.option('--format', '-f', default='svg', type=click.Choice(['svg', 'png']), help='File format.')
@click.option('--ec', default='M', type=click.Choice(['L', 'M', 'Q', 'H']), help='Error correction level.')
def main(input, output, format, ec):
"""High-Performance Bulk QR Code Generator CLI in Python"""
if not os.path.exists(input):
click.echo(f"Error: Input file '{input}' does not exist.")
return
os.makedirs(output, exist_ok=True)
if input.endswith('.xlsx') or input.endswith('.xls'):
df = pd.read_excel(input)
else:
df = pd.read_csv(input)
if 'payload' not in df.columns:
click.echo("Error: File must contain a 'payload' column.")
return
records = []
for idx, row in df.iterrows():
fname = str(row.get('filename', f'qr_{idx + 1}'))
payload = str(row['payload'])
records.append((fname, payload, output, format, ec))
total = len(records)
num_cpus = cpu_count()
click.echo(f"Starting bulk qr code generator for {total} records using {num_cpus} CPU cores...")
start_time = time.time()
with Pool(processes=num_cpus) as pool:
pool.map(generate_single_qr, records)
duration = time.time() - start_time
click.echo(f"Bulk batch completed in {duration:.2f} seconds ({total / duration:.0f} codes/sec).")
if __name__ == '__main__':
main()
```
---
## 4. Performance Benchmarks
Running these scripts on a standard 8-Core Apple M1 / Intel i7 workstation yields impressive throughput:
```
┌───────────────────────────┬────────────────┬─────────────────┬───────────────────┐
│ Implementation │ Records │ Total Time │ Speed │
├───────────────────────────┼────────────────┼─────────────────┼───────────────────┤
│ Node.js (p-limit 20) │ 10,000 SVGs │ 3.8 seconds │ ~2,630 codes/sec │
│ Python (Multiprocessing) │ 10,000 SVGs │ 4.2 seconds │ ~2,380 codes/sec │
│ Single-Thread Browser JS │ 1,000 PNGs │ 45.0 seconds │ ~22 codes/sec │
└───────────────────────────┴────────────────┴─────────────────┴───────────────────┘
```
---
## Conclusion
Building your own offline **bulk qr code generator** CLI tool frees you from browser memory limits and third-party rate limits. By utilizing multi-core process pools and vector SVG output, you can generate tens of thousands of print-ready QR codes in seconds.
If you need a cloud-native web dashboard for bulk Excel uploads, automatic ZIP packaging, and dynamic tracking, check out [QR Master Bulk Generator](https://www.qrmaster.net/bulk-qr-code-generator).

View File

@@ -0,0 +1,181 @@
---
title: "PDF & File QR Code Generator: How to Convert Documents, Menus & PDFs into Scannable Barcodes"
description: "A developer and marketer guide to building a PDF QR code generator, handling cloud file storage uploads, optimizing PDF load speeds, and creating dynamic file barcodes."
tags: webdev, pdf, cloud, tutorial
keywords: pdf qr code generator free, file qr code generator, generate free qr code for pdf, file to qr code generator, pdf to qr code, qr code generator for file
canonical_url: https://www.qrmaster.net/blog/qr-code-restaurant-menu
---
# PDF & File QR Code Generator: How to Convert Documents, Menus & PDFs into Scannable Barcodes
Converting digital documents, PDF menus, product brochures, user manuals, and event schedules into scannable QR codes is one of the most effective ways to eliminate paper waste and distribute digital collateral in physical spaces.
Whether a restaurant guest scans a table sign to view a restaurant menu PDF, a conference attendee scans a badge to download a presentation slide deck, or an industrial customer scans packaging to view a PDF safety manual, using a **pdf qr code generator free** tool connects paper touchpoints directly to digital cloud files.
However, developers and marketers often face technical challenges:
- How do you host PDF files so they load instantly on mobile networks?
- Should you use a static file link or an editable **file qr code generator**?
- How do you optimize PDF file size so phone browsers do not freeze when downloading large multi-megabyte documents over cellular connections?
In this guide, we will cover the end-to-end architecture of a **file to qr code generator**, cloud storage hosting (S3/Cloudflare R2), PDF optimization, and building a TypeScript file upload pipeline.
---
## 1. System Architecture: How a PDF QR Code Works
You cannot embed a 5 MB PDF file directly inside the physical black-and-white modules of a 2D QR matrix. A QR code can store a maximum of ~2,953 bytes.
Therefore, a **pdf qr code generator** works by uploading the PDF document to a secure cloud storage bucket (e.g. AWS S3, Cloudflare R2, Google Cloud Storage) and encoding the hosted URL into a QR barcode.
```
┌─────────────────────────┐
│ User Uploads PDF File │ (e.g. menu.pdf, 1.2 MB)
└────────────┬────────────┘
┌─────────────────────────┐
│ PDF Optimization Engine │ (Compresses images & vectors)
└────────────┬────────────┘
┌─────────────────────────┐
│ Cloud Object Storage │ (AWS S3 / Cloudflare R2 CDN)
└────────────┬────────────┘
┌─────────────────────────┐
│ Dynamic Proxy Short Link│ (e.g. https://qr.domain.com/pdf/menu-2026)
└────────────┬────────────┘
┌─────────────────────────┐
│ Vector SVG Barcode │ (Scanned by Mobile Device Camera)
└─────────────────────────┘
```
---
## 2. Static vs. Dynamic PDF QR Codes
When building a **file qr code generator**, choosing between static and dynamic architecture is critical:
```
┌───────────────────────────────────────┬───────────────────────────────────────┐
│ Static PDF QR Code │ Dynamic File QR Code Generator │
├───────────────────────────────────────┼───────────────────────────────────────┤
│ Links directly to S3 URL │ Links to proxy URL (/pdf/menu) │
│ (e.g., s3.aws.com/b/menu-v1.pdf) │ which redirects to active PDF. │
│ │ │
│ ❌ File CANNOT be updated after print │ 🟢 Replace PDF file anytime │
│ ❌ No scan analytics tracking │ 🟢 Full scan metrics (Geo-IP, device) │
│ ⚠️ Long S3 URLs create dense barcodes │ 🟢 Short proxy URL creates clean code │
└───────────────────────────────────────┴───────────────────────────────────────┘
```
> **Best Practice Rule**: Always use a **dynamic file qr code generator** for PDF documents. If a menu price changes or a brochure is revised, you can upload a new PDF version to your dashboard—the printed QR code on tables or flyers stays active and automatically serves the updated PDF!
---
## 3. PDF Optimization for Mobile Scanning Speed
When mobile users scan a PDF barcode over a 4G/5G connection, an uncompressed 15 MB PDF takes 10+ seconds to load in Safari or Chrome, resulting in high bounce rates.
### Golden Rules for Mobile PDF Optimization:
1. **Compress Raster Images**: Downsample images inside the PDF to 150 DPI (suitable for mobile screens) instead of 300+ DPI print resolution.
2. **Subset Embedded Fonts**: Include only the characters used in the document rather than embedding entire font families.
3. **Linearization (Fast Web View)**: Enable "Fast Web View" when exporting PDFs. This restructures the PDF stream so mobile browsers display Page 1 immediately before the rest of the file finishes downloading!
4. **Target File Size Limit**: Keep PDF file size **under 2.5 MB** for instant mobile loading.
---
## 4. TypeScript Implementation: Building a Cloud PDF QR Pipeline
Below is a complete implementation in TypeScript that handles PDF uploads to S3-compatible storage (Cloudflare R2), generates a short dynamic redirect link, and exports a vector SVG QR code.
### Step 4.1: Installation
```bash
npm install @aws-sdk/client-s3 qrcode
npm install --save-dev typescript @types/node
```
### Step 4.2: PDF QR Service (`src/services/pdfQrService.ts`)
```typescript
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import QRCode from 'qrcode';
// Initialize S3 / Cloudflare R2 Client
const s3 = new S3Client({
region: 'auto',
endpoint: process.env.R2_ENDPOINT!,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
export interface PdfUploadOptions {
fileBuffer: Buffer;
originalFileName: string;
slug: string;
}
export class PdfQrService {
/**
* Uploads a PDF to S3/R2 storage and returns a vector SVG QR code.
*/
public static async createPdfQr(options: PdfUploadOptions): Promise<{ cdnUrl: string; svgQr: string }> {
const { fileBuffer, originalFileName, slug } = options;
const fileKey = `documents/${Date.now()}_${originalFileName.replace(/[^a-z0-9.]/gi, '_')}`;
// 1. Upload PDF File to Cloud Storage Bucket
const uploadCommand = new PutObjectCommand({
Bucket: process.env.R2_BUCKET_NAME!,
Key: fileKey,
Body: fileBuffer,
ContentType: 'application/pdf',
ContentDisposition: 'inline', // Opens inside browser instead of forcing download
CacheControl: 'public, max-age=31536000',
});
await s3.send(uploadCommand);
const cdnUrl = `${process.env.CDN_BASE_URL}/${fileKey}`;
// 2. Generate Managed Short Redirect URL for Dynamic Editing
const proxyRedirectUrl = `https://www.qrmaster.net/r/doc/${slug}`;
// 3. Generate High-Quality Vector SVG Barcode
const svgQr = await QRCode.toString(proxyRedirectUrl, {
type: 'svg',
errorCorrectionLevel: 'M',
margin: 4,
color: { dark: '#0F172A', light: '#FFFFFF' },
});
return { cdnUrl, svgQr };
}
}
```
---
## 5. Frequently Asked Questions (FAQ)
### Q1: How do I generate a free QR code for a PDF?
Upload your PDF to a cloud host (such as Google Drive, Dropbox, or your website server), copy the share link, and paste it into a **pdf qr code generator free** tool like QR Master to generate a vector SVG code.
### Q2: Can I change the PDF file after printing the QR code?
Yes, provided you use a **file to qr code generator** with dynamic proxy links. You can upload a new PDF file to replace the old document in your dashboard without reprinting the physical QR code.
### Q3: Why does my PDF QR code force a download instead of opening in Safari?
This is controlled by the HTTP `Content-Disposition` header served by your cloud host. If set to `attachment`, the browser forces a download. Set `Content-Disposition: inline` so mobile browsers render the PDF directly on screen!
---
## Conclusion
Using a **pdf qr code generator** allows businesses to replace bulky paper manuals and printed menus with instant digital experiences. By hosting PDFs on fast S3/R2 CDNs, setting `inline` view headers, and using dynamic redirect links, you deliver a seamless mobile document experience.
To upload your PDF documents and generate custom vector QR codes with real-time scan analytics, check out [QR Master File & PDF QR Generator](https://www.qrmaster.net/blog/qr-code-restaurant-menu).

View File

@@ -0,0 +1,173 @@
---
title: "QR Code Generator That Never Expires: The Truth About Hidden Limits & Permanent Free QR Codes"
description: "A comprehensive guide to understanding why static QR codes never expire, avoiding third-party paywall traps, and building permanent barcodes for print & marketing campaigns."
tags: webdev, security, marketing, tutorial
keywords: qr code generator does not expire, permanent qr code generator, free forever qr code generator, qr code generator no subscription, free unlimited qr code generator, static qr code generator
canonical_url: https://www.qrmaster.net/blog/static-vs-dynamic-qr-code
---
# QR Code Generator That Never Expires: The Truth About Hidden Limits & Permanent Free QR Codes
Few things are more frustrating for a business owner or marketer than printing 500 brochures, packaging labels, or restaurant tabletop signs, only to discover two weeks later that the printed QR code has stopped working because a third-party generator placed the link behind a hidden subscription paywall.
Every day, thousands of users search Google for phrases like:
- *"qr code generator does not expire"*
- *"permanent qr code generator"*
- *"free forever qr code generator"*
- *"qr code generator no subscription"*
Why does this happen so frequently? Because many commercial QR tools use aggressive **freemium lock-in tactics**: they allow users to generate a "free" code, wait until the physical materials are printed and distributed, and then redirect the barcode to a paywall blocking screen until the user pays a monthly subscription fee.
In this technical guide, we will unpack the computer science reality of how QR code expiration actually works, how to generate 100% permanent static QR codes that physically **cannot expire**, and how to choose a **permanent qr code generator** for your projects.
---
## 1. The Computer Science Reality: Can a QR Code Physically Expire?
To understand expiration, you must understand where the data lives. A QR code is a 2D optical barcode that stores binary data in a physical matrix grid of dark and light modules.
```
┌───────────────────────────────────────┬───────────────────────────────────────┐
│ Static QR Code (Permanent) │ Dynamic Proxy QR Code │
├───────────────────────────────────────┼───────────────────────────────────────┤
│ Payload (URL, text, WiFi) is encoded │ Encodes a short proxy URL link │
│ directly into the matrix bits. │ (e.g. https://service.com/r/123) │
│ │ │
│ ❌ CANNOT EXPIRE physically │ ⚠️ EXPIRES if proxy server is closed │
│ ❌ No server or account required │ ⚠️ Requires active redirect service │
└───────────────────────────────────────┴───────────────────────────────────────┘
```
### Static QR Codes: 100% Expiration-Proof
A static QR code generated by a **static qr code generator** encodes the raw text or URL directly into the matrix (using ISO/IEC 18004 Reed-Solomon encoding).
- Once printed on paper, metal, or plastic, the barcode is purely offline data—like a printed book or a 1D supermarket EAN barcode.
- **There is no central server, database, or account attached.**
- As long as the printed paper remains clean and readable, a camera reading a static QR code in 50 years will extract the exact same string. **A static QR code cannot expire.**
### Dynamic Proxy QR Codes: Service-Dependent
A dynamic QR code encodes a short managed proxy URL (e.g., `https://qr.domain.com/r/xyz123`) instead of the final website link.
- When scanned, the phone contacts the proxy server, which looks up the target destination in a database and forwards the scanner via an HTTP 307 redirect.
- If the proxy service goes out of business, deletes your account, or cancels your plan, the short proxy link returns a `404 Not Found` or payment wall.
---
## 2. Deconstructing the "Free QR Code Trap"
Many online QR tools take advantage of user unfamiliarity with the difference between static and dynamic codes.
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ The Freemium Lock-in Pipeline │
├─────────────────────────────────────────────────────────────────────────────┤
│ 1. User visits a "free qr generator" site to create a barcode for a flyer. │
│ 2. The site secretly generates a DYNAMIC proxy code instead of a static one.│
│ 3. User prints 1,000 brochures with the printed barcode. │
│ 4. 14 days later, the free trial ends. The proxy URL is redirected to: │
│ "This QR code has expired! Upgrade to PRO for $35/month to unlock." │
│ 5. User is forced to pay because reprinting 1,000 brochures costs more! │
└─────────────────────────────────────────────────────────────────────────────┘
```
### How to Detect the Trap BEFORE Printing:
Before sending any QR code image to a commercial print shop, scan it with your smartphone camera and check the target URL preview on your screen:
- **Direct Target URL** (e.g. `https://yourcompany.com/menu`): It is a **static permanent QR code**. It is 100% safe and will never expire!
- **Obfuscated Third-Party URL** (e.g. `https://qr-gen-app.link/x79z`): It is a **dynamic proxy code**. If you are on a free trial, it WILL expire when the trial ends unless you pay!
---
## 3. Comparison: Static vs. Dynamic vs. Permanent Free Tools
Let's compare your options when looking for a **free forever qr code generator**:
```
┌───────────────────────────┬───────────────────────────┬───────────────────────────┬───────────────────────────┐
│ Feature │ Free Static QR Generator │ Paid Dynamic QR Generator │ Predatory "Free" Generators│
├───────────────────────────┼───────────────────────────┼───────────────────────────┼───────────────────────────┤
│ Expiration Risk │ 🟢 NEVER (0% Risk) │ 🟡 Active Subscription │ 🔴 Expires after 7-14 days│
│ Requires Account/Sign-Up │ 🟢 No │ 🟡 Yes │ 🔴 Yes │
│ Link Editability │ 🔴 No (Fixed Matrix) │ 🟢 Yes (Update anytime) │ 🟡 Only while paid │
│ Scan Analytics │ 🔴 No │ 🟢 Yes (GA4 / Geo-IP) │ 🟡 Behind paywall │
│ Vector SVG Download │ 🟢 Yes │ 🟢 Yes │ 🔴 Blocked or Watermarked │
└───────────────────────────┴───────────────────────────┴───────────────────────────┴───────────────────────────┘
```
---
## 4. Programmatic Implementation: Building a Guaranteed Non-Expiring QR Generator
To ensure your applications generate **permanent qr code generator** outputs programmatically, build an in-house static generator module in TypeScript.
### Step 4.1: Installation
```bash
npm install qrcode
npm install --save-dev typescript @types/node
```
### Step 4.2: Permanent Static QR Service (`src/services/permanentQrEngine.ts`)
```typescript
import QRCode from 'qrcode';
export interface StaticQrConfig {
text: string;
errorCorrection?: 'L' | 'M' | 'Q' | 'H';
colorDark?: string;
colorLight?: string;
}
export class PermanentQrEngine {
/**
* Generates a 100% static, non-expiring vector SVG QR code.
* Direct payload encoding ensures zero third-party server dependency.
*/
public static async createPermanentSvg(config: StaticQrConfig): Promise<string> {
const {
text,
errorCorrection = 'M',
colorDark = '#000000',
colorLight = '#FFFFFF',
} = config;
if (!text || text.trim().length === 0) {
throw new Error('Payload text or URL is required to generate a static QR code.');
}
try {
const svgString = await QRCode.toString(text, {
type: 'svg',
errorCorrectionLevel: errorCorrection,
margin: 4,
color: {
dark: colorDark,
light: colorLight,
},
});
return svgString;
} catch (err) {
throw new Error(`Static QR Generation Error: ${(err as Error).message}`);
}
}
}
```
---
## 5. Frequently Asked Questions (FAQ)
### Q1: Is there a free unlimited qr code generator that never expires?
**Yes.** Any **static qr code generator** that encodes your destination URL directly into the matrix creates a permanent barcode that never expires. Static codes require no account or subscription.
### Q2: What happens if the domain of a static QR code changes?
Because a static code hardcodes the URL into the matrix, if your website domain changes (e.g. from `site.com` to `newsite.com`), the static code will still point to `site.com`. You can fix this by setting up a domain-level 301 redirect on your web server from your old domain to your new domain!
### Q3: How do I get a permanent QR code with a logo?
Use a **custom qr code generator** enforcing Reed-Solomon **Level H** error correction. This allows you to embed a brand logo in the center while keeping the static matrix 100% permanent.
---
## Conclusion
Understanding the fundamental technical difference between static matrix encoding and dynamic proxy redirects protects you from predatory subscription paywalls. For permanent print campaigns where URLs are stable, a **permanent qr code generator** using static SVG output is the safest, zero-cost choice.
To generate 100% permanent, non-expiring static QR codes with zero ads, zero watermarks, and high-resolution vector SVG downloads, check out [QR Master Free Permanent QR Code Generator](https://www.qrmaster.net/blog/static-vs-dynamic-qr-code).

View File

@@ -0,0 +1,244 @@
---
title: "Preventing Quishing (QR Phishing): Building an Automated Threat Inspection Pipeline"
description: "A deep cybersecurity developer guide to understanding Quishing attack vectors, qr code security, building a secure qr code generator, and verifying domain SSL certificates in Node.js."
tags: security, cybersecurity, nodejs, webdev
keywords: qr code security, secure qr code generator, safe qr code generator, qr code security best practices, quishing prevention
canonical_url: https://www.qrmaster.net/blog/qr-code-security
---
# Preventing Quishing (QR Phishing): Building an Automated Threat Inspection Pipeline
As QR codes become standard infrastructure for payments, Wi-Fi connections, and physical login flows, **qr code security** has become a top priority. Cybercriminals have adopted **Quishing** (QR Phishing)—the act of replacing physical QR codes on parking meters, posters, or restaurant tables with malicious codes that redirect victims to credential-harvesting phishing portals.
Because security scanners in email gateways and web browsers cannot inspect physical paper stickers, Quishing bypasses traditional perimeter defenses.
For SaaS platforms building a **secure qr code generator** that allows users to create dynamic redirects, preventing malicious actors from turning your platform into a phishing proxy is a major AppSec priority.
In this cybersecurity guide, we will analyze Quishing attack mechanics and build an automated threat inspection pipeline in TypeScript to ensure your platform remains a **safe qr code generator**.
---
## 1. Deconstructing the Quishing Attack Vector
Unlike standard phishing emails containing suspicious links like `http://paypal-security-login.xyz`, Quishing exploits the visual obscurity of 2D barcodes. Humans cannot read a QR matrix with their eyes; they must scan it first to reveal the URL.
```
┌────────────────────────────────────────┐
│ Attacker Swaps Physical QR Sticker │
└───────────────────┬────────────────────┘
┌────────────────────────────────────────┐
│ User Scans QR Code with Smartphone │
└───────────────────┬────────────────────┘
┌────────────────────────────────────────┐
│ Redirect Chain (Multi-Hop Proxy) │
│ http://short.link ➔ http://eval.site │
│ ➔ https://fake-bank-login.com │
└───────────────────┬────────────────────┘
┌────────────────────────────────────────┐
│ Victim Enters Password / MFA Credentials│
└───────────────────┴────────────────────┘
```
### Common Evasion Tactics in QR Code Security:
1. **Multi-Hop Redirections**: Using 3 or 4 chained shorteners (`bit.ly` $\to$ `tinyurl` $\to$ malicious domain) to obfuscate final destination.
2. **Time-Gated Payload Switching**: Pointing the QR code to a benign site during initial review, then updating the target to a phishing page after printing.
3. **Geo-Targeted Cloaking**: Serving a harmless homepage to cloud inspection bots (AWS/GCP IPs), but redirecting mobile device user-agents to phishing kits.
---
## 2. Architecture of a Secure QR Code Generator Pipeline
When a user submits a destination URL in your **secure qr code generator**, it must pass through an automated inspection pipeline prior to link activation:
```
User Submitted URL
┌────────────────────────────────────────┐
│ 1. Syntax & Open Redirect Sanitizer │
└───────────────────┬────────────────────┘
┌────────────────────────────────────────┐
│ 2. Domain Age & Whois Verification │
└───────────────────┬────────────────────┘
┌────────────────────────────────────────┐
│ 3. Google Safe Browsing API Check │
└───────────────────┬────────────────────┘
┌────────────────────────────────────────┐
│ 4. Multi-Hop Redirect Trace & Headless │
│ DOM Inspection (Puppeteer) │
└───────────────────┬────────────────────┘
Pass / Fail Flag
```
---
## 3. Step-by-Step Implementation in TypeScript
Let's build a threat scanner module in TypeScript for a **safe qr code generator**.
### Step 3.1: Install Dependencies
```bash
npm install axios google-auth-library valid-url tldts
npm install --save-dev typescript @types/node
```
### Step 3.2: Threat Scanner Service (`src/services/threatScanner.ts`)
```typescript
import axios from 'axios';
import { parse } from 'tldts';
export interface ThreatScanResult {
isSafe: boolean;
finalDestination: string;
redirectChain: string[];
threatType?: string;
reason?: string;
}
export class ThreatScanner {
private static SAFE_BROWSING_API_KEY = process.env.GOOGLE_SAFE_BROWSING_KEY || '';
/**
* Runs complete QR code security inspection pipeline on a submitted URL.
*/
public static async inspectUrl(initialUrl: string): Promise<ThreatScanResult> {
const redirectChain: string[] = [initialUrl];
// 1. Basic Protocol & Syntax Validation
if (!initialUrl.startsWith('http://') && !initialUrl.startsWith('https://')) {
return {
isSafe: false,
finalDestination: initialUrl,
redirectChain,
reason: 'Invalid protocol. Only HTTP and HTTPS are permitted.',
};
}
// 2. Prevent IP-based URLs (e.g. http://192.168.1.1 or http://169.254.169.254 AWS Metadata attack)
const domainInfo = parse(initialUrl);
if (!domainInfo.domain && !domainInfo.isIp) {
return {
isSafe: false,
finalDestination: initialUrl,
redirectChain,
reason: 'Invalid or missing domain name.',
};
}
if (domainInfo.isIp) {
return {
isSafe: false,
finalDestination: initialUrl,
redirectChain,
reason: 'Direct IP address destinations are prohibited to prevent SSFR attacks.',
};
}
// 3. Trace Full Redirect Chain (Max 5 Hops)
let currentUrl = initialUrl;
try {
let hops = 0;
while (hops < 5) {
const response = await axios.head(currentUrl, {
maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 400,
timeout: 4000,
headers: {
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15',
},
});
if (response.status >= 300 && response.status < 400 && response.headers.location) {
const nextUrl = new URL(response.headers.location, currentUrl).href;
redirectChain.push(nextUrl);
currentUrl = nextUrl;
hops++;
} else {
break; // Terminal destination reached
}
}
} catch (err) {
console.warn(`[ThreatScanner] Warning: Redirect trace halted on ${currentUrl}`);
}
const finalDestination = currentUrl;
// 4. Query Google Safe Browsing API v4
if (this.SAFE_BROWSING_API_KEY) {
const isMalicious = await this.checkGoogleSafeBrowsing(finalDestination);
if (isMalicious) {
return {
isSafe: false,
finalDestination,
redirectChain,
threatType: 'MALWARE_OR_PHISHING',
reason: 'Destination flagged by Google Safe Browsing security database.',
};
}
}
return {
isSafe: true,
finalDestination,
redirectChain,
};
}
private static async checkGoogleSafeBrowsing(targetUrl: string): Promise<boolean> {
try {
const endpoint = `https://safebrowsing.googleapis.com/v4/threatMatches:find?key=${this.SAFE_BROWSING_API_KEY}`;
const payload = {
client: {
clientId: 'qrmaster-security-scanner',
clientVersion: '1.0.0',
},
threatInfo: {
threatTypes: ['MALWARE', 'SOCIAL_ENGINEERING', 'UNWANTED_SOFTWARE', 'POTENTIALLY_HARMFUL_APPLICATION'],
platformTypes: ['ANY_PLATFORM'],
threatEntryTypes: ['URL'],
threatEntries: [{ url: targetUrl }],
},
};
const response = await axios.post(endpoint, payload);
return !!(response.data && response.data.matches && response.data.matches.length > 0);
} catch (err) {
console.error('[SafeBrowsing API Error]:', (err as Error).message);
return false;
}
}
}
```
---
## 4. Best Practices for QR Code Security
Implementing automated URL scanning is only step one. Enforce these operational safeguards across a **secure qr code generator**:
1. **Mandatory Custom Domain Support**: Allow enterprise users to brand dynamic QR links with their own custom domain (e.g., `qr.brand.com`) instead of sharing a generic domain pool. This isolates reputation.
2. **Real-Time URL Re-Scanning**: Re-run threat scans periodically (e.g., every 24 hours) on active dynamic QR codes to catch time-gated payload switching attacks.
3. **Phishing Report Abuse Button**: Include a small "Report Abuse" link on interstitial preview pages so users can flag suspicious links immediately.
---
## Conclusion
Quishing poses a real threat to digital-to-physical user trust. By implementing automated URL syntax sanitization, multi-hop redirect tracing, and Google Safe Browsing integration, developers can build a **secure qr code generator** that protects platforms and users from malicious QR phishing attacks.
To learn more about **qr code security**, SSL encryption, and custom domain isolation, check out [QR Master Security Best Practices](https://www.qrmaster.net/blog/qr-code-security).

View File

@@ -0,0 +1,190 @@
---
title: "Understanding Reed-Solomon Error Correction Math & Safe Logo Embedding in QR Codes"
description: "A deep computer science exploration of Galois Field GF(2^8) math in Reed-Solomon error correction and building a custom QR code generator to embed brand logos."
tags: math, computer-science, graphics, algorithm
keywords: custom qr code generator, free custom qr code generator, qr code designer, branded qr code generator, custom qr code, create custom qr code
canonical_url: https://www.qrmaster.net/blog/custom-qr-code-design
---
# Understanding Reed-Solomon Error Correction Math & Safe Logo Embedding in QR Codes
Many developers assume QR codes are fragile grids where changing a single black module into white destroys the entire payload. In reality, QR codes generated by a **custom qr code generator** are engineered with **Reed-Solomon Error Correction**, a powerful algebraic coding scheme that allows up to 30% of the physical barcode to be completely destroyed, stained, or covered by a company logo while remaining 100% scannable.
However, naive logo overlays—such as slapping a large PNG graphic directly into the center of a QR code using image editing software—frequently cause scan failures in low-light or low-resolution camera sensors.
In this article, we will unpack the computer science math behind Galois Fields $GF(2^8)$, Reed-Solomon error correction polynomials, and how a **branded qr code generator** computes safe logo placement margins without corrupting the barcode matrix.
---
## 1. The Computer Science Math of Reed-Solomon Codes
Reed-Solomon error correction in a **custom qr code generator** operates by representing data as polynomial coefficients over a finite field (also known as a **Galois Field**, denoted as $GF(2^8)$).
### Finite Field Arithmetic: $GF(2^8)$
Computers store data in bytes ($8\text{ bits} = 256$ distinct values). In $GF(2^8)$, arithmetic operations (addition, multiplication) are defined such that results never overflow 8 bits (values stay strictly between $0$ and $255$).
- **Addition & Subtraction**: In $GF(2^8)$, addition is equivalent to bitwise XOR (`^` in JavaScript/C++):
$$A + B = A \oplus B$$
- **Multiplication**: Multiplication uses a generator polynomial (typically $x^8 + x^4 + x^3 + x^2 + 1$, corresponding to the primitive decimal polynomial $285$).
### The Generator Polynomial
To generate $R$ error correction codewords for a data message polynomial $M(x)$, the message is multiplied by $x^R$ and divided by a generator polynomial $G(x)$:
$$G(x) = \prod_{i=0}^{R-1} (x - \alpha^i)$$
The remainder of this polynomial division forms the **Error Correction Codewords** appended to the end of the QR payload.
When a camera reads a damaged matrix from a **qr code designer**:
1. It evaluates the polynomial to find **Syndromes** ($S_1, S_2, \dots, S_R$).
2. If all syndromes equal $0$, the matrix has zero errors.
3. If syndromes are non-zero, algorithms like **Berlekamp-Massey** or **Chien Search** locate the exact error positions and correct the inverted bit values automatically!
---
## 2. Error Correction Capacity Levels in QR Codes
The ISO/IEC 18004 specification defines four error correction levels in a **custom qr code generator free** engine, determining how many redundant codewords are added to the matrix:
```
┌─────────────────────────┬──────────────────────┬───────────────────────────────┐
│ Error Correction Level │ Recovery Capacity │ Max Logo Coverage Budget │
├─────────────────────────┼──────────────────────┼───────────────────────────────┤
│ Level L (Low) │ ~7% of codewords │ Dangerous (Max < 4% surface) │
│ Level M (Medium) │ ~15% of codewords │ Low (Max ~8% surface) │
│ Level Q (Quartile) │ ~25% of codewords │ Moderate (Max ~15% surface) │
│ Level H (High) │ ~30% of codewords │ High (Max ~22-25% surface) │
└─────────────────────────┴──────────────────────┴───────────────────────────────┘
```
When you place a logo over the center of a QR code using a **custom qr code generator**, you are intentionally destroying codewords. Therefore:
> **Golden Rule**: Always set Error Correction Level to **Level H (High)** whenever embedding logos or custom artwork.
---
## 3. Mathematical Rules for Safe Logo Embedding
Overlaying a logo is not just about keeping the covered area under 30%. Camera scanners face environmental degradation (glare, shadows, camera blur, dirty lenses). If your logo consumes 28% of the error correction budget, a slight lens smudge will push total error past 30%, causing scan failure!
### Rule 1: Never Touch the Three Finder Patterns
The three large $7 \times 7$ square finder patterns in the top-left, top-right, and bottom-left corners are sacrosanct. If a camera cannot detect all three finder patterns, it cannot determine orientation or matrix dimensions, and decoding aborts instantly before Reed-Solomon math is even attempted!
### Rule 2: Keep Logo Surface Area Below 20%
To ensure reliable scanning across all smartphone models and lighting conditions in your **custom qr code designer**, limit your logo footprint to **15% to 20% of the total matrix area**.
$$\text{Max Logo Dimension (px)} = \text{Matrix Width (px)} \times \sqrt{0.20} \approx \text{Matrix Width} \times 0.44$$
### Rule 3: Add a Protective Padding Zone (Quiet Boundary)
Logos should never merge directly into surrounding QR modules. A 2-module wide solid background padding around the logo prevents module misinterpretation.
---
## 4. Programmatic Implementation: Merging Logo into QR SVG with Node.js
Below is a Node.js TypeScript module that programmatically computes matrix dimensions, generates a Level H QR SVG, embeds a centered vector logo, and applies a protective background mask for a **create custom qr code** service.
### Step 4.1: Code Implementation (`src/services/customQrBuilder.ts`)
```typescript
import QRCode from 'qrcode';
export interface LogoEmbedOptions {
text: string;
logoSvgContent: string; // Raw SVG string of logo (e.g. <path .../>)
logoWidthPercent?: number; // Target logo width as percentage of matrix (default: 20%)
colorDark?: string;
colorLight?: string;
}
export class CustomQRBuilder {
/**
* Generates a combined SVG string with centered logo and protective padding.
*/
public static async generateLogoQR(options: LogoEmbedOptions): Promise<string> {
const {
text,
logoSvgContent,
logoWidthPercent = 20,
colorDark = '#090D16',
colorLight = '#FFFFFF',
} = options;
// Enforce Level H (30% error tolerance)
const qrMatrix = QRCode.create(text, { errorCorrectionLevel: 'H' });
const moduleCount = qrMatrix.modules.size; // Total modules per side (e.g., 29x29)
const size = 500; // SVG canvas size in pixels
const margin = 4; // Module padding
const totalModules = moduleCount + margin * 2;
const moduleSizePx = size / totalModules;
// Compute Logo Pixel Bounds
const maxLogoPercent = Math.min(Math.max(logoWidthPercent, 10), 22);
const logoSizePx = size * (maxLogoPercent / 100);
const logoOffset = (size - logoSizePx) / 2;
// Protective padding around logo (in pixels)
const paddingPx = moduleSizePx * 1.5;
const padSizePx = logoSizePx + paddingPx * 2;
const padOffset = (size - padSizePx) / 2;
// 1. Generate Base QR SVG Paths
const rawSvg = await QRCode.toString(text, {
type: 'svg',
errorCorrectionLevel: 'H',
margin,
color: { dark: colorDark, light: colorLight },
});
// 2. Extract SVG Inner Content (Paths)
const svgInnerMatch = rawSvg.match(/<svg[^>]*>([\s\S]*?)<\/svg>/i);
const baseContent = svgInnerMatch ? svgInnerMatch[1] : '';
// 3. Construct Final Composite SVG with Protective White Rect + Logo
const compositeSvg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">
<!-- Base QR Matrix -->
${baseContent}
<!-- Protective Quiet Mask behind Logo -->
<rect
x="${padOffset.toFixed(2)}"
y="${padOffset.toFixed(2)}"
width="${padSizePx.toFixed(2)}"
height="${padSizePx.toFixed(2)}"
fill="${colorLight}"
rx="${moduleSizePx.toFixed(2)}"
/>
<!-- Embedded Centered Brand Logo -->
<g transform="translate(${logoOffset.toFixed(2)}, ${logoOffset.toFixed(2)}) scale(${(logoSizePx / 100).toFixed(4)})">
${logoSvgContent}
</g>
</svg>`.trim();
return compositeSvg;
}
}
```
---
## 5. Verification & Scannability Testing Checklist
Before deploying a **custom qr code generator** with embedded logos, run through this automated and manual test matrix:
```
[ ] Enforce Level H Error Correction in code config.
[ ] Verify logo consumes ≤ 20% total matrix area.
[ ] Confirm finder patterns (3 corner squares) are 100% un-obscured.
[ ] Test scan under low-light conditions (phone screen at 20% brightness).
[ ] Test scan at 45-degree angled perspective.
[ ] Test scan using both native iOS Camera App and Android Google Lens.
```
---
## Conclusion
Reed-Solomon error correction is an engineering marvel that makes a **custom qr code generator** with logo embedding possible. By understanding finite field mathematics, enforcing Level H error recovery, and restricting logo surface area to 20%, developers can build stunning, branded QR codes without sacrificing scan reliability.
To build pixel-perfect custom QR codes with verified scannability, vector logo uploads, and real-time scan metrics, try [QR Master Custom QR Code Generator](https://www.qrmaster.net/custom-qr-code-generator).

View File

@@ -0,0 +1,202 @@
---
title: "Parsing vCard (RFC 2426/6350) Specifications & Optimizing 2D Barcode Payload Limits"
description: "A deep technical guide to the vCard data specification standard, character encodings, payload byte limits in a vcard qr code generator, and building a qr code generator for business cards."
tags: webdev, javascript, typescript, standards
keywords: vcard qr code generator, free vcard qr code generator, qr code generator business card, free qr code generator for business cards, qr code business card free, qr code generator contact card
canonical_url: https://www.qrmaster.net/blog/vcard-qr-code-generator
---
# Parsing vCard (RFC 2426/6350) Specifications & Optimizing 2D Barcode Payload Limits
Digital business cards powered by a **vcard qr code generator** allow users to instantly save contact details—name, phone number, email, website, job title, and social links—directly into an iOS or Android address book with a single camera scan.
Behind the scenes, building a **qr code generator for business cards** relies on the **vCard specification** (RFC 2426 for vCard 3.0 and RFC 6350 for vCard 4.0).
However, many developers run into a major issue: when users paste extensive bio notes, social media links, profile photos, or secondary addresses into a **free qr code generator for business cards**, the QR matrix becomes extremely dense (Version 25+ with over 1,500 modules). This results in a tiny, cluttered barcode that fails to scan on mobile cameras.
In this developer guide, we will analyze the vCard specification RFC standards, calculate maximum 2D barcode payload capacity, and write a TypeScript contact card optimizer that compresses vCard data for instant scannability.
---
## 1. Breakdown of the vCard Specification Standards
A vCard used in a **vcard qr code generator** is a plain-text MIME directory format storing contact details line-by-line using `KEY:VALUE` properties.
### vCard 3.0 (RFC 2426) vs. vCard 4.0 (RFC 6350)
```
┌───────────────────────────┬─────────────────────────────┬─────────────────────────────┐
│ Feature │ vCard 3.0 (RFC 2426) │ vCard 4.0 (RFC 6350) │
├───────────────────────────┼─────────────────────────────┼─────────────────────────────┤
│ Mobile OS Compatibility │ 100% Universal (iOS & Android)│ ~85% (Fails on older OS) │
│ Character Encoding │ UTF-8 / Quoted-Printable │ Mandatory UTF-8 │
│ Preferred Recommendation │ ✅ BEST for QR Code Barcodes │ ⚠️ Use with caution │
└───────────────────────────┴─────────────────────────────┴─────────────────────────────┘
```
> **Important Developer Note**: Always target **vCard 3.0** when building a **free vcard qr code generator** that embeds data directly into static QR codes. Native camera scanner parsers on older Android versions and non-standard camera apps frequently fail to recognize vCard 4.0 properties.
### Standard vCard 3.0 Structure Example:
```text
BEGIN:VCARD
VERSION:3.0
N:Knuth;Timo;;;
FN:Timo Knuth
ORG:QR Master
TITLE:Lead Software Architect
TEL;TYPE=CELL,VOICE:+15550192834
EMAIL;TYPE=INTERNET,PREF:timo@qrmaster.net
URL:https://www.qrmaster.net
ADR;TYPE=WORK:;;100 Tech Way;San Francisco;CA;94107;USA
END:VCARD
```
---
## 2. QR Code Capacity Limits & The Matrix Density Problem
QR codes have 40 discrete matrix sizes (Version 1 to Version 40). As byte payload increases, matrix size grows exponentially:
```
┌─────────┬──────────────┬─────────────────────────┬────────────────────────────────┐
│ Version │ Matrix Grid │ Max Bytes (Level M) │ Scan Usability on Business Cards│
├─────────┼──────────────┼─────────────────────────┼────────────────────────────────┤
│ Ver 3 │ 29 x 29 │ 53 bytes │ Super Fast (Instant) │
│ Ver 6 │ 41 x 41 │ 134 bytes │ Excellent │
│ Ver 11 │ 61 x 61 │ 321 bytes │ Good (Standard vCard max) │
│ Ver 20 │ 97 x 97 │ 858 bytes │ Sluggish / Requires Closeup │
│ Ver 40 │ 177 x 177 │ 2,331 bytes │ Fails on printed cards │
└─────────┴──────────────┴─────────────────────────┴────────────────────────────────┘
```
### The Physical Print Limit Rule for Business Cards
On a standard $85\text{ mm} \times 55\text{ mm}$ printed business card, a QR code created with a **qr code business card free** generator printed smaller than $20\text{ mm} \times 20\text{ mm}$ should **never exceed Version 10 (600 bytes)**. Encoding full profile photos (BASE64 strings) directly into a static vCard QR code requires over 5,000 bytes, which exceeds maximum QR capacity entirely!
---
## 3. Building a TypeScript vCard Optimizer & Sanitizer
To guarantee fast scans, we can build a utility class in TypeScript for a **qr code generator contact card** that formats vCard properties, strips unnecessary whitespace, sanitizes multi-byte characters, and compresses payload size.
### Step 3.1: vCard Builder Implementation (`src/services/vcardOptimizer.ts`)
```typescript
export interface ContactFields {
firstName: string;
lastName: string;
organization?: string;
title?: string;
phoneCell?: string;
phoneWork?: string;
email?: string;
url?: string;
city?: string;
country?: string;
}
export class VCardOptimizer {
/**
* Generates a clean, byte-optimized vCard 3.0 string for a vcard qr code generator.
*/
public static buildOptimizedVCard(fields: ContactFields): string {
const lines: string[] = [];
// Header
lines.push('BEGIN:VCARD');
lines.push('VERSION:3.0');
// Structured Name (N:LastName;FirstName;;;)
const last = this.cleanText(fields.lastName || '');
const first = this.cleanText(fields.firstName || '');
lines.push(`N:${last};${first};;;`);
// Formatted Name (FN:FirstName LastName)
const fullName = `${first} ${last}`.trim();
lines.push(`FN:${fullName}`);
// Optional Fields (Only append if non-empty to conserve bytes)
if (fields.organization) {
lines.push(`ORG:${this.cleanText(fields.organization)}`);
}
if (fields.title) {
lines.push(`TITLE:${this.cleanText(fields.title)}`);
}
if (fields.phoneCell) {
lines.push(`TEL;TYPE=CELL:${this.sanitizePhone(fields.phoneCell)}`);
}
if (fields.phoneWork) {
lines.push(`TEL;TYPE=WORK:${this.sanitizePhone(fields.phoneWork)}`);
}
if (fields.email) {
lines.push(`EMAIL;TYPE=INTERNET:${fields.email.trim()}`);
}
if (fields.url) {
lines.push(`URL:${fields.url.trim()}`);
}
if (fields.city || fields.country) {
const city = this.cleanText(fields.city || '');
const country = this.cleanText(fields.country || '');
lines.push(`ADR;TYPE=WORK:;;;${city};;;${country}`);
}
// Footer
lines.push('END:VCARD');
// Join with standard CRLF (\r\n) as specified by RFC 2426
return lines.join('\r\n');
}
private static sanitizePhone(phone: string): string {
return phone.replace(/[^\d+]/g, '');
}
private static cleanText(str: string): string {
return str
.trim()
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\n/g, ' ');
}
public static getByteSize(vcardString: string): number {
return Buffer.byteLength(vcardString, 'utf8');
}
}
```
---
## 4. Static vCard vs. Dynamic Business Card Landing Pages
When building a **qr code generator for business cards**, developers face a choice between two architectures:
```
┌───────────────────────────────────────┬───────────────────────────────────────┐
│ Static vCard QR Code │ Dynamic Business Card Landing Page │
├───────────────────────────────────────┼───────────────────────────────────────┤
│ Data stored directly inside QR matrix │ Encodes short URL (e.g. /c/timo) │
│ Works 100% offline (no internet needed)│ Requires internet connection │
│ Contact data CANNOT be edited │ Contact data can be updated anytime │
│ Limited fields (~300 bytes max) │ Unlimited fields, photo & social links│
└───────────────────────────────────────┴───────────────────────────────────────┘
```
### Strategic Recommendation:
- Use **Static vCard 3.0** when working offline or when data privacy is paramount (no external server dependency).
- Use **Dynamic Contact Landing Pages** when you need click analytics, social links, profile photos, or the ability to update details without reprinting cards.
---
## Conclusion
Understanding the vCard RFC 2426 specification and respecting barcode payload byte limits is essential for building a **vcard qr code generator**. By stripping non-essential formatting and targeting vCard 3.0, you ensure instant contact saves on both iOS and Android devices.
To build interactive dynamic business card QR codes with profile picture uploads, social links, and real-time contact save tracking, check out [QR Master vCard QR Code Generator](https://www.qrmaster.net/blog/vcard-qr-code-generator).

View File

@@ -0,0 +1,199 @@
---
title: "How to Create a Free Wi-Fi QR Code: The Complete Guide for Cafes, Hotels & Home Networks"
description: "A complete step-by-step technical guide to generating Wi-Fi QR codes, encoding WPA2/WPA3 credentials, avoiding security bugs, and printing tabletop stand graphics for guest access."
tags: networking, mobile, webdev, tutorial
keywords: qr wifi, wifi qr code generator, print qr code, free static qr code generator, print a qr code, wifi qr code, create wifi qr code
canonical_url: https://www.qrmaster.net/blog/wifi-qr-code-generator
---
# How to Create a Free Wi-Fi QR Code: The Complete Guide for Cafes, Hotels & Home Networks
Tired of spelling out long, complex Wi-Fi passwords to restaurant guests, Airbnb visitors, hotel clients, or home friends?
A **qr wifi** code allows anyone with an iPhone or Android device to point their native camera app at a printed barcode and tap a single banner button to automatically join the network—without typing a single character.
In your Google Keyword Planner data, search queries for `qr wifi` and `print qr code` have exploded with **+900% annual growth**.
In this technical guide, we will walk through the step-by-step process of using a **wifi qr code generator**, explaining string syntax, security protocols (WPA2/WPA3), character escaping rules, and downloading vector SVG graphics to **print a qr code** for physical tabletop stands.
---
## 1. How a Wi-Fi QR Code Works Behind the Scenes
Unlike web URLs that open Safari or Chrome, a Wi-Fi QR code uses a specialized, offline MIME payload format standardized by ZXing.
When a mobile device camera scans a **wifi qr code**, the operating system recognizes the `WIFI:` protocol prefix and hands off the credentials directly to the OS network manager (iOS Wi-Fi Settings / Android Network Manager).
```
┌────────────────────────────────────────┐
│ Camera Scans WIFI: Payload String │
└───────────────────┬────────────────────┘
┌────────────────────────────────────────┐
│ OS Displays Modal Banner: │
│ "Join 'Cafe_Guest' Wi-Fi Network?" │
└───────────────────┬────────────────────┘
┌────────────────────────────────────────┐
│ User Taps Banner ➔ One-Tap Auto Connect│
└────────────────────────────────────────┘
```
Because a Wi-Fi code stores network credentials directly in the matrix, it uses a **free static qr code generator**. It operates 100% offline—meaning guests can scan and connect even when cellular data coverage is unavailable inside a basement venue!
---
## 2. Step-by-Step Guide to Creating a Wi-Fi QR Code
### Step 1: Collect Your Exact Network Credentials
To generate a valid code, gather three exact values from your router or network admin panel:
1. **Network Name (SSID)**: The exact case-sensitive name broadcasted by your router (e.g., `Lounge_Guest_5G`).
2. **Password (Pre-shared Key)**: The exact Wi-Fi password.
3. **Security Encryption Type**:
- `WPA/WPA2/WPA3` (Standard for ~98% of modern home and business routers).
- `WEP` (Legacy encryption).
- `Open / None` (Unencrypted public networks).
---
### Step 2: Format the String with Proper Escaping
If your SSID or Wi-Fi password contains special characters like colons (`:`), semicolons (`;`), backslashes (`\`), or commas (`,`), you must escape them with a backslash.
#### Protocol Syntax Template:
```text
WIFI:S:<SSID>;T:<SECURITY>;P:<PASSWORD>;;
```
#### Example Formats:
```text
# Standard WPA2/WPA3 Home Network
WIFI:S:MyHomeWiFi;T:WPA;P:SecretPass2026;;
# Cafe Network with a Semicolon in the SSID ("Cafe;Lounge")
WIFI:S:Cafe\;Lounge;T:WPA;P:coffee123;;
# Free Open Public Network (No Password)
WIFI:S:Airport_Free_WiFi;T:nopass;;
```
> **Crucial Rule**: Notice the two semicolons (`;;`) at the end of the string. Leaving out the double semicolon will cause iOS Camera apps to fail to parse the barcode!
---
## 3. How to Print a QR Code for Physical Venues
Generating the digital image is only half the battle. When you **print a qr code** for physical tabletop signs or wall posters, follow these print specifications:
```
┌───────────────────────────┬───────────────────────────────────────────┐
│ Print Guideline │ Recommended Specification │
├───────────────────────────┼───────────────────────────────────────────┤
│ File Export Format │ Vector SVG (Scalable, non-pixelated) │
│ Minimum Physical Size │ 3 cm x 3 cm (1.2 in x 1.2 in) │
│ Quiet Zone Margin │ At least 4 modules of whitespace border │
│ Contrast Ratio │ Dark modules on a clean white background │
└───────────────────────────┴───────────────────────────────────────────┘
```
### Printable Tabletop Sign Template (HTML/CSS)
You can copy and save this HTML template to print professional Wi-Fi stand cards for your business:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Wi-Fi Access Tabletop Stand</title>
<style>
@media print { body { -webkit-print-color-adjust: exact; } }
body { font-family: 'Inter', system-ui, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #F8FAFC; margin: 0; }
.stand-card { background: white; width: 300px; padding: 32px 24px; border-radius: 16px; box-shadow: 0 10px 25px rgba(0,0,0,0.08); text-align: center; border: 1px solid #E2E8F0; }
h1 { font-size: 20px; color: #0F172A; margin: 0 0 6px; }
p.sub { color: #64748B; font-size: 13px; margin: 0 0 20px; }
.qr-box { background: #F1F5F9; padding: 16px; border-radius: 12px; display: inline-block; margin-bottom: 20px; }
.info { background: #F8FAFC; padding: 12px; border-radius: 8px; border: 1px solid #E2E8F0; font-size: 12px; text-align: left; }
.row { display: flex; justify-content: space-between; margin-bottom: 4px; }
.row:last-child { margin-bottom: 0; }
.lbl { color: #64748B; }
.val { color: #0F172A; font-weight: 600; font-family: monospace; }
</style>
</head>
<body>
<div class="stand-card">
<h1>Free Wi-Fi Access</h1>
<p class="sub">Scan with phone camera to connect</p>
<div class="qr-box">
<!-- Insert SVG QR Code Here -->
<svg width="180" height="180" viewBox="0 0 180 180">
<!-- SVG Paths -->
</svg>
</div>
<div class="info">
<div class="row"><span class="lbl">SSID:</span><span class="val">Guest_WiFi</span></div>
<div class="row"><span class="lbl">Pass:</span><span class="val">Welcome2026</span></div>
</div>
</div>
</body>
</html>
```
---
## 4. Programmatic Implementation: Wi-Fi Generator in TypeScript
Below is a TypeScript module that constructs escaped Wi-Fi strings and generates vector SVG barcodes automatically.
```typescript
import QRCode from 'qrcode';
export interface WifiParams {
ssid: string;
password?: string;
security: 'WPA' | 'WEP' | 'nopass';
hidden?: boolean;
}
export class WifiQrEngine {
/**
* Builds an escaped WIFI: URI payload and renders vector SVG.
*/
public static async generateWifiSvg(params: WifiParams): Promise<string> {
const { ssid, password = '', security, hidden = false } = params;
if (!ssid) throw new Error('SSID is mandatory.');
if (security !== 'nopass' && !password) throw new Error('Password is required.');
// Escape special characters: colons, semicolons, backslashes, commas
const cleanSsid = ssid.replace(/([\\;:,])/g, '\\$1');
const cleanPass = password.replace(/([\\;:,])/g, '\\$1');
let payload = `WIFI:S:${cleanSsid};T:${security};`;
if (security !== 'nopass') payload += `P:${cleanPass};`;
if (hidden) payload += `H:true;`;
payload += ';;'; // Double semicolon termination
// Generate static SVG
return await QRCode.toString(payload, {
type: 'svg',
errorCorrectionLevel: 'M',
margin: 4,
color: { dark: '#0F172A', light: '#FFFFFF' },
});
}
}
```
---
## Conclusion
Using a **wifi qr code generator** transforms the frustrating experience of typing Wi-Fi passwords into a seamless, one-tap camera interaction. By using static SVG vector files when you **print a qr code**, your guest Wi-Fi access signs remain scannable for years without extra maintenance.
To generate free vector Wi-Fi QR codes with custom tabletop templates and logo branding, check out [QR Master Free Wi-Fi QR Code Generator](https://www.qrmaster.net/blog/wifi-qr-code-generator).

View File

@@ -0,0 +1,261 @@
---
title: "Wi-Fi QR Code Protocol: WIFI: String Syntax Specification & Mobile OS Parsing"
description: "A comprehensive developer guide to the unofficial WIFI: URI protocol specification, character escaping rules, WPA2/WPA3 network formats, and creating a print qr code for Wi-Fi access."
tags: networking, mobile, webdev, security
keywords: qr wifi, wifi qr code generator, print qr code, print a qr code, free static qr code generator, static qr code generator
canonical_url: https://www.qrmaster.net/blog/wifi-qr-code-generator
---
# Wi-Fi QR Code Protocol: WIFI: String Syntax Specification & Mobile OS Parsing
Scanning a **qr wifi** code to automatically connect a smartphone to a Wi-Fi network without manually typing complex WPA3 passwords is one of the most common physical tech interactions.
Unlike vCards or geo-locations which have formal IETF RFC standards, a **wifi qr code generator** uses an de facto industry standard string syntax originally popularized by ZXing ("Zebra Crossing").
In this technical guide, we will inspect the exact `WIFI:` connection string syntax, character escaping rules, WPA2/WPA3 security flags, hidden network parameters, and build a TypeScript utility to generate a **print qr code** for physical tabletop stands using a **free static qr code generator**.
---
## 1. The `WIFI:` String Protocol Syntax
The payload generated by a **wifi qr code generator** is a formatted key-value string prefixed with `WIFI:`.
### Protocol Format:
```text
WIFI:S:<SSID>;T:<SECURITY_TYPE>;P:<PASSWORD>;H:<HIDDEN_FLAG>;;
```
### Parameter Specification:
| Parameter Key | Description | Allowed Values | Required? |
|---|---|---|---|
| **S** | Network SSID (Name) | Any string (UTF-8) | ✅ Mandatory |
| **T** | Security Encryption Type | `WPA`, `WEP`, `nopass` | ✅ Mandatory |
| **P** | Pre-shared Key (Password) | Network password string | Conditional (Skip if `nopass`) |
| **H** | Hidden SSID Flag | `true` or `false` | Optional (Default: `false`) |
---
## 2. Character Escaping Rules: Avoiding Connection Failures
The most frequent bug when building a **wifi qr code generator** is failing to escape special delimiter characters in the SSID or Password.
### Characters Requiring Backslash Escaping (`\`):
If an SSID or Wi-Fi password contains any of the following four characters:
- Colon `:`
- Semicolon `;`
- Backslash `\`
- Comma `,`
They **must be escaped with a preceding backslash (`\`)**.
### Escaping Examples:
```text
# Example 1: SSID containing a semicolon ("Coffee;Bar") and password "secret:123"
WIFI:S:Coffee\;Bar;T:WPA;P:secret\:123;;
# Example 2: Unencrypted Open Network ("Guest_WiFi")
WIFI:S:Guest_WiFi;T:nopass;;
# Example 3: Hidden WPA2/WPA3 Network ("Vault") with password "P@$$w0rd"
WIFI:S:Vault;T:WPA;P:P@$$w0rd;H:true;;
```
> **Important**: Notice the double semicolon (`;;`) at the very end of the string. Mobile camera scanners use the trailing double semicolon as the string termination marker when parsing **qr wifi** codes!
---
## 3. iOS vs. Android OS Parser Behavior
Understanding how mobile operating systems parse `WIFI:` barcodes prevents support headaches when users **print a qr code**.
```
┌───────────────────────────┬───────────────────────────────────────────┬───────────────────────────────────────────┐
│ Feature │ Apple iOS (Camera App) │ Android (Google Lens / Native Scanner) │
├───────────────────────────┼───────────────────────────────────────────┼───────────────────────────────────────────┤
│ User Interaction Prompt │ Displays banner: "Join 'SSID' Network?" │ Displays modal with "Connect to Network" │
│ One-Tap Auto Connect │ ✅ Yes (Connects without typing password) │ ✅ Yes (Saves & connects automatically) │
│ WPA3 Compatibility │ Map `T:WPA` for both WPA2 & WPA3 │ Map `T:WPA` for both WPA2 & WPA3 │
│ Enterprise (802.1X / EAP)│ ❌ Unsupported via standard `WIFI:` string│ ❌ Requires mobile profile (.mobileconfig)│
└───────────────────────────┴───────────────────────────────────────────┴───────────────────────────────────────────┘
```
*Note on WPA3:* Neither iOS nor Android requires a separate `T:WPA3` tag. Specifying `T:WPA` in your **static qr code generator** covers WPA, WPA2, and WPA3 Personal networks seamlessly.
---
## 4. TypeScript Implementation: Wi-Fi Payload Generator
Below is a complete, production-ready TypeScript utility class that formats, escapes, and validates payloads for a **free static qr code generator**.
### `src/services/wifiPayloadBuilder.ts`
```typescript
export type WifiSecurityType = 'WPA' | 'WEP' | 'nopass';
export interface WifiConfig {
ssid: string;
password?: string;
securityType: WifiSecurityType;
isHidden?: boolean;
}
export class WifiPayloadBuilder {
/**
* Generates a fully escaped, validated WIFI: connection string.
*/
public static buildPayload(config: WifiConfig): string {
const { ssid, password = '', securityType, isHidden = false } = config;
if (!ssid || ssid.trim().length === 0) {
throw new Error('Wi-Fi SSID is mandatory.');
}
if (securityType !== 'nopass' && (!password || password.length === 0)) {
throw new Error(`Password is required for security type "${securityType}".`);
}
// Escape special delimiter characters
const escapedSSID = this.escapeString(ssid);
const escapedPassword = securityType !== 'nopass' ? this.escapeString(password) : '';
let payload = `WIFI:S:${escapedSSID};T:${securityType};`;
if (securityType !== 'nopass') {
payload += `P:${escapedPassword};`;
}
if (isHidden) {
payload += `H:true;`;
}
// Append compulsory double-semicolon termination marker
payload += ';';
return payload;
}
private static escapeString(str: string): string {
return str.replace(/([\\;:,])/g, '\\$1');
}
public static isValidWifiPayload(payload: string): boolean {
return payload.startsWith('WIFI:') && payload.endsWith(';;');
}
}
```
---
## 5. How to Print a QR Code: Printable Wi-Fi Tabletop Sign Template
When you **print a qr code** for physical venues (hotels, cafes, coworking spaces), pairing the vector barcode with clean printable HTML typography ensures guests know how to scan **qr wifi**.
### Example Printable HTML Template (`public/wifi-stand-card.html`):
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Wi-Fi Access Sign - Print QR Code</title>
<style>
@media print { body { -webkit-print-color-adjust: exact; } }
body { font-family: 'Inter', system-ui, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #F8FAFC; margin: 0; }
.card { background: white; width: 320px; padding: 36px 28px; border-radius: 20px; box-shadow: 0 10px 25px rgba(0,0,0,0.08); text-align: center; border: 1px solid #E2E8F0; }
h1 { font-size: 22px; color: #0F172A; margin: 0 0 6px; }
p.subtitle { color: #64748B; font-size: 14px; margin: 0 0 24px; }
.qr-container { background: #F1F5F9; padding: 16px; border-radius: 16px; display: inline-block; margin-bottom: 24px; }
.qr-container svg { display: block; }
.info-box { background: #F8FAFC; padding: 12px 16px; border-radius: 12px; border: 1px solid #E2E8F0; text-align: left; font-size: 13px; }
.info-row { display: flex; justify-content: space-between; margin-bottom: 6px; }
.info-row:last-child { margin-bottom: 0; }
.label { color: #64748B; font-weight: 500; }
.val { color: #0F172A; font-weight: 600; font-family: monospace; }
</style>
</head>
<body>
<div class="card">
<h1>Connect to Wi-Fi</h1>
<p class="subtitle">Scan with your phone camera to join</p>
<div class="qr-container">
<!-- Insert Vector SVG QR Code Here -->
<svg width="180" height="180" viewBox="0 0 180 180">
<!-- SVG Paths -->
</svg>
</div>
<div class="info-box">
<div class="info-row">
<span class="label">Network:</span>
<span class="val">Guest_Lounge_5G</span>
</div>
<div class="info-row">
<span class="label">Password:</span>
<span class="val">Welcome2026!</span>
</div>
</div>
</div>
</body>
</html>
```
---
## 6. End-to-End Test Suite with Jest
Let's write a unit test suite to verify string escaping and boundary conditions.
### `tests/wifiPayload.test.ts`
```typescript
import { WifiPayloadBuilder } from '../src/services/wifiPayloadBuilder';
describe('WifiPayloadBuilder', () => {
test('should generate standard WPA2 payload', () => {
const payload = WifiPayloadBuilder.buildPayload({
ssid: 'MyHomeWiFi',
password: 'SuperSecretPassword123',
securityType: 'WPA',
});
expect(payload).toBe('WIFI:S:MyHomeWiFi;T:WPA;P:SuperSecretPassword123;;');
});
test('should escape colons and semicolons in SSID and Password', () => {
const payload = WifiPayloadBuilder.buildPayload({
ssid: 'Cafe;WiFi:5G',
password: 'pass;word:123,key\\',
securityType: 'WPA',
});
expect(payload).toBe('WIFI:S:Cafe\\;WiFi\\:5G;T:WPA;P:pass\\;word\\:123\\,key\\\\;;');
});
test('should handle open unencrypted networks', () => {
const payload = WifiPayloadBuilder.buildPayload({
ssid: 'FreePublicWiFi',
securityType: 'nopass',
});
expect(payload).toBe('WIFI:S:FreePublicWiFi;T:nopass;;');
});
test('should include hidden flag when network is hidden', () => {
const payload = WifiPayloadBuilder.buildPayload({
ssid: 'HiddenNetwork',
password: 'secretpassword',
securityType: 'WPA',
isHidden: true,
});
expect(payload).toBe('WIFI:S:HiddenNetwork;T:WPA;P:secretpassword;H:true;;');
});
});
```
---
## Conclusion
Understanding the `WIFI:` payload specification and implementing strict character escaping in a **wifi qr code generator** ensures seamless, friction-free auto-connections when you **print a qr code** for hotel guests, restaurant customers, and office visitors.
To generate customizable vector Wi-Fi QR codes with custom brand colors, logo embedding, and printable tabletop stand templates, check out [QR Master Free Wi-Fi QR Generator](https://www.qrmaster.net/blog/wifi-qr-code-generator).

View File

@@ -0,0 +1,116 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>QR Master — Design Bake-off</title>
<style>
:root {
--bg: #141414; --panel: #1c1c1c; --line: #333333; --ink: #ECECEC; --muted: #8A8A8A;
--accent: #E8622C; --mono: "IBM Plex Mono","JetBrains Mono",ui-monospace,Consolas,monospace;
}
* { margin:0; padding:0; box-sizing:border-box; }
html,body { height:100%; background:var(--bg); color:var(--ink); font-family:var(--mono); }
.topbar {
display:flex; align-items:center; justify-content:space-between; gap:20px;
padding:14px 20px; border-bottom:1px solid var(--line); flex-wrap:wrap;
}
.topbar-left { display:flex; align-items:center; gap:14px; flex-wrap:wrap; }
.topbar-label { font-size:10.5px; letter-spacing:0.1em; text-transform:uppercase; color:var(--muted); line-height:1.3; }
.collection-btns { display:flex; gap:8px; flex-wrap:wrap; }
.cbtn {
font-family:var(--mono); font-size:11px; font-weight:600; letter-spacing:0.06em; text-transform:uppercase;
background:transparent; border:1px solid var(--line); color:var(--ink);
padding:9px 16px; border-radius:3px; cursor:pointer; transition:all .15s ease;
}
.cbtn:hover { border-color:var(--accent); }
.cbtn.active { background:var(--accent); border-color:var(--accent); color:#141414; }
.topbar-hint { font-size:10.5px; letter-spacing:0.06em; color:var(--muted); text-transform:uppercase; text-align:right; }
.topbar-hint b { color:var(--ink); }
.compare { display:grid; grid-template-columns:1fr 1fr; height:calc(100vh - 60px); }
.pane { display:flex; flex-direction:column; border-right:1px solid var(--line); min-width:0; }
.pane:last-child { border-right:none; }
.pane-head {
display:flex; align-items:center; justify-content:space-between; gap:12px;
padding:10px 18px; border-bottom:1px solid var(--line); background:var(--panel);
}
.pane-head .name { font-size:12px; font-weight:700; letter-spacing:0.08em; text-transform:uppercase; }
.pane-head .name.left-accent { color:var(--accent); }
.pane-head .name.right-accent { color:#5FA8FF; }
.pane-head .tag { font-size:10px; color:var(--muted); letter-spacing:0.08em; text-transform:uppercase; }
.pane iframe { flex:1; width:100%; border:none; background:#fff; }
@media (max-width: 900px) {
.compare { grid-template-columns:1fr; height:auto; }
.pane { border-right:none; border-bottom:1px solid var(--line); height:70vh; }
}
</style>
</head>
<body>
<div class="topbar">
<div class="topbar-left">
<span class="topbar-label">Design<br>Collection:</span>
<div class="collection-btns" id="collectionBtns">
<button class="cbtn" data-slug="blueprint">Print-Tech</button>
<button class="cbtn" data-slug="dither-mono">Dither Mono</button>
<button class="cbtn" data-slug="vast-quiet">Vast Quiet</button>
<button class="cbtn" data-slug="data-texture">Data-Texture</button>
<button class="cbtn" data-slug="classical">Classical</button>
<button class="cbtn active" data-slug="quiet-mono">Quiet Mono</button>
</div>
</div>
<div class="topbar-hint">Left = <b>Light</b> · Right = <b>Dark</b> · Keys 16</div>
</div>
<div class="compare">
<div class="pane">
<div class="pane-head"><span class="name left-accent" id="leftName">Classical</span><span class="tag">Light</span></div>
<iframe id="leftFrame" title="Light mode preview"></iframe>
</div>
<div class="pane">
<div class="pane-head"><span class="name right-accent" id="rightName">Classical</span><span class="tag">Dark</span></div>
<iframe id="rightFrame" title="Dark mode preview"></iframe>
</div>
</div>
<script>
(function () {
var LABELS = {
"blueprint": "Print-Tech",
"dither-mono": "Dither Mono",
"vast-quiet": "Vast Quiet",
"data-texture": "Data-Texture",
"classical": "Classical",
"quiet-mono": "Quiet Mono"
};
var ORDER = ["blueprint", "dither-mono", "vast-quiet", "data-texture", "classical", "quiet-mono"];
var btns = document.querySelectorAll('.cbtn');
var leftFrame = document.getElementById('leftFrame');
var rightFrame = document.getElementById('rightFrame');
var leftName = document.getElementById('leftName');
var rightName = document.getElementById('rightName');
function setCollection(slug) {
btns.forEach(function (b) { b.classList.toggle('active', b.getAttribute('data-slug') === slug); });
leftFrame.src = 'landing-' + slug + '.html';
rightFrame.src = 'landing-' + slug + '.html#dark';
leftName.textContent = LABELS[slug];
rightName.textContent = LABELS[slug];
}
btns.forEach(function (b) {
b.addEventListener('click', function () { setCollection(b.getAttribute('data-slug')); });
});
document.addEventListener('keydown', function (e) {
var idx = parseInt(e.key, 10) - 1;
if (idx >= 0 && idx < ORDER.length) setCollection(ORDER[idx]);
});
setCollection('quiet-mono');
})();
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,85 @@
# Test/staging overlay for testmodul.qrmaster.net.
#
# Start with:
# docker compose -p qrmaster-test --env-file .env.test \
# -f docker-compose.yml -f docker-compose.test.yml up -d --build
#
# The project name is what keeps the data apart: `-p qrmaster-test` gives this stack its own
# volumes, so its Postgres can never touch the production one.
services:
db:
container_name: qrmaster-test-db
# Production already publishes 5435 on the host.
ports: !reset []
# The base file hardcodes the database name in the probe. Staging uses its own name so a
# hand-typed psql session makes it obvious which instance you are on - without this
# override the probe would check a database that does not exist, the container would stay
# unhealthy and web (depends_on: service_healthy) would never start.
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d qrmaster_test"]
# Only on the internal network. `db` and `redis` are network aliases assigned per
# compose project, so leaving them on the shared external network would make `db`
# resolve to two containers and this stack could reach the production database.
#
# `!override` is required: compose MERGES the networks mapping from the base file, so a
# plain list would leave qrmaster-network attached and reintroduce exactly that bug.
networks: !override
- test-internal
redis:
container_name: qrmaster-test-redis
networks: !override
- test-internal
web:
container_name: qrmaster-test-web
# No `prisma migrate deploy` here. The migrations in prisma/migrations stopped in
# April 2026 and the schema has moved on through manual SQL since, so running them
# against a fresh database would build a stale schema the app cannot work with.
# Bring the schema in with `pg_dump --schema-only` from production instead.
environment:
# Docker sets HOSTNAME=<container-id>, and the Next.js standalone server binds to
# that single interface. With two networks Caddy then cannot reach the container.
HOSTNAME: "0.0.0.0"
# `db` and `redis` are taken in BOTH networks - by this stack in test-internal and
# by production in qrmaster-network. Production wins the lookup every time, so the
# base file's hostnames point the staging app at the production instances. Container
# names are unique per daemon and cannot be shadowed.
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@qrmaster-test-db:5432/${POSTGRES_DB}?schema=public
DIRECT_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@qrmaster-test-db:5432/${POSTGRES_DB}?schema=public
REDIS_URL: redis://qrmaster-test-redis:6379
entrypoint: ["node", "server.js"]
build:
args:
# Host-only cookie on staging, so `:-` (empty) is the correct value here.
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
# These three use `:?` on purpose: an empty value would silently fall back to the
# production defaults baked into the Dockerfile, and the staging frontend would then
# talk to production. Better to fail the build with a readable message.
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:?set AUTH_COOKIE_NAME in .env.test, e.g. userId_test}
NEXT_PUBLIC_WWW_URL: ${NEXT_PUBLIC_WWW_URL:?set NEXT_PUBLIC_WWW_URL in .env.test to https://testmodul.qrmaster.net}
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:?set NEXT_PUBLIC_APP_URL in .env.test to https://testmodul.qrmaster.net}
# Reachable by Caddy over the shared network, everything else stays internal.
networks:
- test-internal
- qrmaster-network
social-worker:
container_name: qrmaster-test-social-worker
environment:
# Never resolve the ambiguous `web` alias on the shared production
# network. The test container name is unique on this Docker daemon.
QRMASTER_API_BASE: http://qrmaster-test-web:3000
networks: !override
- test-internal
adminer:
container_name: qrmaster-test-adminer
ports: !reset []
networks: !override
- test-internal
networks:
test-internal:
driver: bridge

View File

@@ -43,6 +43,11 @@ services:
build:
context: .
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
restart: unless-stopped
environment:
@@ -53,11 +58,24 @@ services:
NEXTAUTH_URL: ${NEXTAUTH_URL}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
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}
CRON_SECRET: ${CRON_SECRET:-}
SOCIAL_MILESTONE_THRESHOLDS: ${SOCIAL_MILESTONE_THRESHOLDS:-}
SOCIAL_MILESTONE_POST_DELAY_HOURS: ${SOCIAL_MILESTONE_POST_DELAY_HOURS:-}
SOCIAL_MILESTONE_MIN_GAP_HOURS: ${SOCIAL_MILESTONE_MIN_GAP_HOURS:-}
# Channels the consent dialog may ask for. Only extend this once the
# worker actually publishes that channel.
SOCIAL_MILESTONE_CHANNELS: ${SOCIAL_MILESTONE_CHANNELS:-x}
TIKTOK_CLIENT_KEY: ${TIKTOK_CLIENT_KEY:-}
TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-}
TIKTOK_REDIRECT_URI: ${TIKTOK_REDIRECT_URI:-https://qrmaster.net/api/tiktok/callback}
TIKTOK_ADMIN_KEY: ${TIKTOK_ADMIN_KEY:-}
# Guards the asset upload route that Instagram (and TikTok) pull media
# from. Falls back to the TikTok key so existing setups keep working.
SOCIAL_ASSET_ADMIN_KEY: ${SOCIAL_ASSET_ADMIN_KEY:-}
TIKTOK_EXPECTED_OPEN_ID: ${TIKTOK_EXPECTED_OPEN_ID:-}
IP_SALT: ${IP_SALT:-your-salt-change-in-production}
ENABLE_DEMO: ${ENABLE_DEMO:-false}
@@ -77,8 +95,12 @@ services:
RESEND_API_KEY: ${RESEND_API_KEY:-}
SMTP_HOST: ${SMTP_HOST:-smtp.qrmaster.net}
SMTP_PORT: ${SMTP_PORT:-465}
SMTP_USER: ${SMTP_USER:-timo@qrmaster.net}
SMTP_USER: ${SMTP_USER:-info@qrmaster.net}
SMTP_PASS: ${SMTP_PASS:-}
NEWSLETTER_ADMIN_EMAIL: ${NEWSLETTER_ADMIN_EMAIL:-}
NEWSLETTER_ADMIN_PASSWORD: ${NEWSLETTER_ADMIN_PASSWORD:-}
NEWSLETTER_TEST_EMAIL: ${NEWSLETTER_TEST_EMAIL:-}
EMAIL_UNSUBSCRIBE_SECRET: ${EMAIL_UNSUBSCRIBE_SECRET:-}
NEXT_PUBLIC_POSTHOG_KEY: ${NEXT_PUBLIC_POSTHOG_KEY:-}
NEXT_PUBLIC_POSTHOG_HOST: ${NEXT_PUBLIC_POSTHOG_HOST:-https://us.i.posthog.com}
# Cloudflare R2 Storage
@@ -87,6 +109,8 @@ services:
R2_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY:-}
R2_BUCKET_NAME: ${R2_BUCKET_NAME:-qrmaster-menus}
R2_PUBLIC_URL: ${R2_PUBLIC_URL:-}
NEXT_PUBLIC_UMAMI_SRC: ${NEXT_PUBLIC_UMAMI_SRC:-}
NEXT_PUBLIC_UMAMI_ID: ${NEXT_PUBLIC_UMAMI_ID:-}
depends_on:
db:
condition: service_healthy
@@ -100,6 +124,35 @@ services:
networks:
- qrmaster-network
social-worker:
build:
context: ./scripts/social-worker
restart: unless-stopped
environment:
QRMASTER_API_BASE: http://web:3000
INTERNAL_API_SECRET: ${INTERNAL_API_SECRET}
SOCIAL_MILESTONE_POSTING_ENABLED: ${SOCIAL_MILESTONE_POSTING_ENABLED:-false}
SOCIAL_WORKER_INTERVAL_SECONDS: ${SOCIAL_WORKER_INTERVAL_SECONDS:-10}
X_API_KEY: ${X_API_KEY:-}
X_API_SECRET: ${X_API_SECRET:-}
X_ACCESS_TOKEN: ${X_ACCESS_TOKEN:-}
X_ACCESS_TOKEN_SECRET: ${X_ACCESS_TOKEN_SECRET:-}
# Channels this worker publishes. Must stay a subset of the app's
# SOCIAL_MILESTONE_CHANNELS - a channel the dialog offers but nobody
# publishes would leave approvals sitting in the queue.
SOCIAL_WORKER_CHANNELS: ${SOCIAL_WORKER_CHANNELS:-x}
INSTAGRAM_USER_ID: ${INSTAGRAM_USER_ID:-}
INSTAGRAM_ACCESS_TOKEN: ${INSTAGRAM_ACCESS_TOKEN:-}
GRAPH_API_VERSION: ${GRAPH_API_VERSION:-v22.0}
# Instagram downloads the image itself, so the worker hosts it through
# /api/social-assets on the verified domain. Same key as the web service.
SOCIAL_ASSET_ADMIN_KEY: ${SOCIAL_ASSET_ADMIN_KEY:-${TIKTOK_ADMIN_KEY:-}}
depends_on:
web:
condition: service_started
networks:
- qrmaster-network
# Adminer - Database Management UI (Optional)
adminer:

16
docker/init-db.sh Normal file → Executable file
View File

@@ -3,24 +3,26 @@ set -e
# This script runs when the PostgreSQL container is first created
# It ensures the database is properly initialized
#
# Keep this database-name agnostic: the staging stack (docker-compose.test.yml)
# runs the same script with POSTGRES_DB=qrmaster_test. A hardcoded name aborts
# the init, and the container never becomes healthy.
# Must stay LF-only and executable - Postgres sources non-executable init
# scripts, and CRLF breaks them on the first line.
echo "🚀 Initializing QR Master database..."
# Create the database if it doesn't exist (already created by POSTGRES_DB)
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
# The database itself is already created by POSTGRES_DB
psql -v ON_ERROR_STOP=1 -v dbname="$POSTGRES_DB" --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
-- Enable required extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
-- Grant privileges
GRANT ALL PRIVILEGES ON DATABASE qrmaster TO postgres;
-- Set timezone
ALTER DATABASE qrmaster SET timezone TO 'UTC';
ALTER DATABASE :"dbname" SET timezone TO 'UTC';
EOSQL
echo "✅ Database initialization complete!"
echo "📊 Database: $POSTGRES_DB"
echo "👤 User: $POSTGRES_USER"
echo "🌐 Ready to accept connections on port 5432"

View File

@@ -29,7 +29,7 @@
| `C:\Users\timo\Documents\meta_instagram_tokens.env` | Meta Facebook + Instagram long-lived/page tokens |
| `C:\Users\timo\Documents\r2_social_media.env` | R2 upload credentials for IG assets |
| `C:\Users\timo\Documents\instagram_r2_carousel_post.py` | uses both env files above |
| `C:\Users\timo\Documents\XApiAutopost\.env` | X/Twitter QRMaster creds |
| `C:\Users\timo\x-api-autopost\.env` | X/Twitter QRMaster creds (`X_API_KEY`, `X_API_SECRET`, `X_ACCESS_TOKEN`, `X_ACCESS_TOKEN_SECRET`) |
| `C:\Users\timo\Documents\greenlens-x-autopost\.env` | X/Twitter GreenLens creds |
| `C:\Users\timo\Documents\greenlens\Greenlens\.env` | GreenLens TikTok + plant import admin creds |

View File

@@ -0,0 +1,113 @@
# Social milestone worker
The app detects QR-code scan milestones and stores customer consent. It does not
hold X or Meta credentials. The external worker publishes what was approved —
per channel, never more.
## Consent is bound to a channel
Approving a post on X says nothing about Instagram: different audience,
different disclosure about the customer's business. Every channel therefore has
its own checkbox in the dialog, its own text, its own handle and its own row in
`SocialMilestonePost`. **No row means no consent.** A channel is only offered
where a publisher is configured — `SOCIAL_MILESTONE_CHANNELS` (app) must stay in
sync with `SOCIAL_WORKER_CHANNELS` (worker), otherwise approvals pile up in the
queue with nobody to publish them.
## Test setup (manual SQL only)
1. Apply, in this order, to `qrmaster_test`:
[`sql/2026-08-13_social_milestones.sql`](../../sql/2026-08-13_social_milestones.sql),
[`sql/2026-08-16_social_milestone_retries.sql`](../../sql/2026-08-16_social_milestone_retries.sql),
[`sql/2026-08-16_social_milestone_channels.sql`](../../sql/2026-08-16_social_milestone_channels.sql).
2. Set distinct `CRON_SECRET` and `INTERNAL_API_SECRET` values in `.env.test`.
For an end-to-end test without 1,000 scans, also set
`SOCIAL_MILESTONE_THRESHOLDS=1` (or `1,2`). Do not set this on production.
Publishing is immediate after consent by default. Set
`SOCIAL_MILESTONE_POST_DELAY_HOURS=24` only if a revocation window is desired.
Set `SOCIAL_MILESTONE_MIN_GAP_HOURS=0` on test, otherwise the second
milestone waits a full day behind the first one.
3. Deploy using the documented test compose command. `CRON_SECRET` is forwarded
to the web service by `docker-compose.yml`.
4. Trigger detection manually:
```bash
curl -H "Authorization: Bearer $CRON_SECRET" \
https://testmodul.qrmaster.net/api/cron/social-milestones
```
The detector creates records at 1,000 and 10,000 unique scans only. It is safe
to call repeatedly because `(qrId, kind)` is unique. A QR code that is already
past several thresholds on first detection only produces the highest one.
## Queue contract
```bash
curl -H "Authorization: Bearer $INTERNAL_API_SECRET" \
"https://qrmaster.net/api/internal/social-milestones?channel=instagram"
```
Returns at most one approved post for that channel, claims it, and expects a
result report for the returned `id`:
```bash
curl -X PATCH -H "Authorization: Bearer $INTERNAL_API_SECRET" \
-H "Content-Type: application/json" \
-d '{"id":"<post-id>","result":"posted","postUrl":"https://..."}' \
https://qrmaster.net/api/internal/social-milestones
```
First attempts are spaced by `SOCIAL_MILESTONE_MIN_GAP_HOURS` (default 24) per
channel, so the brand timeline cannot be flooded when several customers consent
on the same day. A blocked poll answers
`{"milestone": null, "reason": "spacing", "nextPostAt": "..."}`.
`milestone.text` is published **verbatim** — it is the text the customer read
before consenting. The image is not part of the payload: the worker downloads it
from `<QRMASTER_API_BASE>/s/m/<shareToken>/og`, the same renderer that serves the
popup and the link preview. `?format=` takes `landscape` (1200×630, default),
`square` (1080×1080) or `portrait` (1080×1350).
## Instagram
Publishing runs against the QRMaster.net Business account (see
[social-accounts-and-jobs.md](social-accounts-and-jobs.md)) in three steps:
`POST /{ig-user-id}/media` → poll `status_code` until `FINISHED`
`POST /{ig-user-id}/media_publish`.
Worth knowing before enabling it:
- **JPEG only.** The worker flattens the rendered PNG onto white and uploads it
through `POST /api/social-assets`; Meta downloads `image_url` itself, so it has
to be publicly readable on the verified domain. `SOCIAL_ASSET_ADMIN_KEY` is the
existing `TIKTOK_ADMIN_KEY`.
- **No clickable links in captions.** The Instagram text therefore ends in
hashtags instead of the share URL, and mentions use the customer's Instagram
handle, not their X handle.
- **50 posts / 24 h**, verifiable via `GET /{ig-user-id}/content_publishing_limit`.
- **Reconciliation is caption-based.** Before a repeated attempt the worker
compares the caption against the last 25 media items. Two milestones with an
identical caption — same QR title, same scan count — would be treated as the
same post; the 24 h spacing makes that combination unlikely but not impossible.
- Required environment: `SOCIAL_WORKER_CHANNELS=x,instagram`, `INSTAGRAM_USER_ID`,
`INSTAGRAM_ACCESS_TOKEN`, plus `SOCIAL_MILESTONE_CHANNELS=x,instagram` on the
web service so the dialog asks for it in the first place.
Do not configure this worker against `testmodul`. LinkedIn has no approved
brand-posting integration in this project. Customers can self-share on all three:
X opens a prefilled intent, LinkedIn gets the text copied, Instagram opens the
system share sheet on a phone and falls back to an image download.
## Failed posts
A reported failure — and a claim the worker never confirmed — counts as one
attempt. The queue re-schedules the post itself after 5, then 10 minutes and only
parks it in `failed` once three attempts are used up. Before posting again the
worker reconciles against the account (share token on X, caption on Instagram),
so a failure reported after a successful post cannot duplicate it.
The consent dialog opens once per milestone, so a customer who has closed it can
no longer see or restart a failed post from there. Settings → Milestone sharing
lists every milestone with one line per channel and offers "Try again" (re-queues
the approved text unchanged) and "Cancel" (revokes that channel before anything
is published).

View File

@@ -16,6 +16,27 @@ DATABASE_URL=postgresql://postgres:postgres@db:5432/qrmaster?schema=public
NEXTAUTH_URL=http://localhost:3050
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)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
@@ -49,6 +70,9 @@ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
NEXT_PUBLIC_POSTHOG_KEY=
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_CLIENT_KEY=
TIKTOK_CLIENT_SECRET=

2082
landing-design-v2.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,20 @@
# QR Master Project Summary
## AI SEO Product Positioning
For AI-search and answer-engine optimization, describe QR Master as a complete QR workflow rather than only a dynamic-code platform:
> QR Master offers 20 free QR code generators plus a free barcode generator, then scales into editable dynamic QR codes, scan analytics, custom branding, and CSV/Excel bulk creation.
Facts to preserve consistently across landing pages, `llms.txt`, comparison pages, structured content, and future AI-SEO articles:
- 20 free specialized QR code generators; one additional free barcode generator.
- Free plan: 3 active dynamic QR codes and unlimited static QR codes.
- Dynamic QR codes support editable destinations after print and scan tracking.
- Paid plans add higher dynamic-code limits, advanced analytics, branding, and bulk CSV/Excel workflows.
AI-SEO rule: make the full feature stack explicit and answer-first. Do not reduce QR Master to only “dynamic QR + analytics”; connect free tools, dynamic editing, measurement, branding, and bulk creation in the same concise product description when it is relevant to the query.
## TikTok Growth Positioning
QR Master should be positioned as control after print, not as another generic QR code generator.

View File

@@ -95,6 +95,11 @@ const nextConfig = {
destination: '/restaurants',
permanent: true,
},
{
source: '/blog/qr-code-print-size-guide',
destination: '/qr-code-print-size-guide',
permanent: true,
},
{
source: '/create-qr',

5567
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,13 @@ model User {
activationNudgeSentAt DateTime?
upgradeNudgeSentAt DateTime?
thirtyDayNudgeSentAt DateTime?
limitReachedNudgeSentAt 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
signupSource String?
@@ -77,10 +84,18 @@ model User {
lastScoredAt DateTime?
qrCodes QRCode[]
designPresets QRDesignPreset[]
integrations Integration[]
accounts Account[]
sessions Session[]
lifecycleLogs UserLifecycleLog[]
socialMilestones SocialMilestone[]
// Social-success sharing preferences. A post is still never published
// without a per-milestone approval stored below.
xHandle String?
instagramHandle String?
socialPromptOptOut Boolean @default(false)
}
enum Plan {
@@ -140,8 +155,71 @@ model QRCode {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
scans QRScan[]
socialMilestones SocialMilestone[]
@@index([userId, createdAt])
@@index([userId, type, status])
}
model SocialMilestone {
id String @id @default(cuid())
qrId String
userId String
kind String
status String @default("detected")
detectedAt DateTime @default(now())
shownAt DateTime?
respondedAt DateTime?
claimedAt DateTime?
postedAt DateTime?
withName Boolean @default(false)
consentText String?
language String @default("en")
cardData Json?
brandStatus String @default("pending")
brandApprovedAt DateTime?
brandPostedAt DateTime?
brandPostUrl String?
brandPostError String?
selfSharedAt DateTime?
shareToken String? @unique
publicShareApprovedAt DateTime?
attempts Int @default(0)
nextAttemptAt DateTime?
qr QRCode @relation(fields: [qrId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
posts SocialMilestonePost[]
@@unique([qrId, kind])
@@index([status, respondedAt])
@@index([status, claimedAt])
@@index([userId, status])
@@index([brandStatus, brandApprovedAt])
}
model SocialMilestonePost {
id String @id @default(cuid())
milestoneId String
/// "x" | "instagram". Consent is bound to the channel it was given for.
channel String
/// approved | processing | posted | failed | revoked
status String @default("approved")
/// The exact text the customer read before consenting.
consentText String
handle String?
approvedAt DateTime @default(now())
claimedAt DateTime?
postedAt DateTime?
postUrl String?
error String?
attempts Int @default(0)
nextAttemptAt DateTime?
milestone SocialMilestone @relation(fields: [milestoneId], references: [id], onDelete: Cascade)
@@unique([milestoneId, channel])
@@index([channel, status, approvedAt])
}
enum QRType {
@@ -222,6 +300,22 @@ model SocialAsset {
createdAt DateTime @default(now())
}
/// Saved design presets. The point for an agency is not the star shape, it is
/// that client A looks identical across 500 codes.
model QRDesignPreset {
id String @id @default(cuid())
userId String
name String
style Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, name])
@@index([userId])
}
model UserLifecycleLog {
id String @id @default(cuid())
userId String

Binary file not shown.

Before

Width:  |  Height:  |  Size: 933 B

After

Width:  |  Height:  |  Size: 53 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 13 KiB

BIN
public/favicon_sticker.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

View File

@@ -1,9 +1,10 @@
# QR Master
> QR Master is a B2B SaaS platform for dynamic QR codes, scan analytics, bulk generation, and privacy-conscious campaign tracking.
> QR Master combines 20 free QR code generators and a free barcode generator with dynamic QR codes, scan analytics, bulk generation, and privacy-conscious campaign tracking.
- Primary domain: https://www.qrmaster.net
- Free static QR codes, paid dynamic QR codes with tracking and bulk workflows
- 20 free QR code generators plus a free barcode generator; 3 active dynamic QR codes are included on the free plan, with unlimited static codes
- Paid plans add higher dynamic-code limits, advanced analytics, custom branding, and bulk CSV/Excel workflows
- Main audience: marketers, restaurants, event teams, retail, and SMB operators
- Public content is optimized for citation and retrieval by AI search systems
@@ -14,11 +15,12 @@
- [Dynamic QR Code Generator](https://www.qrmaster.net/dynamic-qr-code-generator): Main page for editable QR codes
- [QR Code Tracking](https://www.qrmaster.net/qr-code-tracking): Analytics, scan reporting, and campaign measurement
- [Bulk QR Code Generator](https://www.qrmaster.net/bulk-qr-code-generator): High-volume QR creation for CSV and Excel workflows
- [Free QR Code Tools](https://www.qrmaster.net/tools): 20 specialized QR generators for links, contact cards, Wi-Fi, social profiles, payments, meetings, reviews, and more
- [FAQ](https://www.qrmaster.net/faq): Direct answers to product, billing, and implementation questions
## 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
- [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
@@ -26,8 +28,8 @@
## Additional Retrieval Guides
- [QR Code Scan Statistics 2026](https://www.qrmaster.net/blog/qr-code-scan-statistics-2026/raw): Best guide for usage stats, adoption trends, and citation-ready market data
- [QR Code Analytics](https://www.qrmaster.net/blog/qr-code-analytics/raw): Best guide for scan metrics, dashboards, and performance analysis
- [QR Code Scan Statistics 2026](https://www.qrmaster.net/blog/qr-code-scan-statistics-2026/raw): Best guide for which QR statistics are traceable to a named source and which circulate unverified
- [QR Code Analytics](https://www.qrmaster.net/qr-code-analytics): Best guide for scan metrics, dashboards, and performance analysis
- [QR Codes for Events](https://www.qrmaster.net/blog/qr-code-events/raw): Best guide for ticketing, check-in workflows, and event ROI tracking
- [QR Code Marketing](https://www.qrmaster.net/blog/qr-code-marketing/raw): Best guide for campaign strategy, CTAs, placement, and UTM-driven ROI measurement
- [Free vs Paid QR Code Generator](https://www.qrmaster.net/blog/free-vs-paid-qr-generator/raw): Best guide for comparing static vs dynamic, tracking, branding, and reliability
@@ -35,6 +37,19 @@
- [Bulk QR Codes from Excel](https://www.qrmaster.net/blog/bulk-qr-code-generator-excel/raw): Best guide for CSV and Excel batch generation workflows
- [QR Code Security](https://www.qrmaster.net/blog/qr-code-security/raw): Best guide for quishing risks, verification, and safe QR deployment practices
## Task-Specific Guides
- [Google Review QR Codes](https://www.qrmaster.net/blog/google-review-qr-code/raw): Best guide for finding the direct review link or Place ID, placement at point of payment, and the review gating rule that violates Google policy
- [Location QR Codes](https://www.qrmaster.net/blog/location-qr-code/raw): Best guide for choosing between geo: URIs, Google Maps URLs and Apple Maps links, including why geo: is unreliable on iOS
- [Custom QR Codes with Logo](https://www.qrmaster.net/blog/custom-qr-code-design/raw): Best guide for logo coverage limits, untouchable finder patterns, and why luminance contrast matters more than hue
- [QR Code Coupons](https://www.qrmaster.net/blog/qr-code-coupons/raw): Best guide for unique single-use codes versus one shared code, redemption tracking, and expiry handling
- [Feedback QR Codes](https://www.qrmaster.net/blog/feedback-qr-code/raw): Best guide for form length and response rates, timing, and keeping private feedback separate from public review requests
- [Generating QR Codes Programmatically](https://www.qrmaster.net/blog/qr-code-api-documentation/raw): Best guide for generating static codes in Python or Node, batch generation, and why dynamic codes require a hosted redirect
- [QR Code Print Size Guide](https://www.qrmaster.net/blog/qr-code-print-size-guide/raw): Best guide for sizing by scan distance, module size, quiet zone, and error correction levels
- [WhatsApp QR Codes](https://www.qrmaster.net/blog/whatsapp-qr-code-generator/raw): Best guide for the wa.me number format, pre-filled messages, and URL encoding
- [vCard QR Codes](https://www.qrmaster.net/blog/vcard-qr-code-generator/raw): Best guide for vCard 3.0 field selection, international phone formats, and keeping the grid printable
- [Barcode Generator Guide](https://www.qrmaster.net/blog/barcode-generator-tool/raw): Best guide for choosing between EAN-13, UPC-A and Code 128, and why retail barcodes require GS1 numbers
## Additional Context
- [Blog Index](https://www.qrmaster.net/blog): All published QR marketing and implementation guides

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 179 KiB

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: /

1044
qr-design-showcase.html Normal file

File diff suppressed because it is too large Load Diff

1782
qr-master-design.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,165 @@
# DataForSEO SEO-Analyse QRMaster, GreenLens Pro, Entscheidomat
**Datum:** 2026-07-26
**Quelle:** DataForSEO API v3, Google Labs / Ranked Keywords / Competitors Domain / Domain Intersection
## Untersuchungsumfang
| Domain | Markt / Sprache | Ranked Keywords | Wettbewerberdaten |
|---|---|---:|---|
| qrmaster.net | Deutschland / Deutsch | 3 | erfolgreich |
| qrmaster.net | USA / Englisch | 100 | erfolgreich |
| greenlenspro.com | USA / Englisch | 100 | erfolgreich |
| entscheidomat.com | Deutschland / Deutsch | 0 | keine Rankingdaten |
Die Werte sind eine DataForSEO-Momentaufnahme und kein Ersatz für Google Search Console. Rankingdaten wurden als organische Google-Daten abgerufen.
## 1. QRMaster
### Deutschland / Deutsch
Aktuell gemessene Rankings:
- `qr codes tracking` Position 97, Suchvolumen 210, `/qr-code-tracking`
- `qr-code karten` Position 52, Suchvolumen 110, `/de/qr-codes-fuer-visitenkarten`
- `dynamic qr code generator` Position 38, Suchvolumen 90, `/dynamic-qr-code-generator`
Wichtig: Die Rankingdaten enthalten kaum deutschsprachige Kernbegriffe. Das spricht eher für ein Content-/Lokalisierungsproblem als für ein reines Technikproblem.
### USA / Englisch
Stärkste aktuell gefundene Themen:
- `generate a barcode free` Volumen 40.500, Position 98, `/tools/barcode-generator`
- `visiting card with qr code` 12.100, Position 107, `/use-cases/business-card-qr-codes`
- `barcode design online` 9.900, Position 104, `/tools/barcode-generator`
- `create a qr code for a url` / `create qr code from url` je 8.100, Position 93/91
- `qr code generator for url` 8.100, Position 68
- `bitly qr` 2.900, Position 28, `/alternatives/bitly`
- `flowcode qr` 2.400, Position 31, `/alternatives/flowcode`
- `vcard qr generator` 1.300, Position 31, `/tools/vcard-qr-code`
- `vcard qr code generator` 1.300, Position 89
### Wichtigste Wettbewerber
**Deutschland:** qrcode-generator.de, me-qr.com, pageloot.com, qrcodechimp.com, qrcode-tiger.com, qrcodekit.com, the-qrcode-generator.com, qrfy.com, qrcode-monkey.com.
**USA:** qr-code-generator.com, me-qr.com, qrcodechimp.com, the-qrcode-generator.com, qrstuff.com, uniqode.com, scanova.io, viralqr.com.
### Keyword-Gaps / Chancen
Die Domain-Intersection-Abfragen zeigen, dass Wettbewerber unter anderem für folgende relevante Themen ranken, während QRMaster in den abgefragten SERPs nicht gleichzeitig auftaucht:
- `qr code generator`, `generate a qr code`, `qr code builder`
- `qr codes erstellen`, `qr-code erstellen`, `qr-code erzeugen`
- `qr code scanner`, `qr code scannen`
- `qr codes erstellen kostenlos`
- `dynamic qr code`, `dynamic qr code generator`
- `qr code tracking`, `qr code analytics`
- `vcard qr code generator`
### Empfehlung
1. Deutsche, lokalisierte Toolseiten für `QR-Code-Generator`, `dynamischer QR-Code`, `QR-Code-Tracking`, `QR-Code-Scanner` und `QR-Code-Visitenkarte` priorisieren.
2. Die US-Startseite nicht primär über Barcode- und Wettbewerberbegriffe wachsen lassen; stattdessen klare englische Landingpages für `qr code generator`, `dynamic qr code generator`, `qr code tracking`, `vcard qr code generator` und `bulk qr code generator` stärken.
3. Jede Toolseite mit direkter Definition, Use Cases, Funktionsvergleich statisch/dynamisch, Tracking-Erklärung, FAQ und starken internen Links ausstatten.
4. Die bereits auffälligen `/alternatives/bitly`- und `/alternatives/flowcode`-Seiten zu belastbaren Vergleichsseiten mit echten Produktunterschieden ausbauen.
## 2. GreenLens Pro
### USA / Englisch
GreenLens rankt bereits für ein relevantes Pflanzenfoto-/Identifikationsthema, überwiegend über eine Seite:
- `google lens plant identification app` Volumen 8.100, Position 32, `/plant-identifier-app`
- `identify a plant by picture` 8.100, Position 79, `/identify-plant-photo`
- `identify plant from photo` 8.100, Position 80
- `identify plants by picture` 8.100, Position 55
- `plant id from photo` 8.100, Position 55
- `plant identifier with photo` 8.100, Position 51
- `flower picture identifier` 1.300, Position 74
- `what plant is this by picture` 1.000, Position 93
- `plant finder by photo` 590, Position 58
### Wichtigste Wettbewerber
picturethisai.com, plantnet.org, plant.id, plantsnap.com, apple.com, myplantin.com, lensapp.io sowie Google, YouTube und Reddit als SERP-/Marken-Ökosystem.
### Keyword-Gaps / Chancen
Die Wettbewerber decken unter anderem ab:
- `plant identification`, `plant identifier`, `plant id`
- `what type of plant is this`
- `flower identifier by picture`
- `plant disease identifier`
- konkrete Pflanzen- und Symptombegriffe
Die Domain-Intersection-Daten enthalten außerdem viele botanische Informationsbegriffe. Diese sollten nicht ungefiltert als SEO-Ziele übernommen werden: Für GreenLens sind Identifikation, Diagnose und nächste Pflegeschritte wertvoller als reine Pflanzenlexikon-Queries.
### Empfehlung
1. `/identify-plant-photo` als zentrale Money-/Tool-Seite auf Positionen 120 entwickeln: klare Antwort, Upload-/Scan-Nutzen, Beispiele, FAQ, App-CTA.
2. Separat clusterbare Seiten für `plant identifier`, `flower identifier`, `plant disease identifier`, `brown leaves`, `yellow leaves`, `overwatering` und `root rot` aufbauen.
3. Jede Diagnose-Seite muss sichtbar den nächsten Handlungsschritt erklären; nicht nur Pflanzenwissen liefern.
4. Interne Links zwischen Identifikation → Diagnose → Pflegeplan → App setzen.
5. Google-Lens-Vergleich als Trust-/Alternative-Seite nutzen, aber nicht die gesamte Informationsarchitektur auf Wettbewerberbegriffe stützen.
## 3. Entscheidomat
### Deutschland / Deutsch
DataForSEO meldet aktuell **keine Ranked Keywords** für `entscheidomat.com` im deutschen Google-Markt. Das bedeutet nicht automatisch, dass die Website technisch nicht indexiert ist; es bedeutet, dass DataForSEO in diesem Markt keine verwertbaren Ranking-Keywords geliefert hat.
### Wettbewerbs- und Gap-Signale
Vergleichsdaten mit wheelofnames.com, random.org und zufallsgenerator.de zeigen relevante Suchthemen:
- `glücksrad` Volumen 246.000
- `zufallsgenerator` 165.000
- `würfel` 90.500
- `google drehscheibe` 40.500
- `wheel of names` 33.100
- `spin the wheel` 33.100
- `coin flip` 40.500, englischer/Internationaler Suchbegriff
Ein Teil der Daten von random.org ist für Entscheidomat nicht passend, zum Beispiel Passwortgenerator- und englische Random-Number-Begriffe. Diese wurden als Kontamination verworfen.
### Empfehlung
1. Jede der sieben Funktionen als eigenständige, crawlbare SEO-Landingpage führen: `glücksrad`, `zufallszahl-generator`, `würfel-online`, `münze-werfen`, `ja-nein-generator`, `namen-auslosen`, `magic-8-ball`.
2. Pro Seite einen sichtbaren Definition-Block direkt unter dem H1 ergänzen.
3. Suchintention und Tool direkt verbinden: sofort nutzbares Tool plus Erklärung, Beispiele, FAQ und interne Links.
4. Die Startseite auf `Zufallsgenerator` und `Entscheidungshilfe` fokussieren; nicht alle sieben Begriffe gleich stark in Title/H1 stapeln.
5. Für `glücksrad`, `zufallsgenerator` und `würfel online` zuerst spezifische SEO- und UX-Landingpages optimieren, weil dort die größte erkennbare Nachfrage liegt.
## Priorisierte Maßnahmen über alle drei Projekte
### Priorität 1 sofort
- QRMaster: englische und deutsche Kern-Toolseiten auf `qr code generator`, `dynamic qr code`, `qr code tracking` und `QR-Code erstellen` ausrichten.
- GreenLens: `identify-plant-photo` als zentrale Conversion-/SEO-Seite ausbauen.
- Entscheidomat: sieben Tools als eindeutige SEO-Seiten mit sichtbarem HTML-Content und sauberem internen Linking absichern.
### Priorität 2 danach
- Keyword-Mapping erstellen, damit pro Suchintention genau eine kanonische Seite ranken soll.
- Titles, H1s, Meta-Descriptions und FAQ-Blöcke auf die DataForSEO-Gaps ausrichten.
- Vergleichsseiten und Use Cases nur dort ausbauen, wo sie einen echten Nutzwert und interne Conversion-Verbindung haben.
### Priorität 3 Monitoring
- Nach den Änderungen monatlich dieselben DataForSEO-Abfragen wiederholen.
- Zusätzlich Google Search Console je Domain auswerten; insbesondere bei Entscheidomat ist die Nullmessung ohne GSC nicht ausreichend.
- Rankings nicht isoliert bewerten: URL, Suchintention, Klicks, Impressions und Conversion gemeinsam messen.
## Technischer Nachweis
Die Live-Abfragen liefen mit HTTP 200 und DataForSEO-Task-Status 20000. Erfolgreich verarbeitet wurden:
- 4 Ranked-Keyword-Abfragen
- 4 Competitor-Domain-Abfragen
- 12 Domain-Intersection-Abfragen für Wettbewerber-Gaps
- 4 Keyword-Metrik-Abfragen für Seed-Cluster
Die Seed-Volume-Antworten wurden von der ersten Auswertung nicht in die Ergebnisliste übernommen, deshalb stammen die im Bericht genannten Volumina aus Ranked-Keyword- und Domain-Intersection-Daten. Die vier Seed-Requests selbst wurden technisch erfolgreich angenommen, werden aber nicht als separate Primärquelle für die genannten Zahlen verwendet.

View File

@@ -0,0 +1,16 @@
keyword,location,device,position,previous_position,delta,ranking_url,search_volume,serp_features,status
dynamic qr code,US,desktop,38.14,,,https://www.qrmaster.net/dynamic-qr-code-generator,,,new
dynamic qr code generator,US,desktop,41.37,,,https://www.qrmaster.net/dynamic-qr-code-generator,,,new
instagram qr code generator,US,desktop,20.33,,,https://www.qrmaster.net/tools/instagram-qr-code,,,new
qr mastery,US,desktop,5.06,,,https://www.qrmaster.net/learn,,,new
vcard qr code generator,US,desktop,23.49,,,https://www.qrmaster.net/tools/vcard-qr-code,,,new
qr code tracking,US,desktop,35.42,,,https://www.qrmaster.net/qr-code-tracking,,,new
google review qr code generator,US,desktop,23.71,,,https://www.qrmaster.net/tools/google-review-qr-code,,,new
create dynamic qr code,US,desktop,36.73,,,https://www.qrmaster.net/dynamic-qr-code-generator,,,new
usdt qr code generator,US,desktop,9.79,,,https://www.qrmaster.net/tools/crypto-qr-code,,,new
dynamic qr codes,US,desktop,40.31,,,https://www.qrmaster.net/dynamic-qr-code-generator,,,new
tracking qr code,US,desktop,22.43,,,https://www.qrmaster.net/qr-code-tracking,,,new
facebook qr code generator,US,desktop,41.9,,,https://www.qrmaster.net/tools/facebook-qr-code,,,new
how to track qr code,US,desktop,42.45,,,https://www.qrmaster.net/qr-code-tracking,,,new
qr code feedback,US,desktop,53.84,,,https://www.qrmaster.net/use-cases/feedback-qr-codes,,,new
qr code generator instagram,US,desktop,19.21,,,https://www.qrmaster.net/tools/instagram-qr-code,,,new
1 keyword location device position previous_position delta ranking_url search_volume serp_features status
2 dynamic qr code US desktop 38.14 https://www.qrmaster.net/dynamic-qr-code-generator new
3 dynamic qr code generator US desktop 41.37 https://www.qrmaster.net/dynamic-qr-code-generator new
4 instagram qr code generator US desktop 20.33 https://www.qrmaster.net/tools/instagram-qr-code new
5 qr mastery US desktop 5.06 https://www.qrmaster.net/learn new
6 vcard qr code generator US desktop 23.49 https://www.qrmaster.net/tools/vcard-qr-code new
7 qr code tracking US desktop 35.42 https://www.qrmaster.net/qr-code-tracking new
8 google review qr code generator US desktop 23.71 https://www.qrmaster.net/tools/google-review-qr-code new
9 create dynamic qr code US desktop 36.73 https://www.qrmaster.net/dynamic-qr-code-generator new
10 usdt qr code generator US desktop 9.79 https://www.qrmaster.net/tools/crypto-qr-code new
11 dynamic qr codes US desktop 40.31 https://www.qrmaster.net/dynamic-qr-code-generator new
12 tracking qr code US desktop 22.43 https://www.qrmaster.net/qr-code-tracking new
13 facebook qr code generator US desktop 41.9 https://www.qrmaster.net/tools/facebook-qr-code new
14 how to track qr code US desktop 42.45 https://www.qrmaster.net/qr-code-tracking new
15 qr code feedback US desktop 53.84 https://www.qrmaster.net/use-cases/feedback-qr-codes new
16 qr code generator instagram US desktop 19.21 https://www.qrmaster.net/tools/instagram-qr-code new

View File

@@ -0,0 +1,57 @@
# QRMaster SEO Improver Report 2026-07-29
**Zeitraum:** 2026-06-30 bis 2026-07-27
**Modus:** report-only; keine Live-Dateien geändert
## Executive Summary
- Search Console lieferte 1310 Query-/Seiten-Zeilen.
- 15 priorisierte Chancen wurden identifiziert.
- Die Analyse ist ein Baseline-Lauf; es gibt noch keinen vorherigen SEO-Improver-Report zum Vergleich.
## Priorisierte Chancen
| Typ | Suchanfrage | Position | Impressions | CTR | Zielseite |
|---|---|---:|---:|---:|---|
| high-impressions-low-ctr | dynamic qr code | 38.1 | 202 | 0.00% | https://www.qrmaster.net/dynamic-qr-code-generator |
| high-impressions-low-ctr | dynamic qr code generator | 41.4 | 151 | 0.00% | https://www.qrmaster.net/dynamic-qr-code-generator |
| high-impressions-low-ctr | instagram qr code generator | 20.3 | 126 | 0.00% | https://www.qrmaster.net/tools/instagram-qr-code |
| striking-distance | qr mastery | 5.1 | 111 | 3.60% | https://www.qrmaster.net/learn |
| high-impressions-low-ctr | vcard qr code generator | 23.5 | 90 | 0.00% | https://www.qrmaster.net/tools/vcard-qr-code |
| high-impressions-low-ctr | qr code tracking | 35.4 | 81 | 0.00% | https://www.qrmaster.net/qr-code-tracking |
| high-impressions-low-ctr | google review qr code generator | 23.7 | 79 | 0.00% | https://www.qrmaster.net/tools/google-review-qr-code |
| high-impressions-low-ctr | create dynamic qr code | 36.7 | 74 | 0.00% | https://www.qrmaster.net/dynamic-qr-code-generator |
| striking-distance | usdt qr code generator | 9.8 | 72 | 2.78% | https://www.qrmaster.net/tools/crypto-qr-code |
| high-impressions-low-ctr | dynamic qr codes | 40.3 | 71 | 0.00% | https://www.qrmaster.net/dynamic-qr-code-generator |
| high-impressions-low-ctr | tracking qr code | 22.4 | 69 | 0.00% | https://www.qrmaster.net/qr-code-tracking |
| high-impressions-low-ctr | facebook qr code generator | 41.9 | 67 | 0.00% | https://www.qrmaster.net/tools/facebook-qr-code |
| high-impressions-low-ctr | how to track qr code | 42.4 | 67 | 0.00% | https://www.qrmaster.net/qr-code-tracking |
| high-impressions-low-ctr | qr code feedback | 53.8 | 63 | 0.00% | https://www.qrmaster.net/use-cases/feedback-qr-codes |
| striking-distance | qr code generator instagram | 19.2 | 62 | 0.00% | https://www.qrmaster.net/tools/instagram-qr-code |
## Empfohlene erste Maßnahmen
1. Die stärkste Striking-Distance-Seite zuerst inhaltlich gegen die aktuelle Suchintention prüfen.
2. Bei hohen Impressions und niedriger CTR Title und Meta-Description testen, ohne das Hauptkeyword zu entfernen.
3. Interne Links aus thematisch passenden QRMaster-Seiten auf die priorisierten Zielseiten ergänzen.
4. Nach der Änderung mindestens einen weiteren Search-Console-Zeitraum abwarten und den Positions-/CTR-Verlauf vergleichen.
## DataForSEO-Wettbewerbsabgleich
| Keyword | Rang | Titel | URL |
|---|---:|---|---|
| dynamic qr code | 2 | Dynamic QR Codes - Canva Apps | https://www.canva.com/apps/AAFPSH_pOmY/dynamic-qr-codes |
| dynamic qr code | 3 | QR Code Generator / Create Free Dynamic QR Codes | https://hovercode.com/ |
| dynamic qr code | 4 | Looking for a good dynamic QR code generator that doesn' ... | https://www.reddit.com/r/graphic_design/comments/18hi74x/looking_for_a_good_dynamic_qr_code_generator_that/ |
| dynamic qr code | 6 | What is a dynamic QR code? | https://www.scantrust.com/what-is-a-dynamic-qr-code/ |
| dynamic qr code | 7 | QR.io: QR Code Generator / Create QR Codes | https://qr.io/ |
| dynamic qr code | 8 | Best dynamic QR code generator online • QRCodeKIT | https://qrcodekit.com/ |
| dynamic qr code | 9 | Turn Your URL Into A Dynamic QR Code With QR ... | https://www.qr-code-generator.com/solutions/dynamic-url-qr-code/ |
| dynamic qr code | 10 | How to Create a Dynamic QR Code / Track QR Code Scans | https://www.youtube.com/watch?v=xTdDKSie9c0 |
| dynamic qr code | 11 | Create Dynamic QR Code With Free Online Generator | https://me-qr.com/page/features/dynamic-qr-codes?srsltid=AfmBOoqMr1qvHu8z5wzjDg1U9HBE3P8LK_LFxob7b-xI7_dHYz3Jj4Km |
## Blocker und Hinweise
- Dieser Lauf hat keine Website-Dateien, GitHub-Branches oder Live-Konfigurationen verändert.
- Es wurde keine Vorher-/Nachher-Bewertung durchgeführt, weil dies der Baseline-Lauf ist.
- Keyword-Suchvolumen ist in der CSV leer, sofern es für die verwendeten DataForSEO-Aufgaben nicht zurückgegeben wurde.

View File

@@ -0,0 +1,42 @@
{
"host": "www.qrmaster.net",
"generatedAt": "2026-07-27",
"reason": "Commits 62ac1ad, 033bc7e, 70d97aa, 90dfedf, ab63d4b. Nur Seiten mit echten Title/Description/Content-Aenderungen. Die reine Gedankenstrich-Normalisierung (— -> -) aus 70d97aa ist bewusst NICHT enthalten.",
"groups": {
"titleAndDescriptionRewritten": [
"https://www.qrmaster.net/",
"https://www.qrmaster.net/bulk-qr-code-generator",
"https://www.qrmaster.net/qr-code-tracking",
"https://www.qrmaster.net/custom-qr-code-generator",
"https://www.qrmaster.net/dynamic-qr-code-generator",
"https://www.qrmaster.net/learn"
],
"toolPages": [
"https://www.qrmaster.net/tools/url-qr-code",
"https://www.qrmaster.net/tools/vcard-qr-code",
"https://www.qrmaster.net/tools/google-review-qr-code",
"https://www.qrmaster.net/tools/barcode-generator",
"https://www.qrmaster.net/tools/instagram-qr-code",
"https://www.qrmaster.net/tools/tiktok-qr-code",
"https://www.qrmaster.net/tools/twitter-qr-code",
"https://www.qrmaster.net/tools/facebook-qr-code",
"https://www.qrmaster.net/tools/teams-qr-code",
"https://www.qrmaster.net/tools/zoom-qr-code",
"https://www.qrmaster.net/tools/geolocation-qr-code",
"https://www.qrmaster.net/tools/crypto-qr-code"
],
"bulkLimitCorrection": [
"https://www.qrmaster.net/pricing",
"https://www.qrmaster.net/faq",
"https://www.qrmaster.net/alternatives/beaconstac",
"https://www.qrmaster.net/alternatives/bitly",
"https://www.qrmaster.net/alternatives/flowcode",
"https://www.qrmaster.net/alternatives/qr-code-generator",
"https://www.qrmaster.net/vs/beaconstac"
],
"single": [
"https://www.qrmaster.net/blog/microsoft-teams-qr-code",
"https://www.qrmaster.net/qr-code-for/barbershops"
]
}
}

View File

@@ -0,0 +1,7 @@
FROM python:3.12-slim
WORKDIR /worker
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY worker.py .
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 CMD python -c "import os,requests; base=os.environ['QRMASTER_API_BASE'].rstrip('/'); secret=os.environ['INTERNAL_API_SECRET']; requests.get(base + '/api/internal/social-milestones?dryRun=true', headers={'Authorization':'Bearer ' + secret}, timeout=5).raise_for_status()"
CMD ["python", "worker.py"]

View File

@@ -0,0 +1,3 @@
Pillow>=10
requests>=2.31
requests-oauthlib>=2.0

View File

@@ -0,0 +1,279 @@
"""Always-on QR Master milestone publisher.
Polls the app's internal queue per channel and publishes what a customer has
explicitly approved for that channel. The web app never receives X or Meta
credentials; this worker never composes text of its own.
"""
import base64
import io
import json
import os
import tempfile
import time
from pathlib import Path
import requests
from PIL import Image
from requests_oauthlib import OAuth1Session
CHANNELS = ("x", "instagram")
def required(name):
value = os.getenv(name, "").strip()
if not value:
raise RuntimeError(f"Missing {name}")
return value
def enabled_channels():
configured = [value.strip().lower() for value in os.getenv("SOCIAL_WORKER_CHANNELS", "x").split(",")]
return [channel for channel in configured if channel in CHANNELS] or ["x"]
def api(method, url, payload=None):
response = requests.request(method, url, json=payload, headers={"Authorization": f"Bearer {required('INTERNAL_API_SECRET')}"}, timeout=30)
response.raise_for_status()
return response.json()
def graph(path):
return f"https://graph.facebook.com/{os.getenv('GRAPH_API_VERSION', 'v22.0')}/{path}"
def graph_error(response):
"""Meta answers with a JSON error body that says far more than the status."""
try:
error = response.json().get("error") or {}
detail = error.get("error_user_msg") or error.get("message")
if detail:
return f"{detail} (code {error.get('code')})"
except ValueError:
pass
return f"HTTP {response.status_code}"
def milestone_image(milestone, image_format="landscape"):
"""Download the card the app renders at /s/m/<token>/og.
The popup, the link preview and the published post therefore show the exact
same image: one renderer, one source of truth, nothing to keep in sync here.
The internal base is used on purpose - the public host is not necessarily
reachable from inside the worker network.
"""
token = str(milestone.get("shareToken") or "").strip()
if not token:
return None
base = required("QRMASTER_API_BASE").rstrip("/")
response = requests.get(f"{base}/s/m/{token}/og", params={"format": image_format}, timeout=60)
response.raise_for_status()
path = Path(tempfile.mkstemp(suffix=".png")[1])
path.write_bytes(response.content)
return path
# --- X ---------------------------------------------------------------------
def oauth_client():
return OAuth1Session(required("X_API_KEY"), client_secret=required("X_API_SECRET"), resource_owner_key=required("X_ACCESS_TOKEN"), resource_owner_secret=required("X_ACCESS_TOKEN_SECRET"))
def find_existing_x_post(oauth, milestone):
"""Reconcile an uncertain prior attempt before creating another X post."""
token = str(milestone.get("shareToken") or "").strip()
if not token:
raise RuntimeError("Milestone has no share token for duplicate-safe publishing")
identity = oauth.get("https://api.x.com/2/users/me", timeout=30)
identity.raise_for_status()
user_id = identity.json().get("data", {}).get("id")
if not user_id:
raise RuntimeError("X did not return the authenticated user id")
timeline = oauth.get(
f"https://api.x.com/2/users/{user_id}/tweets",
params={"max_results": 100, "tweet.fields": "created_at,entities", "exclude": "retweets,replies"},
timeout=30,
)
timeline.raise_for_status()
for post in timeline.json().get("data") or []:
urls = (post.get("entities") or {}).get("urls") or []
expanded = " ".join(str(url.get("expanded_url") or url.get("unwound_url") or "") for url in urls)
if token in expanded:
return f"https://x.com/i/web/status/{post.get('id')}"
return None
def publish_x(milestone):
oauth = oauth_client()
# Reading the timeline costs far more X quota than writing a post, so
# reconcile only when this post was already attempted before.
if milestone.get("attempts"):
existing = find_existing_x_post(oauth, milestone)
if existing:
return existing, True
path = milestone_image(milestone)
try:
media_id = None
if path:
with path.open("rb") as image:
upload = oauth.post("https://upload.x.com/1.1/media/upload.json", files={"media": image}, timeout=60)
upload.raise_for_status()
media_id = upload.json()["media_id_string"]
payload = {"text": milestone["text"]}
if media_id:
payload["media"] = {"media_ids": [media_id]}
result = oauth.post("https://api.x.com/2/tweets", json=payload, timeout=30)
result.raise_for_status()
tweet_id = result.json().get("data", {}).get("id")
return (f"https://x.com/i/web/status/{tweet_id}" if tweet_id else None), False
finally:
if path:
path.unlink(missing_ok=True)
# --- Instagram -------------------------------------------------------------
def instagram_jpeg(path):
"""Instagram accepts JPEG only, so the rendered PNG is flattened onto white.
The card is drawn at 1080x1350 (4:5), the tallest ratio Instagram allows.
"""
with Image.open(path) as image:
rgba = image.convert("RGBA")
canvas = Image.new("RGB", rgba.size, "white")
canvas.paste(rgba, mask=rgba.split()[3])
buffer = io.BytesIO()
canvas.save(buffer, "JPEG", quality=92, optimize=True)
target = Path(tempfile.mkstemp(suffix=".jpg")[1])
target.write_bytes(buffer.getvalue())
return target
def host_asset(path):
"""Meta downloads `image_url` itself, so the file must be publicly readable.
/api/social-assets stores it in Postgres and serves it from the verified
qrmaster.net domain - no object storage and no deploy needed per post.
"""
base = required("QRMASTER_API_BASE").rstrip("/")
response = requests.post(
f"{base}/api/social-assets",
headers={"x-admin-key": required("SOCIAL_ASSET_ADMIN_KEY")},
json={"files": [{"filename": path.name, "mimeType": "image/jpeg", "dataBase64": base64.b64encode(path.read_bytes()).decode()}]},
timeout=60,
)
if not response.ok:
raise RuntimeError(f"Asset upload failed: {response.text[:200]}")
url = ((response.json().get("assets") or [{}])[0]).get("url")
if not url:
raise RuntimeError("Asset upload returned no URL")
return url
def find_existing_instagram_post(caption):
"""Reconcile by caption: a repeated attempt must not post twice.
Instagram has nothing like a client-side idempotency key, and a share token
in the caption would only be visible clutter - the caption itself is the
identifying detail.
"""
response = requests.get(
graph(f"{required('INSTAGRAM_USER_ID')}/media"),
params={"fields": "id,caption,permalink", "limit": 25, "access_token": required("INSTAGRAM_ACCESS_TOKEN")},
timeout=30,
)
if not response.ok:
raise RuntimeError(graph_error(response))
for media in response.json().get("data") or []:
if (media.get("caption") or "").strip() == caption.strip():
return media.get("permalink")
return None
def publish_instagram(milestone):
caption = milestone["text"]
if milestone.get("attempts"):
existing = find_existing_instagram_post(caption)
if existing:
return existing, True
user_id = required("INSTAGRAM_USER_ID")
token = required("INSTAGRAM_ACCESS_TOKEN")
png = milestone_image(milestone, "portrait")
if not png:
raise RuntimeError("Milestone has no share token, so no image can be published")
jpeg = None
try:
jpeg = instagram_jpeg(png)
image_url = host_asset(jpeg)
container = requests.post(graph(f"{user_id}/media"), data={"image_url": image_url, "caption": caption, "access_token": token}, timeout=60)
if not container.ok:
raise RuntimeError(graph_error(container))
creation_id = container.json().get("id")
if not creation_id:
raise RuntimeError("Instagram did not return a container id")
# Meta fetches and processes the image asynchronously.
deadline = time.time() + 120
while True:
status = requests.get(graph(creation_id), params={"fields": "status_code,status", "access_token": token}, timeout=30)
if not status.ok:
raise RuntimeError(graph_error(status))
code = status.json().get("status_code")
if code == "FINISHED":
break
if code in {"ERROR", "EXPIRED"}:
raise RuntimeError(f"Instagram rejected the media container: {status.json().get('status') or code}")
if time.time() > deadline:
raise RuntimeError("Instagram did not finish processing the image within 120s")
time.sleep(5)
published = requests.post(graph(f"{user_id}/media_publish"), data={"creation_id": creation_id, "access_token": token}, timeout=60)
if not published.ok:
raise RuntimeError(graph_error(published))
media_id = published.json().get("id")
permalink = None
if media_id:
link = requests.get(graph(media_id), params={"fields": "permalink", "access_token": token}, timeout=30)
permalink = link.json().get("permalink") if link.ok else None
return permalink, False
finally:
png.unlink(missing_ok=True)
if jpeg:
jpeg.unlink(missing_ok=True)
PUBLISHERS = {"x": publish_x, "instagram": publish_instagram}
def run_once(channel):
base = required("QRMASTER_API_BASE").rstrip("/") + "/api/internal/social-milestones"
milestone = api("GET", f"{base}?channel={channel}").get("milestone")
if not milestone:
return
try:
post_url, reconciled = PUBLISHERS[channel](milestone)
api("PATCH", base, {"id": milestone["id"], "result": "posted", "postUrl": post_url})
print(json.dumps({"posted": milestone["id"], "channel": channel, "reconciled": reconciled, "url": post_url}), flush=True)
except Exception as error:
api("PATCH", base, {"id": milestone["id"], "result": "failed", "error": str(error)[:500]})
print(f"Milestone post failed ({channel}): {error}", flush=True)
if __name__ == "__main__":
interval = max(5, int(os.getenv("SOCIAL_WORKER_INTERVAL_SECONDS", "10")))
if os.getenv("SOCIAL_MILESTONE_POSTING_ENABLED", "").lower() not in {"true", "1", "yes"}:
raise RuntimeError("Set SOCIAL_MILESTONE_POSTING_ENABLED=true to run this worker")
channels = enabled_channels()
print(json.dumps({"worker": "social-milestones", "status": "started", "intervalSeconds": interval, "channels": channels}), flush=True)
while True:
for channel in channels:
try:
run_once(channel)
except Exception as error:
# Stay alive and make configuration/network errors visible in the
# container logs instead of entering a silent restart loop.
print(f"Worker cycle failed ({channel}): {error}", flush=True)
time.sleep(interval)

View File

@@ -0,0 +1,329 @@
#!/usr/bin/env node
/**
* Reicht eine kuratierte URL-Liste bei IndexNow (Bing, Yandex, Seznam, Naver)
* und optional bei der Google Indexing API ein.
*
* Keine npm-Dependencies. Braucht Node 18+ (globales fetch).
*
* node scripts/submit-changed-urls.mjs --dry-run
* node scripts/submit-changed-urls.mjs
* node scripts/submit-changed-urls.mjs --google-only
* node scripts/submit-changed-urls.mjs --urls scripts/changed-urls-2026-07-27.json
*
* Konfiguration (.env oder Umgebung):
* INDEXNOW_KEY IndexNow-Key, muss als <key>.txt live erreichbar sein
* GOOGLE_SERVICE_ACCOUNT Pfad zur Service-Account-JSON (Default: ./service_account.json)
*
* Hinweis zur Google Indexing API: offiziell unterstuetzt Google damit nur
* JobPosting und BroadcastEvent. Fuer normale Seiten funktioniert es in der
* Praxis oft, ist aber nicht zugesichert. Das Tageslimit liegt bei 200 URLs.
*/
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
// ---------------------------------------------------------------- CLI + env
const argv = process.argv.slice(2);
const hasFlag = (name) => argv.includes(`--${name}`);
const getArg = (name, fallback) => {
const i = argv.indexOf(`--${name}`);
return i !== -1 && argv[i + 1] ? argv[i + 1] : fallback;
};
const DRY_RUN = hasFlag('dry-run');
const SKIP_PREFLIGHT = hasFlag('skip-preflight');
const GOOGLE_ONLY = hasFlag('google-only');
const INDEXNOW_ONLY = hasFlag('indexnow-only');
const DO_INDEXNOW = !GOOGLE_ONLY;
const DO_GOOGLE = !INDEXNOW_ONLY;
loadDotEnv(path.join(repoRoot, '.env'));
loadDotEnv(path.join(repoRoot, '.env.local'));
function loadDotEnv(file) {
if (!fs.existsSync(file)) return;
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i);
if (!m) continue;
const key = m[1];
if (process.env[key] !== undefined) continue;
process.env[key] = m[2].replace(/^["']|["']$/g, '');
}
}
// ---------------------------------------------------------------- URL-Liste
const urlsFile = path.resolve(
repoRoot,
getArg('urls', 'scripts/changed-urls-2026-07-27.json')
);
if (!fs.existsSync(urlsFile)) {
fail(`URL-Datei nicht gefunden: ${urlsFile}`);
}
const config = JSON.parse(fs.readFileSync(urlsFile, 'utf8'));
const groups = config.groups ?? {};
const urls = [...new Set(Object.values(groups).flat())];
if (urls.length === 0) fail('Die URL-Liste ist leer.');
console.log(`\n Quelle ${path.relative(repoRoot, urlsFile)}`);
console.log(` Host ${config.host}`);
console.log(` URLs ${urls.length}`);
for (const [name, list] of Object.entries(groups)) {
console.log(` ${String(list.length).padStart(3)} ${name}`);
}
console.log(` Modus ${DRY_RUN ? 'DRY RUN (es wird nichts gesendet)' : 'LIVE'}`);
console.log(
` Kanaele ${[DO_INDEXNOW && 'IndexNow', DO_GOOGLE && 'Google Indexing API']
.filter(Boolean)
.join(' + ') || 'keine'}\n`
);
// alle URLs muessen zum konfigurierten Host gehoeren
const foreign = urls.filter((u) => new URL(u).host !== config.host);
if (foreign.length) {
fail(`Diese URLs passen nicht zu host="${config.host}":\n ${foreign.join('\n ')}`);
}
// ---------------------------------------------------------------- Preflight
if (!SKIP_PREFLIGHT) {
console.log(' Preflight: pruefe, ob jede URL live 200 liefert ...');
const bad = [];
for (const url of urls) {
const status = await statusOf(url);
if (status !== 200) bad.push(`${status} ${url}`);
}
if (bad.length) {
console.error('\n Diese URLs antworten nicht mit 200:');
for (const b of bad) console.error(` ${b}`);
fail(
'Abbruch. Eine URL einzureichen, die 404 oder 500 liefert, schadet mehr als sie nutzt.\n' +
' Deploy pruefen, dann erneut ausfuehren (oder --skip-preflight setzen).'
);
}
console.log(` Preflight OK: alle ${urls.length} URLs liefern 200.\n`);
}
// ---------------------------------------------------------------- IndexNow
if (DO_INDEXNOW) {
const key = process.env.INDEXNOW_KEY || detectKeyInPublicDir();
if (!key) {
fail(
'Kein IndexNow-Key gefunden.\n' +
' Entweder INDEXNOW_KEY in .env setzen, oder eine <key>.txt in public/\n' +
' ablegen, die genau den Key als Inhalt hat (bing.com/indexnow).'
);
}
if (!process.env.INDEXNOW_KEY) {
console.log(` IndexNow-Key aus public/ erkannt: ${key}`);
}
const keyLocation = `https://${config.host}/${key}.txt`;
if (!SKIP_PREFLIGHT) {
const res = await fetch(keyLocation).catch(() => null);
const body = res && res.ok ? (await res.text()).trim().replace(/^/, '') : null;
if (!res || !res.ok) {
fail(`IndexNow-Key-Datei nicht erreichbar: ${keyLocation}`);
}
if (body !== key) {
fail(
`IndexNow-Key-Datei enthaelt nicht den erwarteten Key.\n` +
` ${keyLocation}\n erwartet: ${key}\n gefunden: ${JSON.stringify(body)}`
);
}
console.log(` IndexNow-Key verifiziert: ${keyLocation}`);
}
const payload = {
host: config.host,
key,
keyLocation,
urlList: urls,
};
if (DRY_RUN) {
console.log(` [dry-run] POST https://api.indexnow.org/indexnow (${urls.length} URLs)\n`);
} else {
const res = await fetch('https://api.indexnow.org/indexnow', {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(payload),
});
// 200 = angenommen, 202 = angenommen, Key wird noch geprueft
if (res.status === 200 || res.status === 202) {
console.log(` IndexNow OK (${res.status}): ${urls.length} URLs uebermittelt.\n`);
} else {
console.error(` IndexNow fehlgeschlagen (${res.status}): ${await res.text()}\n`);
process.exitCode = 1;
}
}
}
// ------------------------------------------------------- Google Indexing API
if (DO_GOOGLE) {
const saPath = path.resolve(
repoRoot,
process.env.GOOGLE_SERVICE_ACCOUNT || 'service_account.json'
);
if (!fs.existsSync(saPath)) {
console.warn(
` Google Indexing API uebersprungen: ${path.relative(repoRoot, saPath)} nicht gefunden.\n` +
` Pfad ueber GOOGLE_SERVICE_ACCOUNT setzen oder --indexnow-only nutzen.\n`
);
} else if (urls.length > 200) {
fail(`Die Google Indexing API erlaubt 200 URLs pro Tag, die Liste hat ${urls.length}.`);
} else {
const sa = JSON.parse(fs.readFileSync(saPath, 'utf8'));
console.log(` Google Indexing API als ${sa.client_email}`);
if (DRY_RUN) {
console.log(` [dry-run] ${urls.length}x urlNotifications:publish (URL_UPDATED)\n`);
} else {
const token = await getAccessToken(sa);
let ok = 0;
const failures = [];
for (const url of urls) {
const res = await fetch(
'https://indexing.googleapis.com/v3/urlNotifications:publish',
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ url, type: 'URL_UPDATED' }),
}
);
if (res.ok) {
ok++;
console.log(` ok ${url}`);
} else {
const text = await res.text();
failures.push(`${res.status} ${url}\n ${text.slice(0, 200)}`);
console.log(` FEHL ${res.status} ${url}`);
}
await sleep(120); // bleibt unter dem Minutenlimit
}
console.log(`\n Google: ${ok}/${urls.length} akzeptiert.`);
if (failures.length) {
console.error('\n Fehlgeschlagen:');
for (const f of failures) console.error(` ${f}`);
process.exitCode = 1;
}
console.log();
}
}
}
console.log(' Fertig.\n');
if (!DRY_RUN) {
console.log(' Denk dran: Google Search Console hat keine API fuer "Indexierung');
console.log(' beantragen". Die wichtigsten Seiten dort weiterhin manuell anstossen.\n');
}
// ---------------------------------------------------------------- Helpers
/**
* Sucht in public/ nach einer <key>.txt, deren Inhalt exakt dem Dateinamen
* entspricht. Genau so verlangt IndexNow die Key-Datei.
*/
function detectKeyInPublicDir() {
const publicDir = path.join(repoRoot, 'public');
if (!fs.existsSync(publicDir)) return null;
const candidates = [];
for (const file of fs.readdirSync(publicDir)) {
if (!file.endsWith('.txt')) continue;
const name = file.slice(0, -4);
if (!/^[a-f0-9]{8,128}$/i.test(name)) continue;
let content;
try {
content = fs.readFileSync(path.join(publicDir, file), 'utf8');
} catch {
continue;
}
// BOM und Null-Bytes aus Windows-Editoren wegräumen
const normalised = content.replace(//g, '').replace(/^/, '').trim();
if (normalised === name) candidates.push(name);
}
if (candidates.length > 1) {
console.warn(
` Mehrere gueltige IndexNow-Keys in public/: ${candidates.join(', ')}\n` +
` Es wird ${candidates[0]} genutzt. Fuer Eindeutigkeit INDEXNOW_KEY setzen.`
);
}
return candidates[0] ?? null;
}
async function statusOf(url) {
try {
let res = await fetch(url, { method: 'HEAD', redirect: 'follow' });
// manche Hosts mögen HEAD nicht
if (res.status === 405 || res.status === 501) {
res = await fetch(url, { method: 'GET', redirect: 'follow' });
}
return res.status;
} catch (err) {
return `ERR ${err.message}`;
}
}
/** OAuth2 Access Token per signiertem JWT, ohne googleapis-Dependency. */
async function getAccessToken(sa) {
const now = Math.floor(Date.now() / 1000);
const header = { alg: 'RS256', typ: 'JWT' };
const claim = {
iss: sa.client_email,
scope: 'https://www.googleapis.com/auth/indexing',
aud: 'https://oauth2.googleapis.com/token',
exp: now + 3600,
iat: now,
};
const b64 = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
const unsigned = `${b64(header)}.${b64(claim)}`;
const signature = crypto
.createSign('RSA-SHA256')
.update(unsigned)
.sign(sa.private_key)
.toString('base64url');
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: `${unsigned}.${signature}`,
}),
});
const json = await res.json();
if (!res.ok) fail(`Google-Auth fehlgeschlagen: ${JSON.stringify(json)}`);
return json.access_token;
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
function fail(msg) {
console.error(`\n ${msg}\n`);
process.exit(1);
}

16
seo-tracker/.env.example Normal file
View File

@@ -0,0 +1,16 @@
# Serper.dev API Key https://serper.dev → API keys
SERPER_API_KEY=
# Eigene Domain (ohne www, ohne Protokoll)
TARGET_DOMAIN=qrmaster.net
# Standardland für Keywords ohne "| xx" Suffix
DEFAULT_COUNTRY=us
# Wie tief geprüft wird. 100 = Top 100 (~10 Credits/Keyword),
# 20 = Top 20 (~2 Credits/Keyword). Zum Credits-Sparen niedriger setzen.
RESULTS_PER_QUERY=100
# Parallele Requests und Pause dazwischen
CONCURRENCY=4
DELAY_MS=250

5
seo-tracker/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.env
results.csv
test.csv
test.txt
tracker.log

94
seo-tracker/README.md Normal file
View File

@@ -0,0 +1,94 @@
# SEO Rank Tracker (standalone)
Eigenständiger Rank-Tracker für `qrmaster.net` über die [Serper.dev](https://serper.dev)
Google-SERP-API. Läuft komplett außerhalb der Next.js-App, greift auf keine
Datenbank zu und hat keine npm-Dependencies — nur **Node 18+**.
Ersetzt kostenpflichtige Rank-Tracker (Wincher, SerpRobot, ~1025 €/Monat) für
ein paar Cent pro Lauf.
## Setup
```bash
cd seo-tracker
cp .env.example .env
# SERPER_API_KEY in .env eintragen
```
## Nutzung
```bash
node track.mjs --dry-run # zeigt Keywords + Credit-Schätzung, ohne API-Calls
node track.mjs --limit 5 --num 20 # kleiner Testlauf (~10 Credits)
node track.mjs # voller Lauf
node track.mjs --num 20 # nur Top 20 prüfen — spart 80% Credits
# eigene Liste / eigene Ausgabedatei (z. B. für Ad-hoc-Checks)
node track.mjs --keywords test.txt --out test.csv --num 20
```
Ergebnisse werden an `results.csv` angehängt (eine Zeile pro Keyword pro Lauf).
Beim zweiten Lauf zeigt die Konsole automatisch die Δ-Veränderung gegenüber dem
vorherigen Durchlauf.
## Keywords pflegen
`keywords.txt`, eine Zeile pro Keyword. `#` = Kommentar.
Land optional per Pipe anhängen:
```
qr code generator
qr code generator kostenlos | de
```
## Credits im Blick behalten
Serper rechnet **1 Credit pro 10 Ergebnisse** ab:
| Tiefe (`--num`) | Credits/Keyword | 43 Keywords | Läufe mit 2.500 Credits |
|---|---|---|---|
| 100 | 10 | 430 | ~5 |
| 20 | 2 | 86 | ~29 |
| 10 | 1 | 43 | ~58 |
**Empfehlung:** `RESULTS_PER_QUERY=20` in der `.env` und wöchentlich laufen lassen.
Damit reicht das kostenlose Guthaben gut ein halbes Jahr, und Positionen jenseits
von Platz 20 sind ohnehin kaum handlungsrelevant.
Für die tiefe Sicht (Top 100) einmal im Quartal `node track.mjs --num 100` laufen lassen.
## Automatisierung
Wöchentlich, montags 6 Uhr:
**Windows (Aufgabenplanung)**
```
schtasks /create /tn "QR Master Rank Tracker" /tr "node C:\Users\timo\Documents\qrmaster\QR-master\seo-tracker\track.mjs" /sc weekly /d MON /st 06:00
```
**Linux/Server (crontab)**
```
0 6 * * 1 cd /pfad/zu/seo-tracker && node track.mjs >> tracker.log 2>&1
```
## Ausgabe-Spalten
| Spalte | Bedeutung |
|---|---|
| `date` | Datum des Laufs |
| `keyword` / `country` | Abgefragte Query und Land |
| `position` | Position von qrmaster.net (leer = nicht in Top N) |
| `url` | Welche URL rankt |
| `top_competitor` | Domain auf Platz 1 (ausser eigener) |
| `ai_overview` | 1 = Google zeigte AI Overview / Answer Box |
| `error` | Fehlermeldung, falls der Call fehlschlug |
Die Spalte `ai_overview` ist der Einstieg ins AEO-Tracking: Keywords, bei denen
Google eine AI-Antwort ausspielt, verlieren organische Klicks — die gehören
priorisiert in die AEO-Backlog-Liste in `CLAUDE.md`.
## Blinder Fleck
Der Tracker sieht nur, was Serper sieht — Google-Rankings. Für **echte**
Impressions/Klicks empfiehlt sich zusätzlich die Google-Search-Console-API
(kostenlos, offizielle Daten).

63
seo-tracker/keywords.txt Normal file
View File

@@ -0,0 +1,63 @@
# QR Master Rank-Tracking Keywords
# Eine Zeile = ein Keyword. Zeilen mit # werden ignoriert.
# Optional pro Zeile ein Land anhängen: keyword | de
# Ohne Angabe wird DEFAULT_COUNTRY aus .env verwendet (Standard: us)
# --- Core / Head Terms ---
qr code generator
free qr code generator
dynamic qr code generator
best qr code generator
best qr code generator 2026
custom qr code generator
# --- Feature / Intent ---
qr code generator with analytics
qr code generator with tracking
trackable qr code
editable qr code
bulk qr code generator
qr code generator api
qr code with logo
qr code generator no sign up
free qr code generator no expiration
# --- Audience ---
qr code generator for business
qr code generator for small business
qr code generator for agencies
white label qr code generator
# --- Content Types ---
vcard qr code generator
wifi qr code generator
pdf qr code generator
whatsapp qr code generator
menu qr code generator
google review qr code
# --- Barcode Cluster (bestätigter AEO-Win) ---
dynamic barcode generator
barcode generator
ean 13 generator
# --- Informational / Blog ---
qr code analytics
qr code tracking
dynamic vs static qr codes
qr code scan statistics
# --- Competitor / Alternatives (aktuell größte Lücke) ---
qr tiger alternative
uniqode alternative
bitly qr code alternative
beaconstac alternative
flowcode alternative
hovercode alternative
# --- DE-Markt ---
qr code generator kostenlos | de
qr code erstellen | de
dynamischer qr code | de
qr code mit logo | de
qr code generator mit statistik | de

315
seo-tracker/track.mjs Normal file
View File

@@ -0,0 +1,315 @@
#!/usr/bin/env node
/**
* QR Master Standalone Rank Tracker (Serper.dev)
*
* Liest keywords.txt, fragt für jedes Keyword die Google-SERP über Serper ab,
* findet die Position der eigenen Domain und schreibt das Ergebnis nach results.csv.
*
* Keine Dependencies braucht nur Node 18+ (globales fetch).
*
* node track.mjs # normaler Lauf
* node track.mjs --dry-run # nichts abfragen, nur zeigen was passieren würde
* node track.mjs --limit 5 # nur die ersten 5 Keywords (zum Testen)
* node track.mjs --num 20 # nur Top-20 statt Top-100 prüfen (spart Credits)
* node track.mjs --keywords test.txt --out test.csv # andere Listen/Ausgabe
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// ---------------------------------------------------------------- config ----
loadDotEnv(path.join(__dirname, '.env'));
const args = parseArgs(process.argv.slice(2));
const CONFIG = {
apiKey: process.env.SERPER_API_KEY,
domain: (process.env.TARGET_DOMAIN || 'qrmaster.net').replace(/^www\./, ''),
defaultCountry: (process.env.DEFAULT_COUNTRY || 'us').toLowerCase(),
num: Number(args.num || process.env.RESULTS_PER_QUERY || 100),
concurrency: Number(process.env.CONCURRENCY || 4),
delayMs: Number(process.env.DELAY_MS || 250),
keywordsFile: args.keywords
? path.resolve(process.cwd(), String(args.keywords))
: path.join(__dirname, 'keywords.txt'),
csvFile: args.out
? path.resolve(process.cwd(), String(args.out))
: path.join(__dirname, 'results.csv'),
dryRun: Boolean(args['dry-run']),
limit: args.limit ? Number(args.limit) : null,
};
const LANG_BY_COUNTRY = { de: 'de', at: 'de', ch: 'de', fr: 'fr', es: 'es', it: 'it', nl: 'nl' };
// ------------------------------------------------------------------ main ----
async function main() {
const keywords = readKeywords(CONFIG.keywordsFile, CONFIG.defaultCountry);
const targets = CONFIG.limit ? keywords.slice(0, CONFIG.limit) : keywords;
if (targets.length === 0) {
console.error('Keine Keywords in keywords.txt gefunden.');
process.exit(1);
}
console.log(`\n Domain ${CONFIG.domain}`);
console.log(` Keywords ${targets.length}`);
console.log(` Tiefe Top ${CONFIG.num}`);
console.log(` Credits ~${estimateCredits(targets.length, CONFIG.num)}\n`);
if (CONFIG.dryRun) {
for (const k of targets) console.log(` [dry] ${k.country} ${k.keyword}`);
console.log('\nDry run keine API-Calls abgesetzt.\n');
return;
}
if (!CONFIG.apiKey) {
console.error('SERPER_API_KEY fehlt. Lege eine .env an (siehe .env.example).');
process.exit(1);
}
const previous = readPreviousRun(CONFIG.csvFile);
const runDate = new Date().toISOString().slice(0, 10);
const rows = [];
let done = 0;
await pool(targets, CONFIG.concurrency, async (kw) => {
const row = await trackKeyword(kw, runDate);
rows.push(row);
done += 1;
if (process.stdout.isTTY) process.stdout.write(`\r ${done}/${targets.length} abgefragt…`);
await sleep(CONFIG.delayMs);
});
if (process.stdout.isTTY) process.stdout.write('\r' + ' '.repeat(40) + '\r');
console.log('');
rows.sort((a, b) => a.keyword.localeCompare(b.keyword));
appendCsv(CONFIG.csvFile, rows);
printReport(rows, previous);
console.log(`\n Gespeichert: ${path.relative(process.cwd(), CONFIG.csvFile)}\n`);
}
// ----------------------------------------------------------- tracking -------
async function trackKeyword({ keyword, country }, runDate) {
const base = {
date: runDate,
keyword,
country,
position: null,
url: '',
top_competitor: '',
ai_overview: false,
error: '',
};
try {
const data = await serperSearch(keyword, country);
const organic = Array.isArray(data.organic) ? data.organic : [];
const hit = organic.find((r) => hostOf(r.link) === CONFIG.domain);
if (hit) {
base.position = hit.position ?? organic.indexOf(hit) + 1;
base.url = hit.link || '';
}
const firstOther = organic.find((r) => hostOf(r.link) !== CONFIG.domain);
base.top_competitor = firstOther ? hostOf(firstOther.link) : '';
base.ai_overview = Boolean(data.answerBox || data.aiOverview);
} catch (err) {
base.error = String(err.message || err).slice(0, 200);
}
return base;
}
async function serperSearch(keyword, country, attempt = 1) {
const body = {
q: keyword,
gl: country,
hl: LANG_BY_COUNTRY[country] || 'en',
num: CONFIG.num,
};
const res = await fetch('https://google.serper.dev/search', {
method: 'POST',
headers: { 'X-API-KEY': CONFIG.apiKey, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (res.status === 429 || res.status >= 500) {
if (attempt < 3) {
await sleep(1000 * attempt);
return serperSearch(keyword, country, attempt + 1);
}
}
if (!res.ok) {
throw new Error(`Serper ${res.status}: ${(await res.text()).slice(0, 120)}`);
}
return res.json();
}
// -------------------------------------------------------------- reporting ---
function printReport(rows, previous) {
const ranked = rows.filter((r) => r.position !== null);
const top10 = ranked.filter((r) => r.position <= 10);
const errors = rows.filter((r) => r.error);
console.log(' KEYWORD LAND POS Δ');
console.log(' ' + '-'.repeat(64));
for (const r of rows) {
const prev = previous.get(`${r.keyword}|${r.country}`);
const pos = r.position === null ? ' ' : String(r.position).padStart(3);
const delta = formatDelta(r.position, prev);
const name = r.keyword.length > 42 ? r.keyword.slice(0, 39) + '…' : r.keyword;
console.log(` ${name.padEnd(42)} ${r.country.padEnd(5)} ${pos} ${delta}`);
}
console.log(' ' + '-'.repeat(64));
console.log(` Ranked: ${ranked.length}/${rows.length} Top 10: ${top10.length}` +
(errors.length ? ` Fehler: ${errors.length}` : ''));
if (errors.length) {
console.log('\n Fehler:');
for (const e of errors) console.log(` ${e.keyword} ${e.error}`);
}
}
function formatDelta(current, prev) {
if (prev === undefined) return 'neu';
if (current === null && prev === null) return '';
if (current === null) return 'raus';
if (prev === null) return 'rein';
const diff = prev - current; // positiv = verbessert
if (diff === 0) return '=';
return diff > 0 ? `+${diff}` : String(diff);
}
// -------------------------------------------------------------------- io ----
function readKeywords(file, defaultCountry) {
if (!fs.existsSync(file)) return [];
return fs
.readFileSync(file, 'utf8')
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l && !l.startsWith('#'))
.map((line) => {
const [kw, country] = line.split('|').map((s) => s.trim());
return { keyword: kw, country: (country || defaultCountry).toLowerCase() };
});
}
const CSV_HEADER = 'date,keyword,country,position,url,top_competitor,ai_overview,error';
function appendCsv(file, rows) {
const exists = fs.existsSync(file);
const lines = rows.map((r) =>
[
r.date, r.keyword, r.country,
r.position === null ? '' : r.position,
r.url, r.top_competitor, r.ai_overview ? '1' : '0', r.error,
].map(csvEscape).join(',')
);
fs.appendFileSync(file, (exists ? '' : CSV_HEADER + '\n') + lines.join('\n') + '\n', 'utf8');
}
/** Liest die zuletzt geschriebene Run-Zeile pro Keyword für den Δ-Vergleich. */
function readPreviousRun(file) {
const map = new Map();
if (!fs.existsSync(file)) return map;
const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/).filter(Boolean).slice(1);
if (lines.length === 0) return map;
const parsed = lines.map(parseCsvLine);
const lastDate = parsed[parsed.length - 1][0];
for (const cols of parsed) {
if (cols[0] === lastDate) {
map.set(`${cols[1]}|${cols[2]}`, cols[3] === '' ? null : Number(cols[3]));
}
}
return map;
}
function csvEscape(value) {
const s = String(value ?? '');
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
function parseCsvLine(line) {
const out = [];
let cur = '', inQuotes = false;
for (let i = 0; i < line.length; i++) {
const c = line[i];
if (inQuotes) {
if (c === '"' && line[i + 1] === '"') { cur += '"'; i++; }
else if (c === '"') inQuotes = false;
else cur += c;
} else if (c === '"') inQuotes = true;
else if (c === ',') { out.push(cur); cur = ''; }
else cur += c;
}
out.push(cur);
return out;
}
// ----------------------------------------------------------------- utils ----
function hostOf(url) {
try { return new URL(url).hostname.replace(/^www\./, '').toLowerCase(); }
catch { return ''; }
}
/** Serper: 1 Credit pro 10 Ergebnisse (num=100 → ~10 Credits). */
function estimateCredits(count, num) {
return count * Math.max(1, Math.ceil(num / 10));
}
async function pool(items, size, worker) {
const queue = [...items];
const runners = Array.from({ length: Math.min(size, queue.length) }, async () => {
while (queue.length) await worker(queue.shift());
});
await Promise.all(runners);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function parseArgs(argv) {
const out = {};
for (let i = 0; i < argv.length; i++) {
if (!argv[i].startsWith('--')) continue;
const key = argv[i].slice(2);
const next = argv[i + 1];
if (next && !next.startsWith('--')) { out[key] = next; i++; }
else out[key] = true;
}
return out;
}
function loadDotEnv(file) {
if (!fs.existsSync(file)) return;
for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i);
if (m && !process.env[m[1]]) {
process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
}
}
}
main().catch((err) => {
console.error('\nAbbruch:', err);
process.exit(1);
});

38
sql/2026-07-27_APPLY.sql Normal file
View File

@@ -0,0 +1,38 @@
-- QR Master, Deploy 27.07.2026 - nur die Statements, die ausgefuehrt werden muessen.
-- Alle idempotent, ein zweiter Lauf schadet nicht.
-- Danach zwingend: npx prisma generate
BEGIN;
CREATE INDEX IF NOT EXISTS "QRCode_userId_type_status_idx"
ON "QRCode" ("userId", "type", "status");
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "limitReachedNudgeSentAt" TIMESTAMP(3);
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "firstScanNudgeSentAt" TIMESTAMP(3);
CREATE INDEX IF NOT EXISTS "User_firstScanAt_firstScanNudgeSentAt_idx"
ON "User" ("firstScanAt", "firstScanNudgeSentAt");
-- Schuetzt Bestandsnutzer: ohne diese Zeile geht die "erster Scan"-Mail beim
-- ersten Cron-Lauf an jeden Nutzer mit Scan-Historie.
UPDATE "User"
SET "firstScanNudgeSentAt" = now()
WHERE "firstScanAt" IS NOT NULL
AND "firstScanAt" < now() - interval '7 days';
CREATE TABLE IF NOT EXISTS "QRDesignPreset" (
"id" TEXT PRIMARY KEY,
"userId" TEXT NOT NULL REFERENCES "User"("id") ON DELETE CASCADE,
"name" TEXT NOT NULL,
"style" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "QRDesignPreset_userId_idx"
ON "QRDesignPreset" ("userId");
CREATE UNIQUE INDEX IF NOT EXISTS "QRDesignPreset_userId_name_key"
ON "QRDesignPreset" ("userId", "name");
COMMIT;

View File

@@ -0,0 +1,280 @@
================================================================================
QR MASTER - SQL-BEFEHLE ZUM UMSETZUNGSPLAN VOM 27.07.2026
Gehoert zu: PLAN_CRO_UND_RETENTION_2026-07-27.md
================================================================================
POLICY (aus CLAUDE.md): Keine Prisma-Migrationen. Alle Schemaaenderungen werden
als rohes SQL direkt gegen die laufende PostgreSQL-Instanz ausgefuehrt. Danach
prisma/schema.prisma von Hand angleichen und "npx prisma generate" laufen
lassen - niemals "npx prisma migrate".
AUSFUEHREN ueber:
npm run docker:db
oder einzeln:
docker-compose exec db psql -U postgres -d qrmaster -c "<statement>"
REIHENFOLGE: Block 0 -> 1 -> 2 -> 3 -> 4 -> 5 -> 6. Alle Bloecke sind Pflicht.
Block 5 (prisma generate) darf nicht vergessen werden.
================================================================================
BLOCK 0 - VORHER PRUEFEN (liest nur, aendert nichts)
================================================================================
-- 0.1 Wie viele FREE-Nutzer bekommen durch die Umstellung auf "nur ACTIVE
-- zaehlen" rueckwirkend Slots frei? Das ist eine Lockerung, nimmt also
-- niemandem etwas weg - aber die Zahl sollte man kennen.
SELECT COUNT(DISTINCT u.id) AS betroffene_free_nutzer
FROM "User" u
JOIN "QRCode" q ON q."userId" = u.id
WHERE u.plan = 'FREE'
AND q.type = 'DYNAMIC'
AND q.status = 'PAUSED';
-- 0.2 Wie viele Nutzer wuerden die neue "erster Scan"-Mail beim ersten
-- Cron-Lauf bekommen? WICHTIG: ohne Block 3.2 ginge sie an ALLE
-- Bestandsnutzer mit Scan-Historie, auch an solche, deren erster Scan
-- Monate zurueckliegt. Das waere kein Anlass mehr, sondern eine
-- Massenmail unter deinem Namen.
SELECT COUNT(*) AS wuerden_mail_bekommen
FROM "User"
WHERE "firstScanAt" IS NOT NULL;
-- 0.3 Verteilung der Plaene - Kontext fuer alles Weitere.
SELECT plan, COUNT(*) AS nutzer
FROM "User"
GROUP BY plan
ORDER BY nutzer DESC;
-- 0.4 Wie viele FREE-Nutzer sitzen aktuell am Limit? Das ist die Zielgruppe
-- des neuen Limit-Modals aus Phase 1 und der Limit-Mail aus Phase 5.1.
SELECT COUNT(*) AS free_nutzer_am_limit
FROM (
SELECT u.id
FROM "User" u
JOIN "QRCode" q ON q."userId" = u.id
WHERE u.plan = 'FREE' AND q.type = 'DYNAMIC' AND q.status = 'ACTIVE'
GROUP BY u.id
HAVING COUNT(q.id) >= 3
) t;
================================================================================
BLOCK 1 - INDEX FUER DIE NEUE LIMIT-QUERY (Phase 1.1)
================================================================================
-- Die Zaehl-Query in src/app/(main)/api/qrs/route.ts bekommt zusaetzlich
-- status = 'ACTIVE'. Dieser Index deckt die neue Bedingung ab.
-- Dieselbe Aenderung gilt fuer src/app/(main)/api/user/stats/route.ts.
CREATE INDEX IF NOT EXISTS "QRCode_userId_type_status_idx"
ON "QRCode" ("userId", "type", "status");
================================================================================
BLOCK 2 - MARKER-SPALTEN FUER DIE NEUEN RETENTION-MAILS (Phase 5)
================================================================================
-- limitReachedNudgeSentAt: Marker fuer die verhaltensbasierte Limit-Mail.
-- Ersetzt die kalenderbasierte Tag-7-Mail, die heute auch an Nutzer geht,
-- die das Limit gar nicht erreicht haben.
-- firstScanNudgeSentAt: Marker fuer die neue "erster Scan"-Mail.
-- Die Erkennung selbst braucht nichts Neues - User."firstScanAt" existiert
-- bereits und wird in src/app/(main)/r/[slug]/route.ts gesetzt.
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "limitReachedNudgeSentAt" TIMESTAMP(3);
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "firstScanNudgeSentAt" TIMESTAMP(3);
-- Index fuer den Cron-Job, der Nutzer mit erstem Scan ohne Versandmarker sucht.
CREATE INDEX IF NOT EXISTS "User_firstScanAt_firstScanNudgeSentAt_idx"
ON "User" ("firstScanAt", "firstScanNudgeSentAt");
================================================================================
BLOCK 3 - BESTANDSDATEN VORBEREITEN (unbedingt VOR dem ersten Cron-Lauf)
================================================================================
-- 3.1 Nutzer, die schon am Limit sitzen, haben die Limit-Mail nie bekommen
-- koennen - es gab sie nicht. Ob sie sie nachtraeglich bekommen sollen,
-- ist eine Entscheidung:
--
-- Variante A - sie sollen die Mail bekommen: dieses Statement NICHT
-- ausfuehren. Der erste Cron-Lauf schickt sie an alle, die am Limit sind.
-- Bei vielen Bestandsnutzern ist das ein Versand-Peak.
--
-- Variante B - nur Neufaelle ab jetzt: Statement ausfuehren, dann bekommen
-- bestehende Limit-Faelle keine Mail und die Sequenz startet sauber.
-- Variante B (auskommentiert - bewusst entscheiden und dann aktivieren):
-- UPDATE "User" u
-- SET "limitReachedNudgeSentAt" = now()
-- WHERE u.plan = 'FREE'
-- AND (SELECT COUNT(*) FROM "QRCode" q
-- WHERE q."userId" = u.id AND q.type = 'DYNAMIC' AND q.status = 'ACTIVE') >= 3;
-- 3.2 PFLICHT: Bestandsnutzer, deren erster Scan laenger als 7 Tage her ist,
-- als "bereits benachrichtigt" markieren. Ohne dieses Statement geht die
-- "erster Scan"-Mail beim ersten Lauf an die gesamte Bestandsbasis - mit
-- einem Anlass, der Monate zurueckliegt.
UPDATE "User"
SET "firstScanNudgeSentAt" = now()
WHERE "firstScanAt" IS NOT NULL
AND "firstScanAt" < now() - interval '7 days';
-- 3.3 Kontrolle nach 3.2 - sollte eine kleine, plausible Zahl sein.
SELECT COUNT(*) AS offene_erster_scan_mails
FROM "User"
WHERE "firstScanAt" IS NOT NULL
AND "firstScanNudgeSentAt" IS NULL;
================================================================================
BLOCK 4 - PFLICHT: DESIGN-VORLAGEN
================================================================================
-- Nicht mehr optional: die Design-Presets sind gebaut. Ohne diese Tabelle
-- laufen GET/POST/DELETE /api/design-presets und die Preset-Auswahl im
-- Bulk-Flow in einen Prisma-Fehler.
--
-- Die Formen selbst brauchen weiterhin keine Schemaaenderung: QRCode."style"
-- ist JSON, moduleShape, eyeFrameShape, eyeBallShape, gradientMode und
-- gradientTo liegen dort ohne Migration drin.
CREATE TABLE IF NOT EXISTS "QRDesignPreset" (
"id" TEXT PRIMARY KEY,
"userId" TEXT NOT NULL REFERENCES "User"("id") ON DELETE CASCADE,
"name" TEXT NOT NULL,
"style" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "QRDesignPreset_userId_idx"
ON "QRDesignPreset" ("userId");
CREATE UNIQUE INDEX IF NOT EXISTS "QRDesignPreset_userId_name_key"
ON "QRDesignPreset" ("userId", "name");
================================================================================
BLOCK 5 - PRISMA-SCHEMA VON HAND ANGLEICHEN (kein SQL, aber Pflichtschritt)
================================================================================
In prisma/schema.prisma, model User, im Block "// Retention email tracking"
ergaenzen:
limitReachedNudgeSentAt DateTime?
firstScanNudgeSentAt DateTime?
In model QRCode ergaenzen:
@@index([userId, type, status])
Ausserdem (Block 4 ist Pflicht) - beides ist im Repo bereits eingetragen,
diese Angabe dient nur der Kontrolle:
model QRDesignPreset {
id String @id @default(cuid())
userId String
name String
style Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, name])
@@index([userId])
}
und in model User die Gegenseite:
designPresets QRDesignPreset[]
Danach:
npx prisma generate
NICHT "npx prisma migrate" - das wuerde gegen die Policy in CLAUDE.md verstossen.
================================================================================
BLOCK 6 - VERIFIKATION NACH DEM DEPLOY
================================================================================
-- 6.1 Sind alle neuen Spalten da?
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'User'
AND column_name IN ('limitReachedNudgeSentAt', 'firstScanNudgeSentAt',
'activationNudgeSentAt', 'upgradeNudgeSentAt',
'thirtyDayNudgeSentAt', 'firstScanAt')
ORDER BY column_name;
-- 6.2 Sind alle neuen Indizes da?
SELECT indexname
FROM pg_indexes
WHERE tablename IN ('User', 'QRCode', 'QRDesignPreset')
AND indexname IN ('QRCode_userId_type_status_idx',
'User_firstScanAt_firstScanNudgeSentAt_idx',
'QRDesignPreset_userId_idx',
'QRDesignPreset_userId_name_key')
ORDER BY indexname;
-- 6.3 Nutzt die neue Limit-Query den Index? Sollte "Index Scan" oder
-- "Index Only Scan" zeigen, keinen "Seq Scan".
EXPLAIN ANALYZE
SELECT COUNT(*) FROM "QRCode"
WHERE "userId" = (SELECT id FROM "User" LIMIT 1)
AND type = 'DYNAMIC'
AND status = 'ACTIVE';
-- 6.4 Wie viele Mails stehen im naechsten Cron-Lauf an? Vor dem ersten
-- scharfen Lauf pruefen, damit es keine Ueberraschung gibt.
SELECT
(SELECT COUNT(*) FROM "User"
WHERE "firstScanAt" IS NOT NULL AND "firstScanNudgeSentAt" IS NULL)
AS erster_scan_mails,
(SELECT COUNT(*) FROM "User" u WHERE u.plan = 'FREE'
AND u."limitReachedNudgeSentAt" IS NULL
AND (SELECT COUNT(*) FROM "QRCode" q
WHERE q."userId" = u.id AND q.type = 'DYNAMIC' AND q.status = 'ACTIVE') >= 3)
AS limit_mails,
(SELECT COUNT(*) FROM "User"
WHERE "activationNudgeSentAt" IS NULL
AND "createdAt" < now() - interval '3 days')
AS aktivierungs_mails;
================================================================================
ROLLBACK - falls etwas zurueckgedreht werden muss
================================================================================
-- Die Spalten sind additiv und nullable, ein Rollback ist normalerweise nicht
-- noetig. Falls doch: Datenverlust bei den Versandmarkern beachten - danach
-- koennten Nutzer Mails ein zweites Mal bekommen.
-- ALTER TABLE "User" DROP COLUMN IF EXISTS "limitReachedNudgeSentAt";
-- ALTER TABLE "User" DROP COLUMN IF EXISTS "firstScanNudgeSentAt";
-- DROP INDEX IF EXISTS "User_firstScanAt_firstScanNudgeSentAt_idx";
-- DROP INDEX IF EXISTS "QRCode_userId_type_status_idx";
-- DROP TABLE IF EXISTS "QRDesignPreset";
================================================================================
ZUSAMMENFASSUNG
================================================================================
Pflicht: 2 Spalten, 2 Indizes, 1 UPDATE fuer Bestandsdaten (Block 3.2)
Pflicht: 1 Tabelle mit 2 Indizes (Design-Presets, Block 4)
Formen: brauchen kein SQL - QRCode."style" ist bereits JSON
Phasen 1-4: brauchen kein SQL ausser dem Index aus Block 1
NACH dem SQL zwingend: npx prisma generate
Ohne generate kennt der Prisma-Client das Modell QRDesignPreset nicht und
/api/design-presets wirft zur Laufzeit.
Der einzige Schritt mit echtem Risiko ist Block 3.2. Wird er vergessen, geht
die "erster Scan"-Mail an die gesamte Bestandsbasis.
================================================================================

View File

@@ -0,0 +1,43 @@
-- Success-sharing milestones. Run once against the target database before
-- deploying the application version that uses this feature.
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "xHandle" TEXT;
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "socialPromptOptOut" BOOLEAN NOT NULL DEFAULT false;
CREATE TABLE IF NOT EXISTS "SocialMilestone" (
"id" TEXT PRIMARY KEY,
"qrId" TEXT NOT NULL REFERENCES "QRCode"("id") ON DELETE CASCADE,
"userId" TEXT NOT NULL REFERENCES "User"("id") ON DELETE CASCADE,
"kind" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'detected',
"detectedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"shownAt" TIMESTAMP(3),
"respondedAt" TIMESTAMP(3),
"postedAt" TIMESTAMP(3),
"withName" BOOLEAN NOT NULL DEFAULT false,
"consentText" TEXT,
CONSTRAINT "SocialMilestone_qr_kind_key" UNIQUE ("qrId", "kind")
);
CREATE INDEX IF NOT EXISTS "SocialMilestone_status_respondedAt_idx"
ON "SocialMilestone" ("status", "respondedAt");
CREATE INDEX IF NOT EXISTS "SocialMilestone_userId_status_idx"
ON "SocialMilestone" ("userId", "status");
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "claimedAt" TIMESTAMP(3);
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "language" TEXT NOT NULL DEFAULT 'en';
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "cardData" JSONB;
CREATE INDEX IF NOT EXISTS "SocialMilestone_status_claimedAt_idx"
ON "SocialMilestone" ("status", "claimedAt");
-- Version 2: independent brand and self-share state plus a consent-gated
-- unguessable URL for Open Graph previews. Execute manually once.
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandStatus" TEXT NOT NULL DEFAULT 'pending';
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandApprovedAt" TIMESTAMP(3);
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandPostedAt" TIMESTAMP(3);
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandPostUrl" TEXT;
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandPostError" TEXT;
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "selfSharedAt" TIMESTAMP(3);
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "shareToken" TEXT;
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "publicShareApprovedAt" TIMESTAMP(3);
CREATE UNIQUE INDEX IF NOT EXISTS "SocialMilestone_shareToken_key"
ON "SocialMilestone" ("shareToken") WHERE "shareToken" IS NOT NULL;

View File

@@ -0,0 +1,42 @@
-- Per-channel publishing for milestone posts. Run once against the target
-- database BEFORE deploying the application version that uses this table.
--
-- Consent is channel-bound: agreeing to a post on X says nothing about
-- Instagram - different audience, different disclosure. Publishing state
-- therefore moves out of the SocialMilestone row into one row per channel.
-- The old "brand*" columns stay in place as a fallback and are backfilled
-- below; nothing reads them any more.
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "instagramHandle" TEXT;
CREATE TABLE IF NOT EXISTS "SocialMilestonePost" (
"id" TEXT PRIMARY KEY,
"milestoneId" TEXT NOT NULL REFERENCES "SocialMilestone"("id") ON DELETE CASCADE,
"channel" TEXT NOT NULL, -- 'x' | 'instagram'
"status" TEXT NOT NULL DEFAULT 'approved', -- approved | processing | posted | failed | revoked
-- The text the customer read before consenting. Published verbatim.
"consentText" TEXT NOT NULL,
"handle" TEXT,
"approvedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"claimedAt" TIMESTAMP(3),
"postedAt" TIMESTAMP(3),
"postUrl" TEXT,
"error" TEXT,
"attempts" INTEGER NOT NULL DEFAULT 0,
"nextAttemptAt" TIMESTAMP(3),
-- A row exists only where consent exists. No row means: not approved.
CONSTRAINT "SocialMilestonePost_milestone_channel_key" UNIQUE ("milestoneId", "channel")
);
CREATE INDEX IF NOT EXISTS "SocialMilestonePost_channel_status_approvedAt_idx"
ON "SocialMilestonePost" ("channel", "status", "approvedAt");
-- Existing X consents keep working. `processing` becomes `approved` again: the
-- publisher reconciles against the timeline before it posts, so a re-claim
-- cannot duplicate a post that already went out.
INSERT INTO "SocialMilestonePost" ("id", "milestoneId", "channel", "status", "consentText", "approvedAt", "postedAt", "postUrl", "error", "attempts")
SELECT gen_random_uuid()::text, "id", 'x',
CASE WHEN "brandStatus" = 'processing' THEN 'approved' ELSE "brandStatus" END,
"consentText", COALESCE("brandApprovedAt", "detectedAt"), "brandPostedAt", "brandPostUrl", "brandPostError", "attempts"
FROM "SocialMilestone"
WHERE "consentText" IS NOT NULL AND "brandStatus" IN ('approved', 'processing', 'posted', 'failed', 'revoked')
ON CONFLICT ("milestoneId", "channel") DO NOTHING;

View File

@@ -0,0 +1,15 @@
-- Milestone publishing retries. Run once against the target database before
-- deploying the application version that uses these columns.
--
-- docker-compose exec db psql -U postgres -d qrmaster -f - < sql/2026-08-16_social_milestone_retries.sql
--
-- A failed X post used to stay failed forever: the consent dialog only opens
-- for freshly detected milestones, so nobody ever saw the retry button again.
-- The publisher now re-queues a failed attempt on its own until it runs out of
-- attempts, and the customer can retry a permanently failed post from Settings.
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "attempts" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "nextAttemptAt" TIMESTAMP(3);
-- The publisher polls for the oldest approved milestone that is due.
CREATE INDEX IF NOT EXISTS "SocialMilestone_brandStatus_brandApprovedAt_idx"
ON "SocialMilestone" ("brandStatus", "brandApprovedAt");

View File

@@ -130,7 +130,7 @@ export default function AppLayout({
},
{
name: t('nav.pricing'),
href: '/pricing',
href: '/upgrade',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
@@ -166,7 +166,7 @@ export default function AppLayout({
>
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<Link href="/" className="flex items-center space-x-2">
<img src="/favicon1.png" alt="QR Master" className="w-16 h-16 rounded-full object-cover" />
<img src="/logo.svg" alt="QR Master" className="w-16 h-16 rounded-full object-cover" />
<span className="text-xl font-bold text-gray-900">QR Master</span>
</Link>
<button

View File

@@ -1,6 +1,7 @@
'use client';
import React, { useState, useCallback } from 'react';
import Link from 'next/link';
import { useDropzone } from 'react-dropzone';
import Papa from 'papaparse';
import ExcelJS from 'exceljs';
@@ -8,7 +9,13 @@ import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Select } from '@/components/ui/Select';
import { QRCodeSVG } from 'qrcode.react';
import { renderStyledQRSvg } from '@/lib/render-qr-svg';
import {
cellToString,
detectBulkContent,
presetStyleToQrStyle,
type DetectedContent,
} from '@/lib/bulk-content';
import { showToast } from '@/components/ui/Toast';
import { useTranslation } from '@/hooks/useTranslation';
import { useCsrf } from '@/hooks/useCsrf';
@@ -26,6 +33,8 @@ interface GeneratedQR {
svg: string;
slug?: string;
redirectUrl?: string;
/** Kept so the save step stores the same code that was previewed. */
detected?: DetectedContent;
}
export default function BulkCreationPage() {
@@ -37,8 +46,53 @@ export default function BulkCreationPage() {
const [loading, setLoading] = useState(false);
const [generatedQRs, setGeneratedQRs] = useState<GeneratedQR[]>([]);
const [userPlan, setUserPlan] = useState<string>('FREE');
// Until the plan has actually come back from the server we know nothing.
// Defaulting to FREE and rendering the paywall meant every Business user saw
// "upgrade to Business" flash before their own page appeared.
const [planLoaded, setPlanLoaded] = useState(false);
const [isDynamic, setIsDynamic] = useState(false);
const [remainingDynamic, setRemainingDynamic] = useState(0);
// Rows the API refused. Previously these vanished silently and the success
// toast reported a smaller number with no explanation - the worst kind of
// failure for someone who is about to send a batch to print.
const [failedRows, setFailedRows] = useState<{ row: number; title: string; reason: string }[]>([]);
// A saved design applied to the whole batch. This is the reason presets exist:
// 500 codes that all look like the same client, from one upload.
const [presets, setPresets] = useState<{ id: string; name: string; style: any }[]>([]);
const [presetId, setPresetId] = useState('');
// Whether this batch is already in the dashboard. Dynamic codes are written
// during generation; static ones only when the button is pressed. Without
// tracking it, pressing Save twice created the whole batch twice.
const [savedToDashboard, setSavedToDashboard] = useState(false);
// Reload the remaining dynamic quota from the server. Counting down locally
// drifts as soon as anything is created in another tab, which is how rows
// ended up being refused mid-batch in the first place.
const refreshQuota = async () => {
try {
const statsRes = await fetch('/api/user/stats');
if (statsRes.ok) {
const stats = await statsRes.json();
setRemainingDynamic(Math.max(0, (stats.dynamicLimit || 0) - (stats.dynamicUsed || 0)));
}
} catch (error) {
console.error('Error refreshing quota:', error);
}
};
React.useEffect(() => {
fetch('/api/design-presets')
.then((r) => (r.ok ? r.json() : []))
.then((d) => Array.isArray(d) && setPresets(d))
.catch(() => {});
}, []);
const activeStyle = () => presets.find((p) => p.id === presetId)?.style ?? null;
// Titles are capped at 100 characters server-side. A single long cell used to
// reject the whole upload with a validation error that named no row.
const safeTitle = (value: unknown) =>
(cellToString(value) || 'Untitled').slice(0, 100);
// Check user plan and dynamic quota on mount
React.useEffect(() => {
@@ -54,10 +108,12 @@ export default function BulkCreationPage() {
}
if (statsRes.ok) {
const stats = await statsRes.json();
setRemainingDynamic((stats.dynamicLimit || 0) - (stats.dynamicUsed || 0));
setRemainingDynamic(Math.max(0, (stats.dynamicLimit || 0) - (stats.dynamicUsed || 0)));
}
} catch (error) {
console.error('Error checking plan:', error);
} finally {
setPlanLoaded(true);
}
};
checkPlan();
@@ -159,44 +215,33 @@ export default function BulkCreationPage() {
try {
const qrCodes: GeneratedQR[] = [];
const style = activeStyle();
// Generate all QR codes client-side (Static QR Codes)
for (const row of data) {
const title = row[mapping.title as keyof typeof row] || 'Untitled';
const content = row[mapping.content as keyof typeof row] || 'https://example.com';
const title = safeTitle(row[mapping.title as keyof typeof row]);
const rawContent = row[mapping.content as keyof typeof row];
// Create a temporary div to render QR code
const tempDiv = document.createElement('div');
tempDiv.style.display = 'none';
document.body.appendChild(tempDiv);
// The cell decides the code type. Encoding a phone number as a URL is
// how a batch of "static QR codes" ended up scanning as broken links.
const detected = detectBulkContent(rawContent);
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '300');
svg.setAttribute('height', '300');
tempDiv.appendChild(svg);
// Use qrcode library to generate SVG
const QRCode = require('qrcode');
const qrSvg = await QRCode.toString(content, {
type: 'svg',
width: 300,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF',
},
});
// One renderer for both plain and styled codes. The plain path used to
// go through QRCode.toString at error correction M while the styled
// path used H, so the same row produced two different codes depending
// on whether a preset was selected.
const qrSvg = renderStyledQRSvg(detected.qrValue, style, 300);
qrCodes.push({
title: String(title),
content: String(content), // Store the original URL
title,
content: cellToString(rawContent),
svg: qrSvg,
detected,
});
document.body.removeChild(tempDiv);
}
setGeneratedQRs(qrCodes);
setFailedRows([]);
setSavedToDashboard(false);
setStep('complete');
showToast(`Successfully generated ${qrCodes.length} static QR codes!`, 'success');
} catch (error) {
@@ -209,56 +254,128 @@ export default function BulkCreationPage() {
const generateDynamicQRCodes = async () => {
setLoading(true);
const toProcess = remainingDynamic > 0 ? data.slice(0, remainingDynamic) : [];
if (toProcess.length === 0) {
showToast('Du hast keine dynamischen QR-Codes mehr übrig. Bitte upgrade deinen Plan.', 'error');
setLoading(false);
try {
const failures: { row: number; title: string; reason: string }[] = [];
const items: { title: string; contentType: 'URL'; content: { url: string } }[] = [];
// Which upload row each accepted item came from, so the failure list the
// server sends back can be mapped to the row the user actually sees.
const rowOfItem: number[] = [];
data.forEach((row: any, i) => {
const title = safeTitle(row[mapping.title as keyof typeof row]);
const detected = detectBulkContent(row[mapping.content as keyof typeof row]);
// A dynamic code is a redirect, so anything that is not a link cannot
// become one. Saying so up front beats storing a code that leads nowhere.
if (detected.contentType !== 'URL') {
failures.push({
row: i + 1,
title,
reason: 'Dynamic codes need a web address in the content column',
});
return;
}
if (data.length > remainingDynamic) {
showToast(`Nur ${remainingDynamic} dynamische Codes verfügbar. Es werden nur die ersten ${remainingDynamic} Zeilen verarbeitet.`, 'warning');
items.push({ title, contentType: 'URL', content: { url: detected.content.url } });
rowOfItem.push(i + 1);
});
if (items.length === 0) {
setGeneratedQRs([]);
setFailedRows(failures);
setStep('complete');
showToast('None of the rows could be turned into a dynamic QR code.', 'error');
return;
}
try {
const QRCode = require('qrcode');
const results: GeneratedQR[] = [];
if (items.length > remainingDynamic) {
showToast(
`Only ${remainingDynamic} dynamic slots left. The rest are listed after the run.`,
'warning'
);
}
for (const row of toProcess) {
const title = String(row[mapping.title as keyof typeof row] || 'Untitled');
const url = String(row[mapping.content as keyof typeof row] || 'https://example.com');
const res = await fetchWithCsrf('/api/qrs', {
// One request for the whole batch. Row-by-row POSTs ran straight into the
// per-minute create limit, so most of a large upload silently 429'd.
const res = await fetchWithCsrf('/api/qrs/bulk', {
method: 'POST',
body: JSON.stringify({
title,
contentType: 'URL',
content: { url },
items,
isStatic: false,
style: presetStyleToQrStyle(activeStyle()),
}),
});
if (res.ok) {
const qr = await res.json();
const redirectUrl = `${window.location.origin}/r/${qr.slug}`;
const svg = await QRCode.toString(redirectUrl, { type: 'svg', width: 300, margin: 2 });
results.push({ title, content: url, svg, slug: qr.slug, redirectUrl });
}
if (!res.ok) {
const err = await res.json().catch(() => null);
showToast(err?.message || err?.error || 'Could not create the QR codes.', 'error');
return;
}
const { created, failed } = (await res.json()) as {
created: { row: number; title: string; slug: string }[];
failed: { row: number; title: string; reason: string }[];
};
failed.forEach((f) => {
failures.push({ ...f, row: rowOfItem[f.row - 1] ?? f.row });
});
const style = activeStyle();
const results: GeneratedQR[] = created.map((qr) => {
const redirectUrl = `${window.location.origin}/r/${qr.slug}`;
return {
title: qr.title,
content: items[qr.row - 1]?.content.url ?? '',
svg: renderStyledQRSvg(redirectUrl, style, 300),
slug: qr.slug,
redirectUrl,
};
});
failures.sort((a, b) => a.row - b.row);
setGeneratedQRs(results);
setRemainingDynamic(prev => Math.max(0, prev - results.length));
setFailedRows(failures);
// Dynamic codes exist in the dashboard the moment they are generated.
setSavedToDashboard(true);
await refreshQuota();
setStep('complete');
showToast(`${results.length} dynamische QR-Codes erstellt!`, 'success');
if (failures.length > 0) {
showToast(
`${results.length} of ${data.length} codes created. ${failures.length} row${failures.length === 1 ? '' : 's'} could not be added - see the list below.`,
'warning'
);
} else {
showToast(`${results.length} dynamic QR codes created.`, 'success');
}
} catch (error) {
console.error('Dynamic QR generation error:', error);
showToast('Fehler beim Erstellen der dynamischen QR-Codes', 'error');
showToast('Something went wrong while creating the dynamic QR codes.', 'error');
} finally {
setLoading(false);
}
};
// Hands the user back exactly the rows that did not make it, in a format they
// can re-upload once they have room. Telling someone what is missing without
// giving them the list is only half an apology.
const downloadFailedRowsCsv = () => {
const header = 'row,title,reason\n';
const body = failedRows
.map(f => `${f.row},"${f.title.replace(/"/g, '""')}","${f.reason.replace(/"/g, '""')}"`)
.join('\n');
const blob = new Blob([header + body], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'qrmaster-missing-rows.csv';
a.click();
URL.revokeObjectURL(url);
};
const downloadAllQRCodes = async () => {
const zip = new JSZip();
@@ -285,37 +402,63 @@ export default function BulkCreationPage() {
};
const saveQRCodesToDatabase = async () => {
if (isDynamic) return; // dynamic codes are already saved during generation
// Dynamic codes are written during generation, and a second press would
// duplicate a static batch. Either way there is nothing left to save.
if (savedToDashboard) return;
if (generatedQRs.length === 0) {
showToast('There are no QR codes to save.', 'error');
return;
}
setLoading(true);
try {
const qrCodesToSave = generatedQRs.map((qr) => ({
const items = generatedQRs.map((qr) => {
const detected = qr.detected ?? detectBulkContent(qr.content);
return {
title: qr.title,
isStatic: true, // This tells the API it's a static QR code
contentType: 'URL',
content: { url: qr.content }, // Content needs to be an object with url property
status: 'ACTIVE',
}));
contentType: detected.contentType,
content: detected.content,
};
});
// Save each QR code to the database
const savePromises = qrCodesToSave.map((qr) =>
fetchWithCsrf('/api/qrs', {
// The design goes with them. Without this the batch was previewed in the
// user's own branding and then stored as plain black and white - the code
// on screen and the code in the dashboard were not the same picture.
const res = await fetchWithCsrf('/api/qrs/bulk', {
method: 'POST',
body: JSON.stringify(qr),
})
);
body: JSON.stringify({
items,
isStatic: true,
style: presetStyleToQrStyle(activeStyle()),
}),
});
const results = await Promise.all(savePromises);
const failedCount = results.filter((r) => !r.ok).length;
if (!res.ok) {
const err = await res.json().catch(() => null);
showToast(err?.message || err?.error || 'Failed to save QR codes', 'error');
return;
}
if (failedCount === 0) {
showToast(`Successfully saved ${qrCodesToSave.length} QR codes!`, 'success');
// Redirect to dashboard after 1 second
const { created, failed } = (await res.json()) as {
created: unknown[];
failed: { row: number; title: string; reason: string }[];
};
setSavedToDashboard(true);
if (failed.length === 0) {
showToast(`Successfully saved ${created.length} QR codes!`, 'success');
setTimeout(() => {
window.location.href = '/dashboard';
}, 1000);
} else {
showToast(`Saved ${qrCodesToSave.length - failedCount} QR codes, ${failedCount} failed`, 'warning');
// Named rows, not a count. "12 failed" out of a print batch is not
// something anyone can act on.
setFailedRows(failed);
showToast(
`Saved ${created.length} QR codes. ${failed.length} could not be saved - see the list below.`,
'warning'
);
}
} catch (error) {
console.error('Error saving QR codes:', error);
@@ -350,6 +493,21 @@ export default function BulkCreationPage() {
URL.revokeObjectURL(url);
};
// Nothing is known about the plan until the request comes back. Rendering the
// paywall in the meantime told paying customers they had not paid.
if (!planLoaded) {
return (
<div className="max-w-6xl mx-auto animate-pulse">
<div className="mb-8 space-y-3">
<div className="h-9 w-64 rounded-lg bg-gray-200" />
<div className="h-5 w-96 rounded bg-gray-100" />
</div>
<div className="h-24 rounded-xl bg-gray-100" />
<div className="mt-6 h-64 rounded-xl bg-gray-100" />
</div>
);
}
// Show upgrade prompt if not Business or Enterprise plan
if (userPlan !== 'BUSINESS' && userPlan !== 'ENTERPRISE') {
return (
@@ -370,7 +528,7 @@ export default function BulkCreationPage() {
<Button variant="outline" onClick={() => window.location.href = '/dashboard'}>
Back to Dashboard
</Button>
<Button onClick={() => window.location.href = '/pricing'}>
<Button onClick={() => window.location.href = '/upgrade?reason=bulk&from=/bulk-creation'}>
Upgrade to Business
</Button>
</div>
@@ -386,6 +544,26 @@ export default function BulkCreationPage() {
<h1 className="text-3xl font-bold text-gray-900">{t('bulk.title')}</h1>
<p className="text-gray-600 mt-2">{t('bulk.subtitle')}</p>
{/* Apply a saved design to the whole batch. */}
{presets.length > 0 && (
<div className="mt-4 rounded-xl border border-gray-200 bg-gray-50 p-4">
<label className="mb-2 block text-sm font-medium text-gray-700">
Design preset
</label>
<Select
value={presetId}
onChange={(e) => setPresetId(e.target.value)}
options={[
{ value: '', label: 'Plain black and white' },
...presets.map((p) => ({ value: p.id, label: p.name })),
]}
/>
<p className="mt-2 text-xs text-gray-500">
Applied to every code in this upload, so the whole batch matches.
</p>
</div>
)}
{/* Static / Dynamic Toggle */}
<div className="mt-4 flex items-center gap-4 p-4 bg-gray-50 rounded-xl border border-gray-200">
<span className="text-sm font-medium text-gray-700">QR Code Type:</span>
@@ -444,11 +622,25 @@ export default function BulkCreationPage() {
<svg className="w-6 h-6 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{/* This banner used to say "static only", which stopped being true
when the dynamic option was added. It now describes whichever
mode is actually selected above. */}
<div>
<h3 className="font-semibold text-blue-900 mb-1">Static QR Codes Only</h3>
<h3 className="font-semibold text-blue-900 mb-1">
{isDynamic ? 'Dynamic QR Codes' : 'Static QR Codes'}
</h3>
<p className="text-sm text-blue-800">
{isDynamic ? (
<>
Each code becomes a <strong>trackable short link</strong> you can re-point later.
The content column must hold a web address, and each code uses one dynamic slot.
</>
) : (
<>
Bulk creation generates <strong>static QR codes</strong> that cannot be edited after creation.
These QR codes do not include tracking or analytics. Perfect for print materials and offline use.
</>
)}
</p>
</div>
</div>
@@ -720,22 +912,37 @@ export default function BulkCreationPage() {
</tr>
</thead>
<tbody>
{data.slice(0, 5).map((row: any, index) => (
{data.slice(0, 5).map((row: any, index) => {
// Same detection and same renderer as the real run, so this
// table is a preview rather than a lookalike.
const detected = detectBulkContent(row[mapping.content]);
const raw = cellToString(row[mapping.content]);
return (
<tr key={index} className="border-b">
<td className="py-3 px-4">
<QRCodeSVG
value={row[mapping.content] || 'https://example.com'}
size={40}
<div
className="h-10 w-10"
dangerouslySetInnerHTML={{
__html: renderStyledQRSvg(
detected.qrValue || 'https://example.com',
activeStyle(),
40
),
}}
/>
</td>
<td className="py-3 px-4 text-sm text-gray-900">
{row[mapping.title] || 'Untitled'}
{cellToString(row[mapping.title]) || 'Untitled'}
<span className="ml-2 rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-600">
{detected.contentType}
</span>
</td>
<td className="py-3 px-4 text-sm text-gray-900">
{(row[mapping.content] || '').substring(0, 50)}...
{raw.length > 50 ? `${raw.substring(0, 50)}...` : raw}
</td>
</tr>
))}
);
})}
</tbody>
</table>
</div>
@@ -755,7 +962,7 @@ export default function BulkCreationPage() {
loading={loading}
>
{isDynamic
? `Generate ${Math.min(data.length, remainingDynamic)} Dynamic QR Codes`
? `Generate ${Math.max(0, Math.min(data.length, remainingDynamic))} Dynamic QR Codes`
: `Generate ${data.length} Static QR Codes`}
</Button>
</div>
@@ -772,11 +979,56 @@ export default function BulkCreationPage() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Generation Complete!</h2>
<h2 className="text-2xl font-bold text-gray-900 mb-2">
{failedRows.length > 0
? `${generatedQRs.length} of ${generatedQRs.length + failedRows.length} codes created`
: 'Generation complete'}
</h2>
<p className="text-gray-600 mb-8">
Successfully generated {generatedQRs.length} static QR codes
{failedRows.length > 0
? 'The rows below could not be added. Nothing was silently dropped - here is exactly what is missing.'
: savedToDashboard
? `${generatedQRs.length} ${isDynamic ? 'dynamic' : 'static'} QR codes, saved to your dashboard and ready to download.`
: `${generatedQRs.length} static QR codes, ready to download. Save them to keep them in your dashboard.`}
</p>
{failedRows.length > 0 && (
<div className="mx-auto mb-8 max-w-3xl rounded-lg border border-amber-200 bg-amber-50 text-left">
<div className="border-b border-amber-200 px-5 py-3">
<p className="text-sm font-semibold text-amber-900">
{failedRows.length} row{failedRows.length === 1 ? '' : 's'} not created
</p>
<p className="mt-1 text-sm text-amber-800">
Check these before you send anything to print.
</p>
</div>
<ul className="max-h-64 divide-y divide-amber-100 overflow-y-auto">
{failedRows.slice(0, 50).map((f) => (
<li key={`${f.row}-${f.title}`} className="flex items-start justify-between gap-4 px-5 py-2.5">
<span className="text-sm text-amber-900">
<span className="font-mono text-xs text-amber-700">Row {f.row}</span>{' '}
{f.title}
</span>
<span className="shrink-0 text-xs text-amber-700">{f.reason}</span>
</li>
))}
</ul>
{failedRows.length > 50 && (
<p className="px-5 py-2 text-xs text-amber-700">
and {failedRows.length - 50} more - download the list to see all of them.
</p>
)}
<div className="flex flex-wrap gap-3 border-t border-amber-200 px-5 py-3">
<Button variant="outline" size="sm" onClick={downloadFailedRowsCsv}>
Download missing rows as CSV
</Button>
<Link href="/upgrade?reason=limit&from=/bulk-creation">
<Button variant="primary" size="sm">Raise my limit</Button>
</Link>
</div>
</div>
)}
<div className="mb-8">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8 max-w-6xl mx-auto">
{generatedQRs.slice(0, 8).map((qr, index) => (
@@ -809,6 +1061,10 @@ export default function BulkCreationPage() {
setData([]);
setMapping({});
setGeneratedQRs([]);
// Left over from the previous run, the failure list reappeared
// on top of the next upload as if it belonged to it.
setFailedRows([]);
setSavedToDashboard(false);
}}>
Create More
</Button>
@@ -818,8 +1074,21 @@ export default function BulkCreationPage() {
</svg>
Download All as ZIP
</Button>
{!isDynamic && (
<Button onClick={saveQRCodesToDatabase} loading={loading}>
{/* There is always an action here. The Save button used to be
hidden entirely for dynamic batches, which left the final
screen with no way forward at all - the codes were in the
dashboard, but nothing on screen said so. */}
{savedToDashboard ? (
<Link href="/dashboard">
<Button>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Saved - View in Dashboard
</Button>
</Link>
) : (
<Button onClick={saveQRCodesToDatabase} loading={loading} disabled={generatedQRs.length === 0}>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
</svg>

View File

@@ -1,9 +1,20 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { QRCodeSVG } from 'qrcode.react';
import StyledQRCode from '@/components/generator/StyledQRCode';
import { renderStyledQRSvg } from '@/lib/render-qr-svg';
import {
ModuleShape,
EyeFrameShape,
EyeBallShape,
PRO_MODULE_SHAPES,
BUSINESS_MODULE_SHAPES,
LOW_COVERAGE_SHAPES,
MODULE_SHAPE_LABELS,
EYE_FRAME_LABELS,
EYE_BALL_LABELS,
} from '@/lib/qr-shapes';
import { toPng } from 'html-to-image';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
@@ -16,6 +27,10 @@ import { useCsrf } from '@/hooks/useCsrf';
import { showToast } from '@/components/ui/Toast';
import { trackEvent } from '@/components/PostHogProvider';
import { appendRedirectParam, sanitizeRedirectPath } from '@/lib/auth-flow';
import UpgradeModal, {
UpgradeReason,
ActiveCodeSummary,
} from '@/components/app/UpgradeModal';
import {
ONBOARDING_DOWNLOAD_COMPLETE_EVENT,
ONBOARDING_DOWNLOAD_COMPLETE_KEY,
@@ -127,6 +142,20 @@ export default function CreatePage() {
const [cornerStyle, setCornerStyle] = useState('square');
const [size, setSize] = useState(200);
const [frameType, setFrameType] = useState('none');
const [moduleShape, setModuleShape] = useState<ModuleShape>('square');
const [eyeFrameShape, setEyeFrameShape] = useState<EyeFrameShape>('square');
const [eyeBallShape, setEyeBallShape] = useState<EyeBallShape>('square');
const [gradientMode, setGradientMode] = useState<'none' | 'linear' | 'radial'>('none');
const [gradientTo, setGradientTo] = useState('#7C3AED');
const [presets, setPresets] = useState<{ id: string; name: string; style: any }[]>([]);
const [presetName, setPresetName] = useState('');
// Upgrade modal. Replaces the old redirect to /pricing, which destroyed the
// form state at the exact moment purchase intent was highest.
const [upgradeOpen, setUpgradeOpen] = useState(false);
const [upgradeReason, setUpgradeReason] = useState<UpgradeReason>('limit');
const [limitInfo, setLimitInfo] = useState<{ current: number; limit: number } | null>(null);
const [activeCodes, setActiveCodes] = useState<ActiveCodeSummary[]>([]);
// Get frame options for current content type
const frameOptions = getFrameOptionsForContentType(contentType);
@@ -163,8 +192,16 @@ export default function CreatePage() {
window.dispatchEvent(new CustomEvent(ONBOARDING_DOWNLOAD_COMPLETE_EVENT));
};
// Check if user can customize colors (PRO+ only)
const canCustomizeColors = userPlan === 'PRO' || userPlan === 'BUSINESS';
// Design gating by plan.
// Colors are free for everyone - a QR code the user cannot color reads as a
// basic utility, and that judgement carries into every comparison they make.
const canCustomizeColors = true;
// Module shapes and eye styles are the PRO driver that replaced colors.
const canUseShapes = userPlan === 'PRO' || userPlan === 'BUSINESS';
// Logo stays PRO.
const canUseLogo = userPlan === 'PRO' || userPlan === 'BUSINESS';
// Gradients, frames with labels, logo shapes and the exotic module shapes.
const canUseFullDesign = userPlan === 'BUSINESS';
// Load user plan
useEffect(() => {
@@ -279,6 +316,38 @@ export default function CreatePage() {
const downloadQR = async (format: 'svg' | 'png') => {
if (!qrRef.current) return;
try {
// Unframed codes are re-rendered from the design rather than captured
// from the DOM. The on-screen preview is drawn with margin 0 because the
// container supplies the visual padding, but a downloaded file needs the
// 4-module quiet zone the spec requires - without it a code printed next
// to other artwork often will not scan. This also makes the file from
// here byte-identical to the one the dashboard produces.
if (format === 'png' && frameType === 'none' && contentType !== 'BARCODE') {
const svg = renderStyledQRSvg(qrContent, currentDesign(), 1024);
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = 1024;
canvas.height = 1024;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.drawImage(img, 0, 0, 1024, 1024);
const link = document.createElement('a');
link.download = `qrcode-${title || 'download'}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
markDownloadComplete();
trackEvent('qr_code_downloaded', {
format: 'png',
content_type: contentType,
qr_type: isDynamic ? 'dynamic' : 'static',
plan: userPlan,
});
};
img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)));
return;
}
if (format === 'png') {
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' });
const link = document.createElement('a');
@@ -293,25 +362,16 @@ export default function CreatePage() {
plan: userPlan,
});
} else {
// For SVG, we might still want to use the library or just toPng if SVG export of HTML is not needed
// Simplest is to check if we can export the SVG element directly but that misses the frame HTML.
// html-to-image can generate SVG too.
// But usually for SVG users want the vector. Capturing HTML to SVG is possible but complex.
// For now, let's just stick to the SVG code export if NO FRAME is selected,
// otherwise warn or use toPng (as SVG).
// Actually, the previous implementation was good for pure QR.
// If frame is selected, we MUST use a raster export (PNG) or complex HTML-to-SVG.
// Let's rely on toPng for consistency with frames.
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' });
// Wait, exporting HTML to valid vector SVG is hard.
// Let's just offer PNG for frames for now to be safe, or just use the same PNG download for both buttons if frame is active?
// No, let's try to grab the INNER SVG if no frame, else...
// Without a frame the preview already is the finished vector, so the SVG
// download is a serialisation of what is on screen. With a frame the
// surrounding markup is HTML, which has no faithful vector equivalent -
// that case falls back to PNG and says so.
if (frameType === 'none') {
const svgElement = qrRef.current.querySelector('svg');
if (svgElement) {
const svgData = contentType === 'BARCODE'
? addBarcodeCaptionToSvg(svgElement, 'Scan: iPhone -> Barcode Scanner App | Android -> Google Lens')
: new XMLSerializer().serializeToString(svgElement);
: renderStyledQRSvg(qrContent, currentDesign(), 512);
const blob = new Blob([svgData], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -392,6 +452,126 @@ export default function CreatePage() {
}
};
// Load the user's active dynamic codes so the limit modal can offer a way out
// that does not cost money. Silent failure is fine here - the modal simply
// hides the pause option if this does not come back.
const loadActiveCodes = async () => {
try {
const res = await fetch('/api/qrs');
if (!res.ok) return;
const all = await res.json();
setActiveCodes(
(Array.isArray(all) ? all : [])
.filter((qr: any) => qr.type === 'DYNAMIC' && qr.status === 'ACTIVE')
.map((qr: any) => ({
id: qr.id,
title: qr.title || 'Untitled',
scans30d: qr.scans30d ?? 0,
}))
);
} catch {
// ignore
}
};
const handlePauseCode = async (id: string) => {
const res = await fetchWithCsrf(`/api/qrs/${id}`, {
method: 'PATCH',
body: JSON.stringify({ status: 'PAUSED' }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(data?.error || 'Could not pause that code.');
}
trackEvent('dynamic_code_paused_for_slot', { qr_id: id });
setUpgradeOpen(false);
showToast('Slot freed. Saving your code now.', 'success');
// The form state was never lost, so the original save just runs again.
await handleSubmit({ preventDefault: () => {} } as React.FormEvent);
};
// Last resort that still leaves the user with something usable. A static code
// cannot be edited or tracked, but it works forever on every plan - and
// saying so here is what makes the rest of the modal credible.
const handleDownloadStatic = () => {
trackEvent('static_fallback_from_limit', { plan: userPlan });
setUpgradeOpen(false);
setIsDynamic(false);
showToast(
'Switched to a static code. It cannot be edited or tracked, but it never expires.',
'info'
);
};
// The logo belongs in the preset. For an agency, "client A looks the same on
// all 500 codes" is mostly about the mark in the middle - a preset that
// carries the colours but drops the logo solves the smaller half of the job.
const currentDesign = () => ({
foregroundColor,
backgroundColor,
moduleShape,
eyeFrameShape,
eyeBallShape,
gradientMode,
gradientTo,
frameType,
logoUrl: canUseLogo ? logoUrl : '',
logoSize,
});
const applyDesign = (style: any) => {
if (!style) return;
if (style.foregroundColor) setForegroundColor(style.foregroundColor);
if (style.backgroundColor) setBackgroundColor(style.backgroundColor);
if (style.moduleShape) setModuleShape(style.moduleShape);
if (style.eyeFrameShape) setEyeFrameShape(style.eyeFrameShape);
if (style.eyeBallShape) setEyeBallShape(style.eyeBallShape);
if (style.gradientMode) setGradientMode(style.gradientMode);
if (style.gradientTo) setGradientTo(style.gradientTo);
if (style.frameType) setFrameType(style.frameType);
if (typeof style.logoUrl === 'string' && canUseLogo) setLogoUrl(style.logoUrl);
if (style.logoSize) setLogoSize(style.logoSize);
};
const loadPresets = async () => {
try {
const res = await fetch('/api/design-presets');
if (res.ok) setPresets(await res.json());
} catch {
// presets are a convenience, never block the page on them
}
};
useEffect(() => {
if (canUseFullDesign) void loadPresets();
}, [canUseFullDesign]);
const savePreset = async () => {
const name = presetName.trim();
if (!name) {
showToast('Give the preset a name first.', 'error');
return;
}
const res = await fetchWithCsrf('/api/design-presets', {
method: 'POST',
body: JSON.stringify({ name, style: currentDesign() }),
});
if (res.ok) {
setPresetName('');
await loadPresets();
trackEvent('design_preset_saved', { plan: userPlan });
showToast(`Preset "${name}" saved.`, 'success');
} else {
const err = await res.json().catch(() => null);
showToast(err?.message || 'Could not save the preset.', 'error');
}
};
const deletePreset = async (id: string) => {
const res = await fetchWithCsrf(`/api/design-presets?id=${id}`, { method: 'DELETE' });
if (res.ok) await loadPresets();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
@@ -404,12 +584,17 @@ export default function CreatePage() {
isStatic: !isDynamic,
tags: [],
style: {
// FREE users can only use black/white
foregroundColor: canCustomizeColors ? foregroundColor : '#000000',
backgroundColor: canCustomizeColors ? backgroundColor : '#FFFFFF',
// Colors are available on every plan, including Free.
foregroundColor,
backgroundColor,
cornerStyle,
size,
imageSettings: (canCustomizeColors && logoUrl) ? {
moduleShape,
eyeFrameShape,
eyeBallShape,
gradientMode,
gradientTo,
imageSettings: (canUseLogo && logoUrl) ? {
src: logoUrl,
height: logoSize,
width: logoSize,
@@ -454,8 +639,20 @@ export default function CreatePage() {
console.error('Error creating QR code:', responseData);
if (response.status === 403 && responseData.error === 'Limit reached') {
showToast(responseData.message || 'You have reached your plan limit.', 'error');
router.push('/pricing?reason=limit_reached');
// Do NOT navigate away. The finished code only exists in this
// component's state - a redirect throws away the user's work at the
// exact moment they were most willing to pay for it.
trackEvent('dynamic_limit_reached', {
plan: responseData.plan,
current_count: responseData.currentCount,
});
setLimitInfo({
current: responseData.currentCount ?? 3,
limit: responseData.limit ?? 3,
});
setUpgradeReason('limit');
void loadActiveCodes();
setUpgradeOpen(true);
return;
}
@@ -810,8 +1007,8 @@ export default function CreatePage() {
onChange={(e) => setContent({ ...content, format: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="CODE128">CODE128 General purpose (recommended)</option>
<option value="CODE39">CODE39 Industrial / logistics</option>
<option value="CODE128">CODE128 - General purpose (recommended)</option>
<option value="CODE39">CODE39 - Industrial / logistics</option>
</select>
<p className="text-xs text-gray-500 mt-1">
Only URL-capable formats available. EAN-13, UPC, and ITF-14 encode numbers only and cannot embed a redirect URL.
@@ -834,13 +1031,13 @@ export default function CreatePage() {
onChange={(e) => setContent({ ...content, format: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="CODE128">CODE128 General purpose (recommended)</option>
<option value="EAN13">EAN-13 Retail products (international)</option>
<option value="UPC">UPC Retail products (USA/Canada)</option>
<option value="CODE39">CODE39 Industrial / logistics</option>
<option value="ITF14">ITF-14 Shipping containers</option>
<option value="MSI">MSI Shelf labeling / inventory</option>
<option value="pharmacode">Pharmacode Pharmaceutical packaging</option>
<option value="CODE128">CODE128 - General purpose (recommended)</option>
<option value="EAN13">EAN-13 - Retail products (international)</option>
<option value="UPC">UPC - Retail products (USA/Canada)</option>
<option value="CODE39">CODE39 - Industrial / logistics</option>
<option value="ITF14">ITF-14 - Shipping containers</option>
<option value="MSI">MSI - Shelf labeling / inventory</option>
<option value="pharmacode">Pharmacode - Pharmaceutical packaging</option>
</select>
</div>
</>
@@ -853,7 +1050,7 @@ export default function CreatePage() {
};
return (
<div className="max-w-6xl mx-auto">
<div className="max-w-7xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">{t('create.title')}</h1>
<p className="text-gray-600 mt-2">{t('create.subtitle')}</p>
@@ -963,36 +1160,225 @@ export default function CreatePage() {
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{t('create.style')}</CardTitle>
{!canCustomizeColors && (
<Badge variant="warning">PRO Feature</Badge>
)}
<Badge variant="success">Free on every plan</Badge>
</div>
</CardHeader>
<CardContent className="space-y-6">
{!canCustomizeColors && (
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg mb-4">
<p className="text-sm text-blue-900">
<strong>Upgrade to PRO</strong> to customize colors, add logos, and brand your QR codes.
{/* Module shape. Colors are free; shapes are the Pro driver that
replaced them. Locked options stay clickable so the preview
shows what is being bought before anyone pays for it. */}
<div>
<div className="mb-3 flex items-center justify-between">
<label className="block text-sm font-medium text-gray-700">Module shape</label>
{!canUseShapes && <Badge variant="info">Pro</Badge>}
</div>
<div className="grid grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2 lg:gap-3">
{([...PRO_MODULE_SHAPES, ...BUSINESS_MODULE_SHAPES] as ModuleShape[]).map((shape) => {
const isBusinessOnly = BUSINESS_MODULE_SHAPES.includes(shape);
const allowed = shape === 'square'
|| (isBusinessOnly ? canUseFullDesign : canUseShapes);
return (
<button
key={shape}
type="button"
onClick={() => {
setModuleShape(shape);
if (!allowed) {
trackEvent('upgrade_prompt_shown', {
reason: 'shapes',
shape,
plan: userPlan,
});
setUpgradeReason(isBusinessOnly ? 'business-shapes' : 'shapes');
setUpgradeOpen(true);
}
}}
className={cn(
'rounded-lg border p-2 text-xs transition-colors lg:p-3 lg:text-sm',
moduleShape === shape
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-gray-200 text-gray-600 hover:border-gray-300',
!allowed && 'opacity-60'
)}
>
<span className="block truncate">{MODULE_SHAPE_LABELS[shape]}</span>
{!allowed && (
<span className="mt-0.5 block text-[10px] text-gray-400">
{isBusinessOnly ? 'Business' : 'Pro'}
</span>
)}
</button>
);
})}
</div>
</div>
{/* Eye styles. Only the combinations that survived decoding are
offered - see the note in lib/qr-shapes.ts. */}
<div className="grid grid-cols-2 gap-4">
<Select
label="Eye frame"
value={eyeFrameShape}
onChange={(e) => {
if (!canUseShapes) {
setUpgradeReason('shapes');
setUpgradeOpen(true);
return;
}
setEyeFrameShape(e.target.value as EyeFrameShape);
}}
options={Object.entries(EYE_FRAME_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
<Select
label="Eye centre"
value={eyeBallShape}
onChange={(e) => {
if (!canUseShapes) {
setUpgradeReason('shapes');
setUpgradeOpen(true);
return;
}
setEyeBallShape(e.target.value as EyeBallShape);
}}
options={Object.entries(EYE_BALL_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
</div>
{/* Gradient. Business only - the renderer takes it as a prop, so
this is purely a gating and input concern. */}
<div>
<div className="mb-3 flex items-center justify-between">
<label className="block text-sm font-medium text-gray-700">Gradient</label>
{!canUseFullDesign && <Badge variant="info">Business</Badge>}
</div>
<div className="grid grid-cols-3 gap-2">
{(['none', 'linear', 'radial'] as const).map((mode) => (
<button
key={mode}
type="button"
onClick={() => {
if (mode !== 'none' && !canUseFullDesign) {
trackEvent('upgrade_prompt_shown', { reason: 'business-shapes', feature: 'gradient', plan: userPlan });
setUpgradeReason('business-shapes');
setUpgradeOpen(true);
return;
}
setGradientMode(mode);
}}
className={cn(
'rounded-lg border p-2 text-xs capitalize transition-colors',
gradientMode === mode
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-gray-200 text-gray-600 hover:border-gray-300',
mode !== 'none' && !canUseFullDesign && 'opacity-60'
)}
>
{mode === 'none' ? 'Solid' : mode}
</button>
))}
</div>
{gradientMode !== 'none' && (
<div className="mt-3 flex items-center gap-2">
<label className="text-sm text-gray-700">Second colour</label>
<input
type="color"
value={gradientTo}
onChange={(e) => setGradientTo(e.target.value)}
className="h-10 w-12 rounded border border-gray-300"
/>
<Input
value={gradientTo}
onChange={(e) => setGradientTo(e.target.value)}
className="flex-1"
/>
</div>
)}
</div>
{/* Scannability. Says what was changed and why, rather than
silently raising the error correction behind the user. */}
{(LOW_COVERAGE_SHAPES.includes(moduleShape) || logoUrl) && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3">
<p className="text-sm text-amber-900">
{logoUrl
? 'Error correction is set to H because this code carries a logo.'
: `"${MODULE_SHAPE_LABELS[moduleShape]}" fills less of each module, so error correction has been raised.`}
</p>
<Link href="/pricing">
<Button variant="primary" size="sm" className="mt-2">
Upgrade Now
<p className="mt-1 text-sm text-amber-800">
Print it at 2 x 2 cm or larger, and scan it once with your own
phone before you send it to the printer.
</p>
</div>
)}
{/* Saved presets. Business only. Repeatability is the actual
product here - the star shape is not what an agency buys. */}
{canUseFullDesign && (
<div className="rounded-lg border border-gray-200 p-3">
<label className="mb-2 block text-sm font-medium text-gray-700">
Design presets
</label>
{presets.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2">
{presets.map((preset) => (
<span
key={preset.id}
className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 py-1 pl-3 pr-1 text-xs"
>
<button
type="button"
onClick={() => applyDesign(preset.style)}
className="text-gray-700 hover:text-primary-700"
>
{preset.name}
</button>
<button
type="button"
onClick={() => deletePreset(preset.id)}
className="px-1 text-gray-400 hover:text-red-600"
aria-label={`Delete preset ${preset.name}`}
>
&times;
</button>
</span>
))}
</div>
)}
<div className="flex items-center gap-2">
<Input
value={presetName}
onChange={(e) => setPresetName(e.target.value)}
placeholder="Client A"
className="flex-1"
/>
<Button type="button" variant="outline" size="sm" onClick={savePreset}>
Save current design
</Button>
</Link>
</div>
<p className="mt-2 text-xs text-gray-500">
Saving under an existing name overwrites it.
</p>
</div>
)}
{/* Frame Options */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">Frame</label>
<div className="grid grid-cols-4 gap-2">
<div className="grid grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2 lg:gap-3">
{frameOptions.map((frame: { id: string; label: string }) => (
<button
key={frame.id}
type="button"
onClick={() => setFrameType(frame.id)}
className={cn(
"py-2 px-3 rounded-lg text-sm font-medium transition-all border",
"py-2 px-3 rounded-lg text-sm font-medium transition-all border lg:py-3",
frameType === frame.id
? "bg-slate-900 text-white border-slate-900"
: "bg-gray-50 text-gray-600 border-gray-200 hover:border-gray-300"
@@ -1016,13 +1402,11 @@ export default function CreatePage() {
value={foregroundColor}
onChange={(e) => setForegroundColor(e.target.value)}
className="w-12 h-10 rounded border border-gray-300"
disabled={!canCustomizeColors}
/>
<Input
value={foregroundColor}
onChange={(e) => setForegroundColor(e.target.value)}
className="flex-1"
disabled={!canCustomizeColors}
/>
</div>
</div>
@@ -1037,13 +1421,11 @@ export default function CreatePage() {
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="w-12 h-10 rounded border border-gray-300"
disabled={!canCustomizeColors}
/>
<Input
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="flex-1"
disabled={!canCustomizeColors}
/>
</div>
</div>
@@ -1092,22 +1474,31 @@ export default function CreatePage() {
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Logo</CardTitle>
{!canCustomizeColors && (
<Badge variant="warning">PRO Feature</Badge>
{!canUseLogo && (
<Badge variant="info">Pro</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{!canCustomizeColors && (
{!canUseLogo && (
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg mb-4">
<p className="text-sm text-blue-900">
<strong>Upgrade to PRO</strong> to add logos to your QR codes.
Your logo in the middle of the code tells people whose it is
before they decide to trust it.
</p>
<Link href="/pricing">
<Button variant="primary" size="sm" className="mt-2">
Upgrade Now
<Button
type="button"
variant="primary"
size="sm"
className="mt-2"
onClick={() => {
trackEvent('upgrade_prompt_shown', { reason: 'logo', plan: userPlan });
setUpgradeReason('logo');
setUpgradeOpen(true);
}}
>
Add my logo
</Button>
</Link>
</div>
)}
<div>
@@ -1119,7 +1510,6 @@ export default function CreatePage() {
type="file"
accept="image/*"
onChange={handleLogoUpload}
disabled={!canCustomizeColors}
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 disabled:opacity-50 disabled:cursor-not-allowed"
/>
{logoUrl && (
@@ -1183,7 +1573,7 @@ export default function CreatePage() {
{/* WRAPPER FOR REF AND FRAME */}
<div
ref={qrRef}
className="relative flex w-full min-w-0 max-w-full flex-col items-center justify-center rounded-xl bg-white p-3 transition-all duration-300 sm:p-4"
className="relative flex w-full min-w-0 max-w-full flex-col items-center justify-center rounded-xl bg-white p-3 transition-all duration-300 sm:p-4 lg:p-6"
style={{
minHeight: '220px',
}}
@@ -1230,19 +1620,23 @@ export default function CreatePage() {
transformOrigin: 'center center',
}}
>
<QRCodeSVG
<StyledQRCode
value={qrContent}
size={size}
fgColor={foregroundColor}
bgColor={backgroundColor}
level="H"
includeMargin={false}
imageSettings={logoUrl ? {
src: logoUrl,
height: logoSize,
width: logoSize,
excavate: excavate,
} : undefined}
moduleShape={moduleShape}
eyeFrameShape={eyeFrameShape}
eyeBallShape={eyeBallShape}
gradient={
gradientMode === 'none'
? null
: { type: gradientMode, from: foregroundColor, to: gradientTo }
}
errorCorrection="H"
logoUrl={canUseLogo && logoUrl ? logoUrl : undefined}
logoScale={logoSize / 200}
margin={0}
/>
</div>
) : (
@@ -1281,6 +1675,18 @@ export default function CreatePage() {
</div>
</div>
</form>
<UpgradeModal
open={upgradeOpen}
reason={upgradeReason}
plan={userPlan}
currentCount={limitInfo?.current}
limit={limitInfo?.limit}
activeCodes={activeCodes}
onPauseCode={upgradeReason === 'limit' ? handlePauseCode : undefined}
onDownloadStatic={upgradeReason === 'limit' ? handleDownloadStatic : undefined}
onClose={() => setUpgradeOpen(false)}
/>
</div>
);
}

View File

@@ -16,6 +16,7 @@ import { QrCode } from 'lucide-react';
import { trackEvent, identifyUser } from '@/components/PostHogProvider';
import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans';
import { OnboardingChecklist } from '@/components/dashboard/OnboardingChecklist';
import { SocialMilestoneDialog } from '@/components/dashboard/SocialMilestoneDialog';
interface QRCodeData {
id: string;
@@ -322,13 +323,14 @@ export default function DashboardPage() {
return (
<div className="space-y-6">
<SocialMilestoneDialog />
{/* Header with Plan Badge */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<h1 className="text-3xl font-bold text-gray-900">{t('dashboard.title')}</h1>
<p className="text-gray-600 mt-2">
{!loading && qrCodes.length === 0
? 'Start here create your first QR code in under 2 minutes'
? 'Start here - create your first QR code in under 2 minutes'
: t('dashboard.subtitle')}
</p>
</div>
@@ -337,7 +339,7 @@ export default function DashboardPage() {
{userPlan} Plan
</Badge>
{userPlan === 'FREE' && (
<Link href="/pricing">
<Link href="/upgrade?from=/dashboard">
<Button className="bg-primary-600 text-white hover:bg-primary-700">Upgrade</Button>
</Link>
)}
@@ -401,7 +403,7 @@ export default function DashboardPage() {
You have {FREE_DYNAMIC_QR_LIMIT} free dynamic QR codes. They redirect wherever you want and track every scan.
</p>
<Link href="/create">
<Button className="bg-primary-600 text-white hover:bg-primary-700">Create QR Code it takes 90 seconds</Button>
<Button className="bg-primary-600 text-white hover:bg-primary-700">Create QR Code - it takes 90 seconds</Button>
</Link>
</div>
) : (
@@ -470,7 +472,7 @@ export default function DashboardPage() {
</li>
<li className="flex items-start">
<span className="text-green-600 mr-2"></span>
<span>Custom Branding (Colors & Logo)</span>
<span>4 Module Shapes, Eye Styles &amp; Your Logo</span>
</li>
<li className="flex items-start">
<span className="text-green-600 mr-2"></span>

View File

@@ -10,10 +10,11 @@ export const metadata: Metadata = {
robots: { index: false, follow: false },
icons: {
icon: [
{ url: '/favicon1.png', sizes: '512x512', type: 'image/png' },
{ url: '/favicon.svg', type: 'image/svg+xml' },
{ url: '/favicon.ico', sizes: '16x16 32x32', type: 'image/x-icon' },
],
shortcut: '/favicon1.png',
apple: '/favicon1.png',
shortcut: '/favicon.ico',
apple: '/logo.svg',
},
};

View File

@@ -3,13 +3,14 @@
import React, { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { QRCodeSVG } from 'qrcode.react';
import StyledQRCode from '@/components/generator/StyledQRCode';
import { renderStyledQRSvg } from '@/lib/render-qr-svg';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import {
ArrowLeft, Edit, ExternalLink, Star, MessageSquare,
BarChart3, Copy, Check, Pause, Play
BarChart3, Copy, Check, Pause, Play, Download
} from 'lucide-react';
import { showToast } from '@/components/ui/Toast';
import { useCsrf } from '@/hooks/useCsrf';
@@ -82,6 +83,55 @@ export default function QRDetailPage() {
showToast('Link copied!', 'success');
};
// Download straight from the dashboard. Previously the only way to get a
// file back out was to rebuild the code in /create, which also meant the
// design had to be recreated from memory.
const downloadQR = (format: 'svg' | 'png') => {
if (!qrCode) return;
const url = `${window.location.origin}/r/${qrCode.slug}`;
const style = {
foregroundColor: qrCode.style?.foregroundColor,
backgroundColor: qrCode.style?.backgroundColor,
moduleShape: qrCode.style?.moduleShape,
eyeFrameShape: qrCode.style?.eyeFrameShape,
eyeBallShape: qrCode.style?.eyeBallShape,
gradientMode: qrCode.style?.gradientMode,
gradientTo: qrCode.style?.gradientTo,
logoUrl: qrCode.style?.imageSettings?.src,
logoSize: qrCode.style?.imageSettings?.width,
};
// 1024px so the PNG is usable in print without a second export step.
const svg = renderStyledQRSvg(url, style, format === 'png' ? 1024 : 512);
const safeName = (qrCode.title || 'qr-code').replace(/[^a-z0-9]+/gi, '-').toLowerCase();
if (format === 'svg') {
const blob = new Blob([svg], { type: 'image/svg+xml' });
const href = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = href;
a.download = `${safeName}.svg`;
a.click();
URL.revokeObjectURL(href);
return;
}
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = 1024;
canvas.height = 1024;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.drawImage(img, 0, 0, 1024, 1024);
const a = document.createElement('a');
a.href = canvas.toDataURL('image/png');
a.download = `${safeName}.png`;
a.click();
};
img.onerror = () => showToast('Could not render the PNG. Try the SVG instead.', 'error');
img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)));
};
const toggleStatus = async () => {
if (!qrCode) return;
const newStatus = qrCode.status === 'ACTIVE' ? 'PAUSED' : 'ACTIVE';
@@ -169,11 +219,32 @@ export default function QRDetailPage() {
<Card>
<CardContent className="p-6 flex flex-col items-center">
<div className="bg-white p-4 rounded-xl shadow-sm mb-4">
<QRCodeSVG
{/* Renders from the saved style, so the code
here looks like the one that was designed -
shapes, gradient and logo included. It
previously drew a plain black grid and
ignored everything but the two colours. */}
<StyledQRCode
value={qrUrl}
size={200}
fgColor={qrCode.style?.foregroundColor || '#000000'}
bgColor={qrCode.style?.backgroundColor || '#FFFFFF'}
moduleShape={qrCode.style?.moduleShape || 'square'}
eyeFrameShape={qrCode.style?.eyeFrameShape || 'square'}
eyeBallShape={qrCode.style?.eyeBallShape || 'square'}
gradient={
qrCode.style?.gradientMode && qrCode.style.gradientMode !== 'none'
? {
type: qrCode.style.gradientMode,
from: qrCode.style.foregroundColor || '#000000',
to: qrCode.style.gradientTo || '#000000',
}
: null
}
errorCorrection="H"
logoUrl={qrCode.style?.imageSettings?.src}
logoScale={(qrCode.style?.imageSettings?.width ?? 24) / 200}
margin={0}
/>
</div>
@@ -187,6 +258,14 @@ export default function QRDetailPage() {
<ExternalLink className="w-4 h-4 mr-2" /> Open Link
</Button>
</a>
<div className="grid grid-cols-2 gap-2">
<Button variant="outline" onClick={() => downloadQR('png')}>
<Download className="w-4 h-4 mr-2" /> PNG
</Button>
<Button variant="outline" onClick={() => downloadQR('svg')}>
<Download className="w-4 h-4 mr-2" /> SVG
</Button>
</div>
</div>
</CardContent>
</Card>

View File

@@ -10,11 +10,51 @@ import ChangePasswordModal from '@/components/settings/ChangePasswordModal';
type TabType = 'profile' | 'subscription';
type MilestonePost = {
channel: string;
status: string;
postUrl: string | null;
error: string | null;
postedAt: string | null;
};
type MilestoneHistoryItem = {
id: string;
qrTitle: string;
uniqueScans: number;
detectedAt: string;
promptStatus: string;
selfSharedAt: string | null;
shareUrl: string | null;
posts: MilestonePost[];
};
const CHANNEL_LABELS: Record<string, string> = { x: 'X', instagram: 'Instagram' };
function milestoneStateLabel(milestone: MilestoneHistoryItem) {
if (milestone.posts.some(post => post.status !== 'revoked')) return 'Approved for publishing';
if (milestone.selfSharedAt) return 'Shared by you';
if (milestone.promptStatus === 'declined') return 'Declined';
return 'Waiting for your decision';
}
function postStateLabel(post: MilestonePost) {
if (post.status === 'posted') return 'published';
if (post.status === 'failed') return 'publishing failed';
if (post.status === 'revoked') return 'revoked before publishing';
return 'queued';
}
export default function SettingsPage() {
const { fetchWithCsrf } = useCsrf();
const [activeTab, setActiveTab] = useState<TabType>('profile');
const [loading, setLoading] = useState(false);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const [socialPromptsEnabled, setSocialPromptsEnabled] = useState(true);
const [socialTestResetAvailable, setSocialTestResetAvailable] = useState(false);
const [socialSaving, setSocialSaving] = useState(false);
const [milestones, setMilestones] = useState<MilestoneHistoryItem[]>([]);
const [milestoneBusy, setMilestoneBusy] = useState<string | null>(null);
// Profile states
const [name, setName] = useState('');
@@ -53,6 +93,19 @@ export default function SettingsPage() {
const data = await statsResponse.json();
setUsageStats(data);
}
const socialResponse = await fetch('/api/social-milestones/preferences');
if (socialResponse.ok) {
const data = await socialResponse.json();
setSocialPromptsEnabled(data.promptsEnabled !== false);
setSocialTestResetAvailable(data.testResetAvailable === true);
}
const historyResponse = await fetch('/api/social-milestones/history');
if (historyResponse.ok) {
const data = await historyResponse.json();
setMilestones(Array.isArray(data.milestones) ? data.milestones : []);
}
} catch (e) {
console.error('Failed to load user data:', e);
}
@@ -94,6 +147,48 @@ export default function SettingsPage() {
}
};
const updateSocialPrompts = async (action: 'enable' | 'disable' | 'reset_test') => {
setSocialSaving(true);
try {
const response = await fetchWithCsrf('/api/social-milestones/preferences', {
method: 'PATCH',
body: JSON.stringify({ action }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Could not update milestone prompts');
setSocialPromptsEnabled(data.promptsEnabled !== false);
showToast(action === 'reset_test' ? 'Milestone test reset. Open the dashboard to test it again.' : 'Milestone preference updated.', 'success');
} catch (error) {
showToast(error instanceof Error ? error.message : 'Could not update milestone prompts', 'error');
} finally {
setSocialSaving(false);
}
};
const updateMilestone = async (id: string, action: 'retry' | 'revoke', channel: string) => {
setMilestoneBusy(`${id}:${channel}`);
try {
const response = await fetchWithCsrf(`/api/social-milestones/${id}`, {
method: 'PATCH',
body: JSON.stringify({ action, channel }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Could not update this milestone');
setMilestones(current => current.map(milestone => milestone.id === id ? {
...milestone,
posts: milestone.posts.map(post => {
const next = (data.milestone?.posts || []).find((entry: MilestonePost) => entry.channel === post.channel);
return next ? { ...post, status: next.status, postUrl: next.postUrl, error: next.error } : post;
}),
} : milestone));
showToast(action === 'retry' ? 'Post queued again.' : 'Post revoked. Nothing will be published.', 'success');
} catch (error) {
showToast(error instanceof Error ? error.message : 'Could not update this milestone', 'error');
} finally {
setMilestoneBusy(null);
}
};
const handleManageSubscription = async () => {
setLoading(true);
@@ -247,6 +342,75 @@ export default function SettingsPage() {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Milestone sharing</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="max-w-xl">
<h3 className="text-sm font-medium text-gray-900">Show scan milestone prompts</h3>
<p className="mt-1 text-sm text-gray-500">Choose whether QR Master may ask you to share verified scan achievements. Nothing is published without your confirmation.</p>
</div>
<Button variant="outline" disabled={socialSaving} onClick={() => updateSocialPrompts(socialPromptsEnabled ? 'disable' : 'enable')}>
{socialPromptsEnabled ? 'Turn off' : 'Turn on'}
</Button>
</div>
{milestones.length > 0 && <div className="border-t border-gray-100 pt-4">
<h3 className="text-sm font-medium text-gray-900">Your milestones</h3>
<p className="mt-1 text-sm text-gray-500">Every scan milestone we detected and what happened to it.</p>
<ul className="mt-3 divide-y divide-gray-100">
{milestones.map(milestone => (
<li key={milestone.id} className="py-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-gray-900">{milestone.qrTitle}</p>
<p className="mt-0.5 text-xs text-gray-500">
{milestone.uniqueScans.toLocaleString('en-US')} unique scans · {new Date(milestone.detectedAt).toLocaleDateString('en-US')} · {milestoneStateLabel(milestone)}
</p>
</div>
{milestone.shareUrl && (
<a href={milestone.shareUrl} target="_blank" rel="noreferrer" className="shrink-0 text-sm font-medium text-blue-600 hover:underline">Open card</a>
)}
</div>
{/* One line per channel: consent, and everything that can be
withdrawn or restarted, is per channel. */}
{milestone.posts.map(post => (
<div key={post.channel} className="mt-2 flex flex-col gap-2 rounded-md bg-gray-50 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-xs text-gray-600">
<span className="font-medium text-gray-900">{CHANNEL_LABELS[post.channel] || post.channel}</span> {postStateLabel(post)}
</p>
{post.status === 'failed' && post.error && (
<p className="mt-0.5 text-xs text-rose-600">{post.error}</p>
)}
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
{post.postUrl && (
<a href={post.postUrl} target="_blank" rel="noreferrer" className="text-sm font-medium text-blue-600 hover:underline">View post</a>
)}
{post.status === 'failed' && (
<Button variant="outline" disabled={milestoneBusy === `${milestone.id}:${post.channel}`} onClick={() => updateMilestone(milestone.id, 'retry', post.channel)}>Try again</Button>
)}
{['approved', 'failed'].includes(post.status) && (
<Button variant="outline" disabled={milestoneBusy === `${milestone.id}:${post.channel}`} onClick={() => updateMilestone(milestone.id, 'revoke', post.channel)}>Cancel</Button>
)}
</div>
</div>
))}
</li>
))}
</ul>
</div>}
{socialTestResetAvailable && <div className="border-t border-gray-100 pt-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-gray-500">Test environment: reopen the latest milestone and clear its publishing state.</p>
<Button variant="outline" disabled={socialSaving} onClick={() => updateSocialPrompts('reset_test')}>Reset milestone test</Button>
</div>
</div>}
</CardContent>
</Card>
{/* Security */}
<Card>
<CardHeader>
@@ -356,7 +520,7 @@ export default function SettingsPage() {
<Button
variant="outline"
className="w-full"
onClick={() => window.location.href = '/pricing'}
onClick={() => window.location.href = '/upgrade?from=/settings'}
>
Manage Subscription
</Button>
@@ -365,7 +529,7 @@ export default function SettingsPage() {
{plan === 'FREE' && (
<div className="pt-4 border-t">
<Button variant="primary" className="w-full" onClick={() => window.location.href = '/pricing'}>
<Button variant="primary" className="w-full" onClick={() => window.location.href = '/upgrade?reason=limit&from=/settings'}>
Upgrade Plan
</Button>
</div>

View File

@@ -0,0 +1,309 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { Card, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { showToast } from '@/components/ui/Toast';
import { BillingToggle } from '@/components/ui/BillingToggle';
import { trackEvent } from '@/components/PostHogProvider';
import { Check, Loader2 } from 'lucide-react';
import {
FREE_DYNAMIC_QR_LIMIT,
PRO_DYNAMIC_QR_LIMIT,
BUSINESS_DYNAMIC_QR_LIMIT,
} from '@/lib/plans';
type PlanKey = 'FREE' | 'PRO' | 'BUSINESS';
type BillingInterval = 'month' | 'year';
/**
* In-app upgrade page.
*
* /pricing lives in the (marketing) route group, so every upgrade link inside
* the app dropped the user out of the product shell and into the public site.
* This page sits in the (app) group, keeps the sidebar, and returns the user to
* where they came from after checkout.
*
* Copy note: the plan captions describe the moment each plan stops being enough,
* not a feature count. Someone on this page already knows what the product does
* - what they are deciding is whether they have crossed a line yet.
*/
const PLANS: {
key: PlanKey;
name: string;
priceMonth: string;
priceYear: string;
period: string;
caption: string;
features: string[];
popular?: boolean;
}[] = [
{
key: 'FREE',
name: 'Free',
priceMonth: '€0',
priceYear: '€0',
period: 'forever',
caption: 'Enough to prove the idea on one or two placements.',
features: [
`${FREE_DYNAMIC_QR_LIMIT} active dynamic QR codes`,
'Unlimited static codes that never expire',
'Basic scan tracking',
'Your colors, foreground and background',
'SVG and PNG download',
],
},
{
key: 'PRO',
name: 'Pro',
priceMonth: '€9',
priceYear: '€90',
period: 'per month',
popular: true,
caption: 'When one campaign is no longer the only campaign.',
features: [
`${PRO_DYNAMIC_QR_LIMIT} dynamic QR codes`,
'Scan data by device, location and time',
'4 module shapes and custom eye styles',
'Your logo in the centre of the code',
'Everything in Free',
],
},
{
key: 'BUSINESS',
name: 'Business',
priceMonth: '€29',
priceYear: '€290',
period: 'per month',
caption: 'When codes are produced in batches, not one at a time.',
features: [
`${BUSINESS_DYNAMIC_QR_LIMIT} dynamic QR codes`,
'Bulk creation: 1,000 static or 500 dynamic per upload',
'Full designer: 11 module shapes and colour gradients',
'Saved design presets, applied to a whole bulk upload',
'Priority email support',
'Everything in Pro',
],
},
];
const REASON_HEADLINES: Record<string, string> = {
limit: 'You are out of dynamic code slots.',
shapes: 'Module shapes start on Pro.',
logo: 'Your logo belongs inside the code.',
bulk: 'Bulk creation is a Business feature.',
analytics: 'You are seeing totals, not sources.',
};
export default function UpgradePage() {
const searchParams = useSearchParams();
const [currentPlan, setCurrentPlan] = useState<PlanKey>('FREE');
const [currentInterval, setCurrentInterval] = useState<BillingInterval | null>(null);
const [billingPeriod, setBillingPeriod] = useState<BillingInterval>('month');
const [loadingPlan, setLoadingPlan] = useState<PlanKey | null>(null);
const reason = searchParams.get('reason');
const returnTo = searchParams.get('from');
useEffect(() => {
fetch('/api/user/plan')
.then((r) => (r.ok ? r.json() : null))
.then((d) => {
if (d?.plan) setCurrentPlan(d.plan);
if (d?.interval) setCurrentInterval(d.interval);
})
.catch(() => {});
}, []);
useEffect(() => {
if (searchParams.get('canceled') === 'true') {
showToast('Checkout canceled. Nothing was charged.', 'info');
}
}, [searchParams]);
const handleUpgrade = async (plan: PlanKey) => {
if (plan === 'FREE') return;
setLoadingPlan(plan);
trackEvent('upgrade_clicked', {
plan,
billing_interval: billingPeriod,
source: 'in_app_upgrade',
reason,
});
try {
const res = await fetch('/api/stripe/create-checkout-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
plan,
billingInterval: billingPeriod,
returnPath: returnTo && returnTo.startsWith('/') ? returnTo : '/dashboard',
}),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(data?.error || 'Could not start checkout.');
}
const { url } = await res.json();
window.location.href = url;
} catch (err: any) {
showToast(err?.message || 'Could not start checkout. Please try again.', 'error');
setLoadingPlan(null);
}
};
const handleDowngrade = async () => {
const confirmed = window.confirm(
'Are you sure you want to cancel your paid plan? You will keep premium features until the end of your current billing period.'
);
if (!confirmed) return;
setLoadingPlan('FREE');
trackEvent('downgrade_clicked', { source: 'in_app_upgrade', current_plan: currentPlan });
try {
const res = await fetch('/api/stripe/cancel-subscription', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(data?.error || 'Failed to cancel subscription.');
}
showToast('Subscription will end at the end of your current billing period.', 'success');
setTimeout(() => window.location.reload(), 1500);
} catch (err: any) {
showToast(err?.message || 'Could not downgrade. Please try again.', 'error');
setLoadingPlan(null);
}
};
return (
<div className="mx-auto max-w-6xl px-4 py-10 sm:px-6 lg:px-8">
<div className="mb-10 max-w-2xl">
<h1 className="text-3xl font-bold text-slate-900">
{reason && REASON_HEADLINES[reason]
? REASON_HEADLINES[reason]
: 'Pick the plan that matches what you are running'}
</h1>
<p className="mt-3 text-base leading-relaxed text-slate-600">
Every plan keeps your static codes working forever, and nothing you have
already printed stops resolving if you change plans. Cancel any time from
Settings.
</p>
</div>
<div className="mb-8 flex justify-center">
<BillingToggle value={billingPeriod} onChange={setBillingPeriod} />
</div>
<div className="grid gap-6 lg:grid-cols-3">
{PLANS.map((plan) => {
const isCurrent =
plan.key === currentPlan &&
(plan.key === 'FREE' || currentInterval === null || currentInterval === billingPeriod);
const hasPlanDifferentInterval =
plan.key !== 'FREE' &&
plan.key === currentPlan &&
currentInterval !== null &&
currentInterval !== billingPeriod;
const price = billingPeriod === 'month' ? plan.priceMonth : plan.priceYear;
const period =
plan.key === 'FREE' ? plan.period : billingPeriod === 'month' ? 'per month' : 'per year';
const isDowngrade = plan.key === 'FREE' && currentPlan !== 'FREE';
return (
<Card
key={plan.key}
className={plan.popular ? 'border-2 border-primary-500' : undefined}
>
<CardContent className="flex h-full flex-col p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold text-slate-900">{plan.name}</h2>
{plan.popular && <Badge variant="info">Most popular</Badge>}
{isCurrent && <Badge variant="success">Current plan</Badge>}
</div>
<div className="mb-1 flex items-baseline gap-2">
<span className="text-4xl font-bold text-slate-900">{price}</span>
<span className="text-sm text-slate-500">{period}</span>
</div>
{plan.key !== 'FREE' && billingPeriod === 'year' && (
<Badge variant="success" className="mb-4 w-fit">
Save 16%
</Badge>
)}
<p className="mb-6 text-sm text-slate-600">{plan.caption}</p>
<ul className="mb-8 space-y-3">
{plan.features.map((f) => (
<li key={f} className="flex items-start gap-2 text-sm text-slate-700">
<Check className="mt-0.5 h-4 w-4 shrink-0 text-green-600" />
<span>{f}</span>
</li>
))}
</ul>
<div className="mt-auto">
{isCurrent ? (
<Button variant="outline" className="w-full" disabled>
Current plan
</Button>
) : isDowngrade ? (
<Button
variant="outline"
className="w-full"
disabled={loadingPlan !== null}
onClick={handleDowngrade}
>
{loadingPlan === 'FREE' ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" /> Cancelling...
</span>
) : (
'Downgrade to Free'
)}
</Button>
) : plan.key === 'FREE' ? (
<Button variant="outline" className="w-full" disabled>
Included
</Button>
) : (
<Button
variant={plan.popular ? 'primary' : 'secondary'}
className="w-full"
disabled={loadingPlan !== null}
onClick={() => handleUpgrade(plan.key)}
>
{loadingPlan === plan.key ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" /> Opening checkout...
</span>
) : hasPlanDifferentInterval ? (
`Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}`
) : (
`Upgrade to ${plan.name}`
)}
</Button>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
<p className="mt-8 text-sm text-slate-500">
Prices exclude VAT where applicable. Payments run through Stripe - QR Master
never sees your card details.
</p>
</div>
);
}

View File

@@ -45,7 +45,7 @@ export default function ForgotPasswordPage() {
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
<img src="/favicon1.png" alt="QR Master" className="w-10 h-10 rounded-full object-cover" />
<img src="/logo.svg" alt="QR Master" className="w-10 h-10 rounded-full object-cover" />
<span className="text-2xl font-bold text-gray-900">QR Master</span>
</Link>
<h1 className="text-3xl font-bold text-gray-900">Check Your Email</h1>
@@ -99,7 +99,7 @@ export default function ForgotPasswordPage() {
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
<img src="/favicon1.png" alt="QR Master" className="w-10 h-10 rounded-full object-cover" />
<img src="/logo.svg" alt="QR Master" className="w-10 h-10 rounded-full object-cover" />
<span className="text-2xl font-bold text-gray-900">QR Master</span>
</Link>
<h1 className="text-3xl font-bold text-gray-900">Forgot Password?</h1>

View File

@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/Button';
import { useTranslation } from '@/hooks/useTranslation';
import { useCsrf } from '@/hooks/useCsrf';
import { appendRedirectParam, sanitizeRedirectPath } from '@/lib/auth-flow';
import { needsHostChange, urlForPath } from '@/lib/hosts';
type LoginClientProps = {
showPageHeading?: boolean;
@@ -63,6 +64,15 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps
const redirectUrl = data.needsOnboarding
? appendRedirectParam('/onboarding', redirectTarget)
: (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.refresh();
} else {
@@ -85,7 +95,7 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
<img src="/favicon1.png" alt="QR Master" className="w-10 h-10 rounded-full object-cover" />
<img src="/logo.svg" alt="QR Master" className="w-10 h-10 rounded-full object-cover" />
<span className="text-2xl font-bold text-gray-900">QR Master</span>
</Link>
{showPageHeading ? (

View File

@@ -2,7 +2,7 @@ import type { Metadata } from 'next';
import LoginClient from './LoginClient';
export const metadata: Metadata = {
title: 'QR Master Smart QR Generator & Analytics',
title: 'QR Master - Smart QR Generator & Analytics',
description: 'Create dynamic QR codes, track scans, and scale campaigns with secure analytics. Free advanced features, bulk generation, and custom branding available.',
robots: {
index: false,

View File

@@ -78,7 +78,7 @@ export default function ResetPasswordPage() {
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
<img src="/favicon1.png" alt="QR Master" className="w-10 h-10 rounded-full object-cover" />
<img src="/logo.svg" alt="QR Master" className="w-10 h-10 rounded-full object-cover" />
<span className="text-2xl font-bold text-gray-900">QR Master</span>
</Link>
<h1 className="text-3xl font-bold text-gray-900">Password Reset Successful</h1>
@@ -120,7 +120,7 @@ export default function ResetPasswordPage() {
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
<img src="/favicon1.png" alt="QR Master" className="w-10 h-10 rounded-full object-cover" />
<img src="/logo.svg" alt="QR Master" className="w-10 h-10 rounded-full object-cover" />
<span className="text-2xl font-bold text-gray-900">QR Master</span>
</Link>
<h1 className="text-3xl font-bold text-gray-900">Reset Your Password</h1>

View File

@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/Button';
import { useTranslation } from '@/hooks/useTranslation';
import { useCsrf } from '@/hooks/useCsrf';
import { appendRedirectParam, sanitizeRedirectPath } from '@/lib/auth-flow';
import { needsHostChange, urlForPath } from '@/lib/hosts';
export default function SignupClient() {
const router = useRouter();
@@ -51,6 +52,11 @@ export default function SignupClient() {
const data = await response.json();
if (response.ok && data.success) {
if (data.requiresEmailVerification) {
router.push(`/verify-email?email=${encodeURIComponent(data.email)}`);
return;
}
// Store user in localStorage for client-side
localStorage.setItem('user', JSON.stringify(data.user));
@@ -71,8 +77,16 @@ export default function SignupClient() {
console.error('PostHog tracking error:', error);
}
// Redirect to onboarding
router.push(appendRedirectParam('/onboarding', redirectTarget));
// Redirect to onboarding - which lives on the app host, so this normally
// 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();
} else {
setError(data.error || 'Failed to create account');
@@ -94,7 +108,7 @@ export default function SignupClient() {
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
<img src="/favicon1.png" alt="QR Master" className="w-10 h-10 rounded-full object-cover" />
<img src="/logo.svg" alt="QR Master" className="w-10 h-10 rounded-full object-cover" />
<span className="text-2xl font-bold text-gray-900">QR Master</span>
</Link>
<h1 className="text-3xl font-bold text-gray-900">Create Account</h1>

View File

@@ -3,7 +3,7 @@ import SignupClient from './SignupClient';
export const metadata: Metadata = {
title: 'Create Account | QR Master',
description: 'Start creating dynamic QR codes in seconds. Join thousands of businesses using QR Master.',
description: 'Start creating dynamic QR codes in seconds. Free forever plan, no credit card required.',
robots: {
index: false,
follow: true,

View File

@@ -0,0 +1,28 @@
import Link from 'next/link';
export const metadata = {
title: 'Check your email | QR Master',
robots: { index: false, follow: false },
};
export default function VerifyEmailPage({ searchParams }: { searchParams: { email?: string; status?: string } }) {
const expired = searchParams.status === 'expired';
return (
<main className="flex min-h-screen items-center justify-center bg-gradient-to-br from-primary-50 to-white p-4">
<section className="w-full max-w-md rounded-xl bg-white p-8 shadow-[0_22px_42px_-30px_rgba(50,50,93,0.38)]">
<Link href="/" className="text-sm font-semibold text-primary-700">QR MASTER</Link>
<h1 className="mt-8 text-3xl font-semibold tracking-tight text-slate-950">
{expired ? 'This confirmation link has expired.' : 'Check your inbox.'}
</h1>
<p className="mt-3 text-sm leading-6 text-slate-600">
{expired
? 'Please create your account again to receive a new confirmation email.'
: <>We sent a confirmation link{searchParams.email ? <> to <strong className="font-medium text-slate-800">{searchParams.email}</strong></> : ''}. Open it to finish creating your account.</>}
</p>
<p className="mt-6 text-sm leading-6 text-slate-500">The link expires in 24 hours. Check your spam folder if it does not arrive shortly.</p>
<Link href="/login" className="mt-8 inline-flex text-sm font-medium text-primary-700 hover:text-primary-800">Back to sign in</Link>
</section>
</main>
);
}

View File

@@ -93,7 +93,7 @@ export default function MarketingLayout({
<Link href="/" className="flex items-center space-x-3 group">
<div className="relative w-16 h-16 overflow-hidden rounded-full shadow-indigo-200 shadow-lg group-hover:scale-105 transition-transform duration-200">
<Image
src="/favicon1.png"
src="/logo.svg"
alt="QR Master"
fill
sizes="64px"

View File

@@ -9,7 +9,7 @@ import { ObfuscatedMailto } from '@/components/ui/ObfuscatedMailto';
export const metadata: Metadata = {
title: 'About QR Master | Free QR Code Generator for Businesses',
description: 'QR Master helps businesses create, track, and manage QR codes at scale free dynamic QR codes, real analytics, and no hidden limits. Learn who we are.',
description: 'QR Master helps businesses create, track, and manage QR codes at scale - free dynamic QR codes, real analytics, and no hidden limits. Learn who we are.',
openGraph: {
title: 'About QR Master | Free Dynamic QR Codes & Analytics',
description: 'Free dynamic QR codes with scan analytics, custom branding, and no reprint headaches. Learn about the team and mission behind QR Master.',
@@ -32,7 +32,7 @@ export default function AboutPage() {
QR codes should be <span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-purple-600">flexible, measurable, and reliable</span>.
</h1>
<p className="text-xl text-gray-600 max-w-2xl mx-auto mb-10 leading-relaxed">
QR Master helps teams create dynamic QR codes that can be updated after printingso you can stop reprinting materials every time something changes. Whether youre running a menu, an event, or a multi-channel campaign, QR Master turns QR codes into a tool you can manage and measure.
QR Master helps teams create dynamic QR codes that can be updated after printing-so you can stop reprinting materials every time something changes. Whether youre running a menu, an event, or a multi-channel campaign, QR Master turns QR codes into a tool you can manage and measure.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
@@ -53,7 +53,7 @@ export default function AboutPage() {
Our Mission
</div>
<h2 className="text-3xl font-bold text-gray-900 mb-4">
Create QR codes that work everywhereand make campaigns measurable.
Create QR codes that work everywhere-and make campaigns measurable.
</h2>
</div>
</section>
@@ -76,7 +76,7 @@ export default function AboutPage() {
</div>
<h3 className="text-xl font-bold text-gray-900 mb-3">Dynamic QR Codes</h3>
<p className="text-gray-600 leading-relaxed mb-4">
Change the destination of a QR code after its already printed. Keep your printed materials validwhile you update your content anytime.
Change the destination of a QR code after its already printed. Keep your printed materials valid-while you update your content anytime.
</p>
<Link href="/dynamic-qr-code-generator" className="text-blue-600 font-medium hover:underline">
Learn about Dynamic QR &rarr;
@@ -104,7 +104,7 @@ export default function AboutPage() {
</div>
<h3 className="text-xl font-bold text-gray-900 mb-3">Advanced Analytics</h3>
<p className="text-gray-600 leading-relaxed mb-4">
Understand QR performance with scan analyticsso you can improve placements and campaigns based on real usage over time.
Understand QR performance with scan analytics-so you can improve placements and campaigns based on real usage over time.
</p>
<Link href="/qr-code-tracking" className="text-blue-600 font-medium hover:underline">
See Analytics Features &rarr;
@@ -230,7 +230,7 @@ export default function AboutPage() {
</div>
<div className="flex items-start">
<span className="font-semibold w-32">Support hours:</span>
<span>MondayFriday, 9:0017:00 CET</span>
<span>Monday-Friday, 9:00-17:00 CET</span>
</div>
<div className="flex items-start">
<span className="font-semibold w-32">Languages:</span>

View File

@@ -14,17 +14,17 @@ const competitor = competitors['beaconstac'];
export const metadata: Metadata = {
title: {
absolute: 'Beaconstac / Uniqode Alternative for SMBs QR Master',
absolute: 'Beaconstac / Uniqode Alternative - Free, €9 or €29',
},
description:
'Looking for a Beaconstac or Uniqode alternative? QR Master is the lightweight, affordable option for SMBs and freelancers who need dynamic QR codes and analytics without enterprise pricing. From €0 free.',
'Uniqode is built for enterprise, and priced for it. If you need dynamic QR codes and scan analytics but not SOC2 and SSO: QR Master is €0, €9 or €29 a month.',
keywords:
'beaconstac alternative, uniqode alternative, beaconstac pricing, uniqode too expensive, beaconstac smb alternative, dynamic qr code alternative enterprise',
alternates: {
canonical: 'https://www.qrmaster.net/alternatives/beaconstac',
},
openGraph: {
title: 'Beaconstac / Uniqode Alternative for SMBs QR Master',
title: 'Beaconstac / Uniqode Alternative for SMBs - QR Master',
description:
'Uniqode (formerly Beaconstac) is excellent for enterprise. If you don\'t need SOC2 and SSO but do need reliable dynamic QR + analytics, QR Master starts free at €0.',
url: 'https://www.qrmaster.net/alternatives/beaconstac',
@@ -32,7 +32,7 @@ export const metadata: Metadata = {
images: ['/og-image.png'],
},
twitter: {
title: 'Beaconstac / Uniqode Alternative for SMBs QR Master',
title: 'Beaconstac / Uniqode Alternative for SMBs - QR Master',
description:
'Uniqode (formerly Beaconstac) is built for enterprise. QR Master is the affordable alternative for SMBs and freelancers who need the same core QR functionality.',
},
@@ -44,37 +44,37 @@ const faqItems = [
{
question: 'What is the difference between Beaconstac and Uniqode?',
answer:
'They are the same company. Beaconstac rebranded to Uniqode in 2023. The product is the same enterprise QR code management platform the name changed, not the features or pricing model. When people search for "Beaconstac alternative" or "Uniqode alternative," they are looking for the same thing.',
'They are the same company. Beaconstac rebranded to Uniqode in 2023. The product is the same enterprise QR code management platform - the name changed, not the features or pricing model. When people search for "Beaconstac alternative" or "Uniqode alternative," they are looking for the same thing.',
},
{
question: 'Why is Uniqode / Beaconstac considered expensive for SMBs?',
answer:
'Uniqode\'s entry price is around $5/month, but that tier includes very limited features. To get meaningful analytics, team management, and enough dynamic QR codes for a real use case, you need to spend $4999/month or more. The enterprise features SOC2 compliance, SSO/SAML, deep API access are what justify that pricing for large organizations. For an SMB that needs 50 dynamic QR codes with scan analytics, those enterprise features are not relevant, and paying for them is waste.',
'Uniqode\'s entry price is around $5/month, but that tier includes very limited features. To get meaningful analytics, team management, and enough dynamic QR codes for a real use case, you need to spend $49-99/month or more. The enterprise features - SOC2 compliance, SSO/SAML, deep API access - are what justify that pricing for large organizations. For an SMB that needs 50 dynamic QR codes with scan analytics, those enterprise features are not relevant, and paying for them is waste.',
},
{
question: 'Does QR Master have a free plan?',
answer:
'Yes. QR Master\'s free plan includes 3 active dynamic QR codes, unlimited static QR codes, and basic scan tracking. No credit card required. Uniqode does not offer a free plan you pay from the first month. QR Master Pro at €9/month includes 50 dynamic QR codes, advanced analytics, and custom branding. Business at €29/month adds bulk creation and 500 dynamic codes.',
'Yes. QR Master\'s free plan includes 3 active dynamic QR codes, unlimited static QR codes, and basic scan tracking. No credit card required. Uniqode does not offer a free plan - you pay from the first month. Colors are free on every plan. QR Master Pro at €9/month includes 50 dynamic QR codes, advanced analytics, module shapes and logo embedding. Business at €29/month adds bulk creation and 500 dynamic codes.',
},
{
question: 'Does QR Master support bulk QR code creation like Beaconstac?',
answer:
'Yes. QR Master Business (€29/month) supports CSV and Excel upload for bulk creation of up to 1,000 unique QR codes per batch. Each code can have a different destination URL, label, and UTM parameters. Beaconstac/Uniqode also supports bulk creation, but the feature is locked behind enterprise pricing tiers.',
'Yes. QR Master Business (€29/month) supports CSV and Excel upload for bulk creation: up to 1,000 static codes, or up to 500 dynamic ones per batch, the dynamic cap being the Business allowance. Each code can have a different destination URL, label, and UTM parameters. Beaconstac/Uniqode also supports bulk creation, but the feature is locked behind enterprise pricing tiers.',
},
{
question: 'Is QR Master GDPR-compliant?',
answer:
'Yes. QR Master hashes IP addresses server-side before any analytics data is stored. No raw IP is ever written to the database. Scan analytics capture device type, time, country-level location, and UTM parameters without storing personally identifiable information. This is built into the infrastructure and applies to all plans, including the free tier. Uniqode is a US company and requires additional DPA configuration for GDPR compliance.',
'Yes. QR Master hashes IP addresses server-side before any analytics data is stored. No raw IP is ever written to the database. Scan analytics capture device type, time, country-level location, and UTM parameters - without storing personally identifiable information. This is built into the infrastructure and applies to all plans, including the free tier. Uniqode is a US company and requires additional DPA configuration for GDPR compliance.',
},
{
question: 'Who should stay on Beaconstac / Uniqode instead of switching?',
answer:
'Uniqode is genuinely the right tool for large enterprises that need SOC2 Type II certification, SSO/SAML authentication, deep API integrations, and formal vendor security review processes. If your procurement team requires a security certification or your IT team needs to integrate QR code management into enterprise identity systems, Uniqode is built for that. QR Master is not an enterprise compliance platform it is a clean, fast, affordable tool for teams that need dynamic QR codes and analytics without the enterprise overhead.',
'Uniqode is genuinely the right tool for large enterprises that need SOC2 Type II certification, SSO/SAML authentication, deep API integrations, and formal vendor security review processes. If your procurement team requires a security certification or your IT team needs to integrate QR code management into enterprise identity systems, Uniqode is built for that. QR Master is not an enterprise compliance platform - it is a clean, fast, affordable tool for teams that need dynamic QR codes and analytics without the enterprise overhead.',
},
{
question: 'Can I import my codes from Beaconstac into QR Master?',
answer:
'Beaconstac/Uniqode allows CSV export of your QR code data. You can use that export to re-create your dynamic codes in QR Master using the bulk upload feature (Business plan). For dynamic codes, the redirect URL changes you will need to update printed materials or digital placements that point to Beaconstac\'s redirect infrastructure. Static codes are permanently encoded in the image and do not need migration they continue working regardless of your Beaconstac subscription.',
'Beaconstac/Uniqode allows CSV export of your QR code data. You can use that export to re-create your dynamic codes in QR Master using the bulk upload feature (Business plan, up to 500 dynamic codes per account). For dynamic codes, the redirect URL changes - you will need to update printed materials or digital placements that point to Beaconstac\'s redirect infrastructure. Static codes are permanently encoded in the image and do not need migration - they continue working regardless of your Beaconstac subscription.',
},
];
@@ -103,21 +103,21 @@ const relatedLinks = [
href: '/dynamic-qr-code-generator',
title: 'Dynamic QR Code Generator',
description:
'Create dynamic QR codes you can update after printing with scan analytics, custom branding, and dashboard management.',
'Create dynamic QR codes you can update after printing - with scan analytics, custom branding, and dashboard management.',
ctaLabel: 'Create your first dynamic QR code',
},
{
href: '/qr-code-analytics',
title: 'QR Code Analytics',
description:
'Track device, time, location, and UTM parameters for every scan without storing raw IPs or PII.',
'Track device, time, location, and UTM parameters for every scan - without storing raw IPs or PII.',
ctaLabel: 'See QR code analytics',
},
{
href: '/bulk-qr-code-generator',
title: 'Bulk QR Code Generator',
description:
'Generate up to 1,000 unique QR codes in one upload via CSV or Excel. Each code gets its own destination and UTM parameters.',
'One upload, a whole batch: up to 1,000 static codes or up to 500 dynamic ones. Each code gets its own destination and UTM parameters.',
ctaLabel: 'Explore bulk QR creation',
},
{
@@ -165,11 +165,11 @@ export default function BeaconstacAlternativePage() {
</div>
<ul className="mb-10 space-y-3">
{[
'Free plan with 3 active dynamic QR codes no credit card required',
'Pro at €9/month vs Uniqode\'s $4999/month for comparable features',
'GDPR-compliant analytics out of the box no DPA configuration needed',
'Bulk creation up to 1,000 codes on Business (€29/month)',
'Simple onboarding no enterprise setup process',
'Free plan with 3 active dynamic QR codes - no credit card required',
'Pro at €9/month vs Uniqode\'s $49-99/month for comparable features',
'GDPR-compliant analytics out of the box - no DPA configuration needed',
'Bulk creation on Business: 1,000 static or 500 dynamic (€29/month)',
'Simple onboarding - no enterprise setup process',
].map((feature) => (
<li key={feature} className="flex items-start gap-3">
<span
@@ -220,7 +220,7 @@ export default function BeaconstacAlternativePage() {
<p className="text-sm text-gray-500">Starter plan with analytics</p>
</div>
<div className="text-right">
<p className="text-2xl font-bold text-gray-800">$4999</p>
<p className="text-2xl font-bold text-gray-800">$49-99</p>
<p className="text-xs text-gray-500">per month</p>
</div>
</div>
@@ -258,28 +258,28 @@ export default function BeaconstacAlternativePage() {
<div className="space-y-6 text-lg leading-relaxed" style={{ color: '#52525B' }}>
<p>
Uniqode (formerly Beaconstac) is genuinely excellent at what it does. The platform is built for large
enterprises that operate in compliance-heavy industries healthcare, finance, government contracting
enterprises that operate in compliance-heavy industries - healthcare, finance, government contracting -
where vendors need SOC2 Type II certification, single sign-on integration, formal security review, and
a dedicated account team. For those buyers, Uniqode is a legitimate choice.
</p>
<p>
The problem is that all of that infrastructure costs money, and Uniqode passes those costs through in
its pricing. The entry plan at around $5/month is misleadingly cheap it supports so few codes and
its pricing. The entry plan at around $5/month is misleadingly cheap - it supports so few codes and
offers so few features that almost no real use case fits it. To get 50 dynamic QR codes with proper
analytics and team features, you are looking at $4999/month before you even touch the enterprise
analytics and team features, you are looking at $49-99/month before you even touch the enterprise
tier.
</p>
<p>
For a restaurant owner who wants to update their digital menu link once a quarter, or a marketing
manager running a campaign with 20 QR codes on printed materials, or a freelancer building print
campaigns for clients the SOC2 certification is irrelevant, and $49+/month is a hard number to
campaigns for clients - the SOC2 certification is irrelevant, and $49+/month is a hard number to
justify when the core functionality needed is &ldquo;create QR codes, track scans, update
destinations.&rdquo;
</p>
<p>
QR Master is built for that majority use case. It doesn&apos;t have SOC2. It doesn&apos;t have SSO.
What it has is reliable dynamic QR code management, scan analytics with GDPR-compliant tracking, and
bulk creation at a price that makes sense for teams that don&apos;t need the enterprise compliance
bulk creation - at a price that makes sense for teams that don&apos;t need the enterprise compliance
layer.
</p>
</div>
@@ -290,7 +290,7 @@ export default function BeaconstacAlternativePage() {
<h3 className="mb-2 text-lg font-semibold" style={{ color: '#166534' }}>When you should stay on Uniqode</h3>
<p style={{ color: '#27272A' }}>
If your organization requires a SOC2-certified QR code vendor, needs SSO/SAML integration, or goes
through formal vendor security review Uniqode is built for exactly that. QR Master is not. This page
through formal vendor security review - Uniqode is built for exactly that. QR Master is not. This page
is for the much larger group of SMBs and marketing teams who are paying enterprise prices for
functionality they could get at a fraction of the cost.
</p>
@@ -359,16 +359,16 @@ export default function BeaconstacAlternativePage() {
<div>
<h3 className="mb-4 text-xl font-bold" style={{ color: '#18181B' }}>Pricing structure</h3>
<p className="text-lg leading-relaxed" style={{ color: '#52525B' }}>
Uniqode starts at around $5/month but that plan is largely a placeholder it supports so few codes
Uniqode starts at around $5/month but that plan is largely a placeholder - it supports so few codes
with so few features that most users immediately hit its limits. The next meaningful tier is $49/month
or higher. There is no free plan.
</p>
<p className="mt-4 text-lg leading-relaxed" style={{ color: '#52525B' }}>
QR Master free plan includes 3 active dynamic QR codes, unlimited static codes, and basic scan
tracking permanently, without a credit card. Pro at 9/month (cancel anytime) covers 50 dynamic
codes with full analytics and custom branding. Business at 29/month adds 500 codes and bulk
tracking - permanently, without a credit card. Pro at 9/month (cancel anytime) covers 50 dynamic
codes with full analytics, module shapes and logo. Business at 29/month adds 500 codes and bulk
creation. The gap between what you get at 9/month on QR Master vs $49/month on Uniqode is significant
not because QR Master has more features, but because it doesn&apos;t charge you for enterprise
- not because QR Master has more features, but because it doesn&apos;t charge you for enterprise
infrastructure you don&apos;t use.
</p>
</div>
@@ -376,14 +376,14 @@ export default function BeaconstacAlternativePage() {
<div>
<h3 className="mb-4 text-xl font-bold" style={{ color: '#18181B' }}>Complexity and onboarding</h3>
<p className="text-lg leading-relaxed" style={{ color: '#52525B' }}>
Uniqode is a mature enterprise platform. The interface reflects that it is comprehensive, with
Uniqode is a mature enterprise platform. The interface reflects that - it is comprehensive, with
organization management, user roles, integration settings, and compliance tooling all visible.
For an enterprise IT team, that depth is valuable. For a marketing manager or restaurant owner who
just needs to create and track 20 dynamic QR codes, it adds overhead without adding value.
</p>
<p className="mt-4 text-lg leading-relaxed" style={{ color: '#52525B' }}>
QR Master is deliberately simpler. Create a QR code, set the destination, download it, and see scans
in the dashboard. The workflow is designed around the most common use cases not around the edge
in the dashboard. The workflow is designed around the most common use cases - not around the edge
cases that enterprise compliance teams need.
</p>
</div>
@@ -397,7 +397,7 @@ export default function BeaconstacAlternativePage() {
</p>
<p className="mt-4 text-lg leading-relaxed" style={{ color: '#52525B' }}>
QR Master anonymizes scan data at the infrastructure level. IP addresses are hashed server-side with
a salt before any data is written the raw IP is never stored. No configuration required. This
a salt before any data is written - the raw IP is never stored. No configuration required. This
applies from the free plan upward and is documented in the platform&apos;s open codebase.
</p>
</div>
@@ -469,7 +469,7 @@ export default function BeaconstacAlternativePage() {
{
step: '2',
title: 'Create a QR Master account',
body: 'The free plan gives you 3 dynamic codes immediately. For larger migrations, start with a Pro (€9/month) or Business (€29/month) plan Business includes bulk upload from CSV.',
body: 'The free plan gives you 3 dynamic codes immediately. For larger migrations, start with a Pro (€9/month) or Business (€29/month) plan - Business includes bulk upload from CSV.',
},
{
step: '3',
@@ -479,7 +479,7 @@ export default function BeaconstacAlternativePage() {
{
step: '4',
title: 'Update digital placements',
body: 'Replace QR code images on your website, email, and digital materials immediately these don\'t require a physical reprint.',
body: 'Replace QR code images on your website, email, and digital materials immediately - these don\'t require a physical reprint.',
},
{
step: '5',

View File

@@ -17,7 +17,7 @@ export const metadata: Metadata = {
absolute: 'QR Master vs Bitly QR Codes | Bitly Alternative',
},
description:
'Looking for a Bitly alternative for QR codes? Bitly\'s Core plan costs $10/month but only allows 2 QR codes total. QR Master is purpose-built for QR code management 50 codes at €9/month, bulk creation, GDPR analytics. From €0.',
'Looking for a Bitly alternative for QR codes? Bitly\'s Core plan costs $10/month but only allows 2 QR codes total. QR Master is purpose-built for QR code management - 50 codes at €9/month, bulk creation, GDPR analytics. From €0.',
keywords:
'bitly qr code alternative, bitly qr code limit, bitly alternative qr codes, bitly pricing qr codes, bitly 2 qr codes',
alternates: {
@@ -26,7 +26,7 @@ export const metadata: Metadata = {
openGraph: {
title: 'QR Master vs Bitly QR Codes | Bitly Alternative',
description:
'Bitly\'s Core plan costs $10/month but only gives you 2 QR codes. QR Master gives you 50 dynamic QR codes at €9/month purpose-built for QR workflows, bulk creation, and GDPR analytics.',
'Bitly\'s Core plan costs $10/month but only gives you 2 QR codes. QR Master gives you 50 dynamic QR codes at €9/month - purpose-built for QR workflows, bulk creation, and GDPR analytics.',
url: 'https://www.qrmaster.net/alternatives/bitly',
type: 'website',
images: ['/og-image.png'],
@@ -34,7 +34,7 @@ export const metadata: Metadata = {
twitter: {
title: 'QR Master vs Bitly QR Codes | Bitly Alternative',
description:
'Bitly gives you 2 QR codes for $10/month. QR Master gives you 50 at €9/month purpose-built for real QR campaigns, not link shortening with QR as an afterthought.',
'Bitly gives you 2 QR codes for $10/month. QR Master gives you 50 at €9/month - purpose-built for real QR campaigns, not link shortening with QR as an afterthought.',
},
};
@@ -54,7 +54,7 @@ const atAGlanceRows = [
{
useCase: 'Bulk QR creation',
bitly: 'No dedicated bulk QR generator.',
qrMaster: 'CSV and Excel upload creates up to 1,000 unique QR codes per batch.',
qrMaster: 'CSV and Excel upload: up to 1,000 static codes, or up to 500 dynamic ones, per batch.',
},
{
useCase: 'QR campaign analytics',
@@ -72,17 +72,17 @@ const faqItems = [
{
question: 'How many QR codes does Bitly allow per plan?',
answer:
'Bitly\'s free plan allows 1 QR code. Their Core plan (~$10/month) markets "unlimited scans" prominently but the actual limit that matters is the QR code count: 2 total. If you need a third QR code on that plan, you have to upgrade. Higher plans allow more codes, but the pricing jumps quickly relative to what you get. QR Master\'s Pro plan (€9/month) includes 50 dynamic QR codes with full analytics and no scan caps on redirects.',
'Bitly\'s free plan allows 1 QR code. Their Core plan (~$10/month) markets "unlimited scans" prominently - but the actual limit that matters is the QR code count: 2 total. If you need a third QR code on that plan, you have to upgrade. Higher plans allow more codes, but the pricing jumps quickly relative to what you get. QR Master\'s Pro plan (€9/month) includes 50 dynamic QR codes with full analytics and no scan caps on redirects.',
},
{
question: 'Is Bitly good for QR code management?',
answer:
'Bitly works for QR codes in the sense that it can generate them and track clicks. But the product is built around link management and URL shortening QR codes are a secondary feature. The workflow, the dashboard, and the pricing model are all designed around links, not QR code-specific use cases like restaurant menus, product packaging, event materials, or bulk creation for print campaigns. If QR codes are your primary use case, a purpose-built platform handles the workflow better.',
'Bitly works for QR codes in the sense that it can generate them and track clicks. But the product is built around link management and URL shortening - QR codes are a secondary feature. The workflow, the dashboard, and the pricing model are all designed around links, not QR code-specific use cases like restaurant menus, product packaging, event materials, or bulk creation for print campaigns. If QR codes are your primary use case, a purpose-built platform handles the workflow better.',
},
{
question: 'How does Bitly pricing compare to QR Master for QR codes?',
answer:
'Bitly\'s free plan allows only 1 QR code. Their Core plan (~$10/month) allows 2 QR codes. Higher plans add more codes but pricing escalates steeply. QR Master\'s free plan includes 3 active dynamic QR codes and unlimited static codes. Pro at €9/month includes 50 dynamic codes with full analytics. Business at €29/month includes 500 codes and bulk creation of up to 1,000 at once. Neither the Pro nor Business plan caps QR code redirects by scan volume.',
'Bitly\'s free plan allows only 1 QR code. Their Core plan (~$10/month) allows 2 QR codes. Higher plans add more codes but pricing escalates steeply. QR Master\'s free plan includes 3 active dynamic QR codes and unlimited static codes. Pro at €9/month includes 50 dynamic codes with full analytics. Business at €29/month includes 500 codes and bulk creation of up to 1,000 static codes, or up to 500 dynamic ones at once. Neither the Pro nor Business plan caps QR code redirects by scan volume.',
},
{
question: 'Does QR Master have link shortening like Bitly?',
@@ -92,12 +92,12 @@ const faqItems = [
{
question: 'Can I create QR codes in bulk on QR Master in a way Bitly can\'t?',
answer:
'Yes. QR Master Business (€29/month) supports CSV and Excel upload for bulk creation of up to 1,000 unique QR codes per batch. Each code in the batch can have a different destination URL, label, campaign name, and UTM parameters. Bitly does not offer bulk QR creation at any plan tier.',
'Yes. QR Master Business (€29/month) supports CSV and Excel upload for bulk creation: up to 1,000 static codes, or up to 500 dynamic ones per batch. Each code in the batch can have a different destination URL, label, campaign name, and UTM parameters. Bitly does not offer bulk QR creation at any plan tier.',
},
{
question: 'Does QR Master comply with GDPR for scan analytics?',
answer:
'Yes. QR Master hashes IP addresses server-side before any scan data is stored. No raw IP address is ever written to the database. Analytics capture device type, scan time, country-level location, and UTM parameters all without storing personally identifiable information. This is built into the infrastructure and applies from the free plan upward. Bitly is a US company with its own analytics approach EU businesses should review their DPA for GDPR compliance.',
'Yes. QR Master hashes IP addresses server-side before any scan data is stored. No raw IP address is ever written to the database. Analytics capture device type, scan time, country-level location, and UTM parameters - all without storing personally identifiable information. This is built into the infrastructure and applies from the free plan upward. Bitly is a US company with its own analytics approach - EU businesses should review their DPA for GDPR compliance.',
},
{
question: 'What happens to my Bitly QR codes if I cancel?',
@@ -131,21 +131,21 @@ const relatedLinks = [
href: '/dynamic-qr-code-generator',
title: 'Dynamic QR Code Generator',
description:
'Create QR codes built specifically for print campaigns, menus, packaging, and events with no scan limits and updateable destinations.',
'Create QR codes built specifically for print campaigns, menus, packaging, and events - with no scan limits and updateable destinations.',
ctaLabel: 'Create your first QR code',
},
{
href: '/qr-code-analytics',
title: 'QR Code Analytics',
description:
'See scan counts, device types, locations, and UTM attribution for every QR code with no caps and no upgrades required to see your own data.',
'See scan counts, device types, locations, and UTM attribution for every QR code - with no caps and no upgrades required to see your own data.',
ctaLabel: 'Explore analytics',
},
{
href: '/bulk-qr-code-generator',
title: 'Bulk QR Code Generator',
description:
'Create up to 1,000 unique QR codes from a CSV or Excel file. Each with its own URL, label, and tracking parameters. No manual creation one-by-one.',
'One upload, a whole batch: up to 1,000 static codes or up to 500 dynamic ones. Each with its own URL, label, and tracking parameters. No creating them one by one.',
ctaLabel: 'Explore bulk QR creation',
},
{
@@ -186,18 +186,18 @@ export default function BitlyAlternativePage() {
A Bitly Alternative That Actually Lets You Create QR Codes
</h1>
<p className="text-lg leading-relaxed" style={{ color: '#52525B' }}>
Bitly is a link shortener. QR codes are a secondary feature and their Core plan charges $10/month
Bitly is a link shortener. QR codes are a secondary feature - and their Core plan charges $10/month
for just 2 QR codes total. QR Master is built specifically for QR code management: 50 dynamic codes
at 9/month, bulk creation, and GDPR-compliant analytics.
</p>
</div>
<ul className="mb-10 space-y-3">
{[
'50 dynamic QR codes at €9/month not 2 codes for $10',
'50 dynamic QR codes at €9/month - not 2 codes for $10',
'Free plan: 3 dynamic QR codes + unlimited static codes, €0',
'Bulk creation from CSV/Excel up to 1,000 codes (Business plan)',
'Bulk creation from CSV/Excel: 1,000 static or 500 dynamic (Business)',
'Built for QR-specific workflows: menus, packaging, events, campaigns',
'GDPR-compliant analytics with hashed IPs no configuration needed',
'GDPR-compliant analytics with hashed IPs - no configuration needed',
].map((feature) => (
<li key={feature} className="flex items-start gap-3">
<span
@@ -243,7 +243,7 @@ export default function BitlyAlternativePage() {
<div className="space-y-5">
<div className="rounded-xl border p-5" style={{ borderColor: '#FECACA', backgroundColor: '#FEF2F2' }}>
<div className="mb-3 flex items-center justify-between">
<span className="font-semibold text-red-800">Bitly Core ~$10/month</span>
<span className="font-semibold text-red-800">Bitly Core - ~$10/month</span>
<span className="rounded-full bg-red-100 px-3 py-1 text-sm font-bold text-red-700">2 QR codes</span>
</div>
<div className="flex gap-3">
@@ -258,11 +258,11 @@ export default function BitlyAlternativePage() {
</div>
))}
</div>
<p className="mt-3 text-xs text-red-600">Marketed as "unlimited scans" but you only get 2 codes total. Need a third? Upgrade.</p>
<p className="mt-3 text-xs text-red-600">Marketed as "unlimited scans" - but you only get 2 codes total. Need a third? Upgrade.</p>
</div>
<div className="rounded-xl border p-5" style={{ borderColor: '#BBF7D0', backgroundColor: '#F0FDF4' }}>
<div className="mb-3 flex items-center justify-between">
<span className="font-semibold text-purple-800">QR Master Pro 9/month</span>
<span className="font-semibold text-purple-800">QR Master Pro - 9/month</span>
<span className="rounded-full bg-purple-100 px-3 py-1 text-sm font-bold text-purple-700">50 QR codes</span>
</div>
<div className="flex flex-wrap gap-2">
@@ -333,25 +333,25 @@ export default function BitlyAlternativePage() {
Bitly is excellent at what it was designed for: shortening URLs for social media posts, email
campaigns, and marketing links where you need a clean, short address. That core product is solid and
widely used. The problem starts when QR codes get added as a secondary feature inside a link
management tool the pricing model and workflow both reflect the link-first design.
management tool - the pricing model and workflow both reflect the link-first design.
</p>
<p>
The most glaring issue with Bitly for QR codes is the code count cap. Bitly&apos;s Core plan
(~$10/month) is marketed around &quot;unlimited clicks and scans&quot; which sounds generous.
But that plan allows a total of <strong>2 QR codes</strong>. Two. If you need a third QR code
for a second product, a second location, or a second campaign you have to jump to a more expensive
(~$10/month) is marketed around &quot;unlimited clicks and scans&quot; - which sounds generous.
But that plan allows a total of <strong>2 QR codes</strong>. Two. If you need a third QR code -
for a second product, a second location, or a second campaign - you have to jump to a more expensive
plan. For teams running any meaningful QR code operation, the code count wall is the first thing
you hit, not the scan volume.
</p>
<p>
The free plan allows exactly 1 QR code. For comparison, QR Master&apos;s free tier gives you 3
active dynamic QR codes with basic analytics and the Pro plan (9/month) gives you 50. The
active dynamic QR codes with basic analytics - and the Pro plan (9/month) gives you 50. The
economics of QR codes on Bitly force rapid upgrades the moment you have a campaign with more than
a trivial number of placements.
</p>
<p>
Beyond the code count, Bitly&apos;s QR workflow is an afterthought. The interface is built around
link management creating a short link is the primary action, and QR codes are generated as a
link management - creating a short link is the primary action, and QR codes are generated as a
secondary output from that. There is no bulk QR creation, no QR-specific analytics beyond click
counts, and no purpose-built tooling for the workflows that QR codes actually live in: restaurant
menus, product packaging, event programs, multi-location flyer campaigns.
@@ -363,8 +363,8 @@ export default function BitlyAlternativePage() {
>
<h3 className="mb-2 text-lg font-semibold" style={{ color: '#C2410C' }}>When Bitly is still the right choice</h3>
<p style={{ color: '#27272A' }}>
If you already use Bitly heavily for link shortening and genuinely need only 12 QR codes with no
expectation of growth staying on Bitly is reasonable. Consolidating tools has value. The problem
If you already use Bitly heavily for link shortening and genuinely need only 1-2 QR codes with no
expectation of growth - staying on Bitly is reasonable. Consolidating tools has value. The problem
starts the moment QR codes become a real part of your workflow, you need more than 2 codes, or you
need bulk creation. At that point the pricing math stops making sense.
</p>
@@ -433,15 +433,15 @@ export default function BitlyAlternativePage() {
<div>
<h3 className="mb-4 text-xl font-bold" style={{ color: '#18181B' }}>Code count limits and pricing at scale</h3>
<p className="text-lg leading-relaxed" style={{ color: '#52525B' }}>
Bitly&apos;s Core plan (~$10/month) advertises &quot;unlimited scans&quot; which is technically
Bitly&apos;s Core plan (~$10/month) advertises &quot;unlimited scans&quot; - which is technically
accurate but misleading. The hard limit on that plan is the number of QR codes: 2 total. The free
plan gives you 1. For most marketing use cases, running into the code count wall happens before
scan volume ever becomes an issue.
</p>
<p className="mt-4 text-lg leading-relaxed" style={{ color: '#52525B' }}>
QR Master Pro (9/month) gives you 50 active dynamic QR codes with no scan limits on redirects.
Business (29/month) gives you 500 codes plus bulk creation of up to 1,000 unique codes from a
single CSV upload. The pricing model is built around QR code management, not link click volume
Business (29/month) gives you 500 dynamic codes plus bulk creation of up to 1,000 static codes from a
single CSV upload. The pricing model is built around QR code management, not link click volume -
which means costs are predictable and don&apos;t scale with campaign success.
</p>
</div>
@@ -449,14 +449,14 @@ export default function BitlyAlternativePage() {
<div>
<h3 className="mb-4 text-xl font-bold" style={{ color: '#18181B' }}>QR-specific workflow support</h3>
<p className="text-lg leading-relaxed" style={{ color: '#52525B' }}>
QR Master is designed around QR code workflows not link management. This means the platform has
QR Master is designed around QR code workflows - not link management. This means the platform has
purpose-built generators for specific QR code types: WiFi QR codes, vCard QR codes, restaurant menu
QR codes, PDF QR codes, and more. Each type has a tailored input form and generates the correct QR
format for that use case. Bitly generates a URL-based QR code that&apos;s the only type available.
format for that use case. Bitly generates a URL-based QR code - that&apos;s the only type available.
</p>
<p className="mt-4 text-lg leading-relaxed" style={{ color: '#52525B' }}>
Bulk creation is another gap. If you are creating QR codes for a product line, an event with
multiple sessions, or a direct mail campaign creating them one at a time is not viable. QR
multiple sessions, or a direct mail campaign - creating them one at a time is not viable. QR
Master&apos;s Business plan generates up to 1,000 unique codes from a single CSV upload. Bitly has
no equivalent.
</p>
@@ -465,7 +465,7 @@ export default function BitlyAlternativePage() {
<div>
<h3 className="mb-4 text-xl font-bold" style={{ color: '#18181B' }}>Analytics depth for QR use cases</h3>
<p className="text-lg leading-relaxed" style={{ color: '#52525B' }}>
Bitly tracks clicks that is its core analytics model. For QR codes, it reports scan counts in
Bitly tracks clicks - that is its core analytics model. For QR codes, it reports scan counts in
the same way it reports link clicks. There is no device-type breakdown specific to mobile QR
scanning, no distinction between campaign placements, and no UTM parameter injection designed
for QR workflows.
@@ -473,7 +473,7 @@ export default function BitlyAlternativePage() {
<p className="mt-4 text-lg leading-relaxed" style={{ color: '#52525B' }}>
QR Master analytics are built around the QR scan as the unit of measurement. Each scan records
device type, operating system, country-level location, time, and UTM parameters. The dashboard is
organized around QR codes not links so you can see scan patterns per code, per campaign, and
organized around QR codes - not links - so you can see scan patterns per code, per campaign, and
over time in a way that makes sense for print and physical media distribution.
</p>
</div>
@@ -494,7 +494,7 @@ export default function BitlyAlternativePage() {
{[
'Anyone who hit Bitly\'s code count cap and had to upgrade just to add a third QR code',
'Marketing teams needing more than 2 QR codes for under $50/month',
'Teams creating QR codes for product packaging, event programs, or retail displays use cases Bitly has no specific tooling for',
'Teams creating QR codes for product packaging, event programs, or retail displays - use cases Bitly has no specific tooling for',
'Anyone needing bulk QR creation from CSV or Excel',
'EU businesses that need GDPR-compliant tracking without extra configuration',
].map((item) => (
@@ -512,7 +512,7 @@ export default function BitlyAlternativePage() {
</div>
<ul className="mt-4 space-y-3">
{[
'You use Bitly primarily for link shortening and genuinely need only 12 QR codes the cost of a separate QR tool doesn\'t justify the switch',
'You use Bitly primarily for link shortening and genuinely need only 1-2 QR codes - the cost of a separate QR tool doesn\'t justify the switch',
'You are already on a Bitly enterprise plan and QR codes are a minor part of a broader link management workflow that uses other Bitly features heavily',
'Your QR code count stays within Bitly\'s plan limits and you have no bulk creation needs',
].map((item) => (
@@ -567,7 +567,7 @@ export default function BitlyAlternativePage() {
{
step: '5',
title: 'Plan physical material replacement',
body: 'For anything printed flyers, packaging, business cards, menus plan replacement into your next print run. Keep your Bitly account active until all physical materials are replaced in circulation.',
body: 'For anything printed - flyers, packaging, business cards, menus - plan replacement into your next print run. Keep your Bitly account active until all physical materials are replaced in circulation.',
},
].map((step) => (
<div key={step.step} className="flex gap-6 py-7" style={{ borderBottom: step.step !== '5' ? '1px solid #E4E0D9' : 'none' }}>
@@ -596,7 +596,7 @@ export default function BitlyAlternativePage() {
<GrowthLinksSection
eyebrow="Related pages"
title="Explore QR Master"
description="See the features purpose-built for QR code workflows not link shortening with QR as a side feature."
description="See the features purpose-built for QR code workflows - not link shortening with QR as a side feature."
links={relatedLinks}
pageType="commercial"
cluster="competitor"
@@ -612,7 +612,7 @@ export default function BitlyAlternativePage() {
<h2 className="mb-4 text-4xl font-bold">50 QR codes at 9. Not 2 codes for $10.</h2>
<p className="mx-auto mb-10 max-w-2xl text-lg" style={{ color: '#A1A1AA' }}>
Start free with 3 dynamic QR codes. Pro at 9/month for 50 codes with full analytics.
Purpose-built for QR code workflows not link shortening with QR as an afterthought.
Purpose-built for QR code workflows - not link shortening with QR as an afterthought.
</p>
<div className="flex flex-col justify-center gap-4 sm:flex-row">
<TrackedCtaLink

View File

@@ -17,7 +17,7 @@ export const metadata: Metadata = {
absolute: 'QR Master vs Flowcode | Flowcode Alternative Without Forced Branding',
},
description:
'Looking for a Flowcode alternative? QR Master gives you clean, customizable QR codes without Flowcode\'s logo or scan-hijacking interstitial pages from €0 free, Pro at €9/month.',
'Looking for a Flowcode alternative? QR Master gives you clean, customizable QR codes without Flowcode\'s logo or scan-hijacking interstitial pages - from €0 free, Pro at €9/month.',
keywords:
'flowcode alternative, flowcode branding removal, flowcode white label, flowcode interstitial, flowcode pricing alternative',
alternates: {
@@ -49,7 +49,7 @@ const atAGlanceRows = [
{
useCase: 'White-label brand control',
flowcode: 'Meaningful white-label control is typically tied to higher paid tiers.',
qrMaster: 'Custom colors and logo support start on Pro at EUR 9/month.',
qrMaster: 'Colors are free on every plan. Module shapes and logo start on Pro at EUR 9/month.',
},
{
useCase: 'Direct scan experience',
@@ -59,7 +59,7 @@ const atAGlanceRows = [
{
useCase: 'Bulk QR creation',
flowcode: 'No built-in CSV or Excel bulk QR generator.',
qrMaster: 'Business supports up to 1,000 unique QR codes per bulk upload.',
qrMaster: 'Business bulk upload: up to 1,000 static codes, or up to 500 dynamic ones.',
},
{
useCase: 'EU privacy posture',
@@ -72,37 +72,37 @@ const faqItems = [
{
question: 'What is the Flowcode interstitial page and why does it matter?',
answer:
'On Flowcode\'s free tier, when someone scans your QR code, they are briefly shown a Flowcode-branded page before being redirected to your destination. This interstitial serves Flowcode\'s branding to your audience effectively using your QR code placement to advertise their product. It also affects scan tracking: the interstitial is the page being counted, which can distort your analytics. QR Master sends scanners directly to your destination with no intermediate branded page at any plan level.',
'On Flowcode\'s free tier, when someone scans your QR code, they are briefly shown a Flowcode-branded page before being redirected to your destination. This interstitial serves Flowcode\'s branding to your audience - effectively using your QR code placement to advertise their product. It also affects scan tracking: the interstitial is the page being counted, which can distort your analytics. QR Master sends scanners directly to your destination with no intermediate branded page at any plan level.',
},
{
question: 'Does Flowcode put its logo on QR codes in the free tier?',
answer:
'Yes. Flowcode\'s free tier applies a distinctive round design with Flowcode branding elements. The visual style is recognizable as a Flowcode product, not a neutral QR code. If you want a standard QR code that looks like your brand rather than Flowcode\'s, you need a paid plan. QR Master allows custom colors and logo embedding from the Pro plan (€9/month) and generates standard QR codes without third-party branding — even on the free tier.',
'Yes. Flowcode\'s free tier applies a distinctive round design with Flowcode branding elements. The visual style is recognizable as a Flowcode product, not a neutral QR code. If you want a standard QR code that looks like your brand rather than Flowcode\'s, you need a paid plan. QR Master gives you full color control on the free plan and generates standard QR codes - without third-party branding - at every tier. Module shapes and logo embedding start on Pro (€9/month).',
},
{
question: 'How much does Flowcode cost for white-label QR codes?',
answer:
'Flowcode\'s pricing for meaningful white-label starts around $49/month. Full team features, brand control, and removal of Flowcode branding typically require their higher-tier plans. QR Master Pro at €9/month includes custom colors, logo embedding, and fully branded QR codes — no Flowcode equivalent visible anywhere.',
'Flowcode\'s pricing for meaningful white-label starts around $49/month. Full team features, brand control, and removal of Flowcode branding typically require their higher-tier plans. Colors are free on QR Master. Pro at €9/month adds module shapes and logo embedding, with no QR Master branding visible anywhere at any tier.',
},
{
question: 'Does Flowcode offer bulk QR code creation?',
answer:
'Flowcode does not have a built-in bulk creation feature for generating many unique codes at once. QR Master Business (€29/month) includes CSV/Excel bulk upload to generate up to 1,000 unique QR codes per batch each with a different destination URL, label, and UTM parameters.',
'Flowcode does not have a built-in bulk creation feature for generating many unique codes at once. QR Master Business (€29/month) includes CSV/Excel bulk upload: up to 1,000 static codes, or up to 500 dynamic ones per batch - each with a different destination URL, label, and UTM parameters.',
},
{
question: 'Is Flowcode GDPR-compliant?',
answer:
'Flowcode is a US company. GDPR compliance depends on how they handle EU user data and whether their data processing agreements meet EU requirements. QR Master handles GDPR compliance at the infrastructure level: IP addresses are hashed server-side before storage, no personally identifiable scan data is retained, and analytics use anonymized signals only. This is not a setting to enable it is how the platform works.',
'Flowcode is a US company. GDPR compliance depends on how they handle EU user data and whether their data processing agreements meet EU requirements. QR Master handles GDPR compliance at the infrastructure level: IP addresses are hashed server-side before storage, no personally identifiable scan data is retained, and analytics use anonymized signals only. This is not a setting to enable - it is how the platform works.',
},
{
question: 'What happens to my Flowcode QR codes if I cancel?',
answer:
'Flowcode QR codes point to Flowcode\'s redirect infrastructure. If you cancel your paid plan and drop to the free tier, your QR codes may revert to showing the Flowcode-branded interstitial again. If you close your account entirely, the redirects stop and your printed QR codes become dead ends. Plan your migration before canceling switch to QR Master and reprint or update digital placements before closing the Flowcode account.',
'Flowcode QR codes point to Flowcode\'s redirect infrastructure. If you cancel your paid plan and drop to the free tier, your QR codes may revert to showing the Flowcode-branded interstitial again. If you close your account entirely, the redirects stop and your printed QR codes become dead ends. Plan your migration before canceling - switch to QR Master and reprint or update digital placements before closing the Flowcode account.',
},
{
question: 'Can I import my Flowcode QR codes into QR Master?',
answer:
'There is no direct import Flowcode\'s redirect infrastructure is separate from QR Master\'s. You need to re-create each dynamic QR code in QR Master with the same destination URLs. For bulk re-creation, QR Master\'s Business plan allows CSV upload so you can migrate many codes at once rather than one by one. Static QR codes are permanently encoded and do not need migration they work independently of any platform.',
'There is no direct import - Flowcode\'s redirect infrastructure is separate from QR Master\'s. You need to re-create each dynamic QR code in QR Master with the same destination URLs. For bulk re-creation, QR Master\'s Business plan allows CSV upload so you can migrate many codes at once rather than one by one. Static QR codes are permanently encoded and do not need migration - they work independently of any platform.',
},
];
@@ -131,14 +131,14 @@ const relatedLinks = [
href: '/dynamic-qr-code-generator',
title: 'Dynamic QR Code Generator',
description:
'Create QR codes with your own branding colors, logo, and design that you can update after printing without replacing the code.',
'Create QR codes with your own branding - colors, logo, and design - that you can update after printing without replacing the code.',
ctaLabel: 'Create a branded dynamic QR code',
},
{
href: '/qr-code-analytics',
title: 'QR Code Analytics',
description:
'Track every scan with device, time, and location data sent directly to your destination with no interstitial page in the way.',
'Track every scan with device, time, and location data - sent directly to your destination with no interstitial page in the way.',
ctaLabel: 'Explore QR analytics',
},
{
@@ -152,7 +152,7 @@ const relatedLinks = [
href: '/pricing',
title: 'QR Master Pricing',
description:
'Free for 3 dynamic codes. Pro at €9/month includes 50 dynamic codes, custom branding, and advanced analytics.',
'Free for 3 dynamic codes, with full color control. Pro at €9/month includes 50 dynamic codes, module shapes, logo and advanced analytics.',
ctaLabel: 'See pricing',
},
];
@@ -187,17 +187,17 @@ export default function FlowcodeAlternativePage() {
</h1>
<p className="text-lg leading-relaxed" style={{ color: '#52525B' }}>
Flowcode&apos;s free tier puts their logo on every QR code and routes your scanners through a
Flowcode-branded interstitial page. QR Master sends scanners directly to your destination no
Flowcode-branded interstitial page. QR Master sends scanners directly to your destination - no
third-party branding, no interstitials, at any plan level.
</p>
</div>
<ul className="mb-10 space-y-3">
{[
'No Flowcode branding on your QR codes even on the free plan',
'No branded interstitial page instant redirect, no Flowcode marketing in between',
'Custom colors and logo from Pro (€9/month)',
'Bulk creation up to 1,000 codes on the Business plan',
'GDPR-compliant analytics with hashed IPs built in, not a setting',
'No Flowcode branding on your QR codes - even on the free plan',
'No branded interstitial page - instant redirect, no Flowcode marketing in between',
'Colors free, shapes and logo from Pro (€9/month)',
'Bulk creation on Business: 1,000 static or 500 dynamic',
'GDPR-compliant analytics with hashed IPs - built in, not a setting',
].map((feature) => (
<li key={feature} className="flex items-start gap-3">
<span
@@ -313,20 +313,20 @@ export default function FlowcodeAlternativePage() {
<div className="space-y-6 text-lg leading-relaxed" style={{ color: '#52525B' }}>
<p>
Flowcode is a well-built product with strong design capabilities. The problem is not the product
itself it&apos;s the business model on the free tier. Flowcode monetizes free users by using their QR
itself - it&apos;s the business model on the free tier. Flowcode monetizes free users by using their QR
code placements as advertising inventory for Flowcode&apos;s own brand.
</p>
<p>
In practice, this means two things. First, the QR codes generated on the free plan are visually styled
as Flowcode products the distinctive round design with Flowcode design elements makes it clear to
as Flowcode products - the distinctive round design with Flowcode design elements makes it clear to
anyone familiar with the space that this is a Flowcode code, not a custom QR. If you&apos;re a
restaurant, a brand, or an agency putting this code on client materials, it is your placement that
Flowcode is using to advertise itself.
</p>
<p>
Second and more consequentially Flowcode&apos;s free tier routes every scan through an interstitial
Second - and more consequentially - Flowcode&apos;s free tier routes every scan through an interstitial
page before the scanner reaches your destination. That page carries Flowcode branding. You are sending
customers to your menu, product page, or campaign but they pass through Flowcode&apos;s branded
customers to your menu, product page, or campaign - but they pass through Flowcode&apos;s branded
experience first. The customer&apos;s first impression is Flowcode, not you.
</p>
<p>
@@ -342,8 +342,8 @@ export default function FlowcodeAlternativePage() {
<h3 className="mb-2 text-lg font-semibold" style={{ color: '#1D4ED8' }}>How QR Master handles this</h3>
<p style={{ color: '#27272A' }}>
QR Master does not apply third-party branding to QR codes at any plan level. The free tier generates
standard QR codes without a QR Master logo, without a forced visual style, and without an interstitial
page. Scanners go directly to your destination. Custom colors and logo embedding are available on Pro
standard QR codes - without a QR Master logo, without a forced visual style, and without an interstitial
page. Scanners go directly to your destination. Colors are free on every plan. Module shapes and logo embedding start on Pro
(9/month). White-label and your brand are the baseline, not an upgrade.
</p>
</div>
@@ -411,14 +411,14 @@ export default function FlowcodeAlternativePage() {
<div>
<h3 className="mb-4 text-xl font-bold" style={{ color: '#18181B' }}>Branding control</h3>
<p className="text-lg leading-relaxed" style={{ color: '#52525B' }}>
Flowcode&apos;s free QR codes are visually distinct the rounded, branded design is recognizable.
Flowcode&apos;s free QR codes are visually distinct - the rounded, branded design is recognizable.
If you are an agency, a restaurant, or a brand putting these on client materials, the Flowcode
aesthetic tells your audience that this is a Flowcode product. White-label where the code looks
like yours requires a paid plan.
aesthetic tells your audience that this is a Flowcode product. White-label - where the code looks
like yours - requires a paid plan.
</p>
<p className="mt-4 text-lg leading-relaxed" style={{ color: '#52525B' }}>
QR Master generates standard QR codes on all plans. On the free plan, you get a clean standard QR.
On Pro (9/month), you add your brand colors and logo to the center of the code. The baseline is
Brand colors are free on every plan. On Pro (9/month), you add module shapes and your logo in the center of the code. The baseline is
always a neutral code that belongs to your brand, not ours.
</p>
</div>
@@ -427,17 +427,17 @@ export default function FlowcodeAlternativePage() {
<h3 className="mb-4 text-xl font-bold" style={{ color: '#18181B' }}>Scan experience and interstitials</h3>
<p className="text-lg leading-relaxed" style={{ color: '#52525B' }}>
The interstitial is a real issue for anyone using QR codes in a customer-facing context. A scanner
at a restaurant table or on a product package is primed to go directly to the destination a menu,
at a restaurant table or on a product package is primed to go directly to the destination - a menu,
a product page, a contact form. An intermediate page breaks that expectation, even if it only lasts
a second or two. It&apos;s also a branding signal: Flowcode appears in the path between your brand
and your customer.
</p>
<p className="mt-4 text-lg leading-relaxed" style={{ color: '#52525B' }}>
QR Master does not show a branded interstitial page. Like any dynamic QR platform, the redirect
runs through QR Master's servers (qrmaster.net) to log the scan but the scanner sees no marketing
runs through QR Master's servers (qrmaster.net) to log the scan - but the scanner sees no marketing
content, no QR Master splash page, and no dwell-time promotion. It processes the scan and forwards
immediately. The visual and branding of the QR code itself is fully customizable colors, logo,
shape without any QR Master identity imposed on it.
immediately. The visual and branding of the QR code itself is fully customizable - colors, logo,
shape - without any QR Master identity imposed on it.
</p>
</div>
@@ -472,7 +472,7 @@ export default function FlowcodeAlternativePage() {
'Brands that want QR codes to reflect their identity, not a third-party platform',
'Agencies putting QR codes on client materials who can\'t have Flowcode branding visible',
'EU businesses that need GDPR-compliant scan tracking without configuration',
'Teams that need bulk QR creation Flowcode has no bulk generation tool',
'Teams that need bulk QR creation - Flowcode has no bulk generation tool',
'Anyone priced out of Flowcode\'s white-label tier but needing clean, functional QR codes',
].map((item) => (
<li key={item} className="flex items-start gap-2 text-gray-700">
@@ -540,7 +540,7 @@ export default function FlowcodeAlternativePage() {
{
step: '5',
title: 'Plan the physical reprint',
body: 'For printed materials menus, flyers, packaging plan the replacement into your next natural reprint cycle. Keep your Flowcode account active until the reprint is done and distributed.',
body: 'For printed materials - menus, flyers, packaging - plan the replacement into your next natural reprint cycle. Keep your Flowcode account active until the reprint is done and distributed.',
},
].map((step) => (
<div key={step.step} className="flex gap-6 py-7" style={{ borderBottom: step.step !== '5' ? '1px solid #E4E0D9' : 'none' }}>

View File

@@ -14,17 +14,17 @@ const competitor = competitors['qr-code-generator'];
export const metadata: Metadata = {
title: {
absolute: 'QR-Code-Generator.com Alternative No Bait and Switch | QR Master',
absolute: 'QR-Code-Generator.com Alternative - No Bait and Switch | QR Master',
},
description:
'Looking for a QR-Code-Generator.com alternative? QR Master gives you 3 truly free dynamic QR codes no trial that expires mid-campaign, no forced annual contracts. Transparent pricing from €0.',
'Looking for a QR-Code-Generator.com alternative? QR Master gives you 3 truly free dynamic QR codes - no trial that expires mid-campaign, no forced annual contracts. Transparent pricing from €0.',
keywords:
'qr-code-generator.com alternative, alternative to qr code generator, qr code generator free expired, dynamic qr code deactivated, qr code bait switch',
alternates: {
canonical: 'https://www.qrmaster.net/alternatives/qr-code-generator',
},
openGraph: {
title: 'QR-Code-Generator.com Alternative No Bait and Switch',
title: 'QR-Code-Generator.com Alternative - No Bait and Switch',
description:
'Your dynamic QR code stopped working after two weeks? QR Master offers 3 permanently free dynamic codes, honest pricing, and no hidden trial timers.',
url: 'https://www.qrmaster.net/alternatives/qr-code-generator',
@@ -32,7 +32,7 @@ export const metadata: Metadata = {
images: ['/og-image.png'],
},
twitter: {
title: 'QR-Code-Generator.com Alternative No Bait and Switch',
title: 'QR-Code-Generator.com Alternative - No Bait and Switch',
description:
'Your dynamic QR code stopped working after two weeks? QR Master offers 3 permanently free dynamic codes and honest pricing.',
},
@@ -44,37 +44,37 @@ const faqItems = [
{
question: 'Why did my dynamic QR code from QR-Code-Generator.com stop working?',
answer:
'QR-Code-Generator.com offers dynamic QR codes on a free trial basis typically around 14 days. After the trial ends, the code is deactivated. Your printed materials (flyers, menus, packaging) become dead ends. To reactivate, they require purchasing an annual subscription. QR Master does not do this: the 3 free dynamic codes on our free plan stay active as long as your account exists.',
'QR-Code-Generator.com offers dynamic QR codes on a free trial basis - typically around 14 days. After the trial ends, the code is deactivated. Your printed materials (flyers, menus, packaging) become dead ends. To reactivate, they require purchasing an annual subscription. QR Master does not do this: the 3 free dynamic codes on our free plan stay active as long as your account exists.',
},
{
question: 'Can I switch from QR-Code-Generator.com without reprinting everything?',
answer:
'Dynamic QR codes cannot be migrated directly because the destination URL is encoded into the QR code image itself each provider uses their own redirect infrastructure. If you are still within the deactivation period, create new dynamic codes in QR Master immediately and update your placements (digital ones) or plan your next reprint run. Static QR codes you created on QR-Code-Generator.com remain permanently valid regardless of your subscription status.',
'Dynamic QR codes cannot be migrated directly because the destination URL is encoded into the QR code image itself - each provider uses their own redirect infrastructure. If you are still within the deactivation period, create new dynamic codes in QR Master immediately and update your placements (digital ones) or plan your next reprint run. Static QR codes you created on QR-Code-Generator.com remain permanently valid regardless of your subscription status.',
},
{
question: 'What does QR Master give me for free, permanently?',
answer:
'The QR Master free plan includes 3 active dynamic QR codes with basic scan tracking and unlimited static QR codes no trial period, no credit card required, no expiration. The 3 dynamic codes are always active. If you need more, Pro starts at €9/month for 50 dynamic codes with full analytics.',
'The QR Master free plan includes 3 active dynamic QR codes with basic scan tracking and unlimited static QR codes - no trial period, no credit card required, no expiration. The 3 dynamic codes are always active. If you need more, Pro starts at €9/month for 50 dynamic codes with full analytics.',
},
{
question: 'Is the free plan at QR Master really free, or will it expire like QR-Code-Generator.com?',
answer:
'The free tier is permanently free within the defined limits. There is no 14-day clock, no activation fee, no "trial" framing. The 3 dynamic codes on the free plan continue working as long as your account is active. We make money from Pro (€9/month) and Business (€29/month) upgrades not from deactivating free users after they\'ve already printed materials.',
'The free tier is permanently free within the defined limits. There is no 14-day clock, no activation fee, no "trial" framing. The 3 dynamic codes on the free plan continue working as long as your account is active. We make money from Pro (€9/month) and Business (€29/month) upgrades - not from deactivating free users after they\'ve already printed materials.',
},
{
question: 'Does QR Master comply with GDPR for scan analytics?',
answer:
'Yes. QR Master anonymizes IP addresses using server-side hashing with a salt before any analytics data is stored. No personally identifiable IP addresses are recorded. Scan data includes device type, time, country-level location, and UTM parameters all without storing raw IPs. This is built into the platform, not a bolt-on option.',
'Yes. QR Master anonymizes IP addresses using server-side hashing with a salt before any analytics data is stored. No personally identifiable IP addresses are recorded. Scan data includes device type, time, country-level location, and UTM parameters - all without storing raw IPs. This is built into the platform, not a bolt-on option.',
},
{
question: 'What happens to my QR codes if I cancel my QR Master subscription?',
answer:
'If you downgrade from a paid plan to Free, your dynamic codes are paused (not deleted) if you exceed the 3-code free limit. You choose which 3 to keep active. Static codes are unaffected and remain permanently valid. If you close your account entirely, dynamic codes stop redirecting which is why we recommend switching to static QR codes for any permanent materials that you cannot update.',
'If you downgrade from a paid plan to Free, your dynamic codes are paused (not deleted) if you exceed the 3-code free limit. You choose which 3 to keep active. Static codes are unaffected and remain permanently valid. If you close your account entirely, dynamic codes stop redirecting - which is why we recommend switching to static QR codes for any permanent materials that you cannot update.',
},
{
question: 'Does QR Master support bulk QR code creation?',
answer:
'Yes. The Business plan (€29/month) includes bulk creation via CSV or Excel upload up to 1,000 unique QR codes per batch. Each code can have a different destination URL, label, and UTM parameters. QR-Code-Generator.com does not offer bulk creation at any tier.',
'Yes. The Business plan (€29/month) includes bulk creation via CSV or Excel upload: up to 1,000 static codes, or up to 500 dynamic ones per batch. Each code can have a different destination URL, label, and UTM parameters. QR-Code-Generator.com does not offer bulk creation at any tier.',
},
];
@@ -110,7 +110,7 @@ const relatedLinks = [
href: '/qr-code-analytics',
title: 'QR Code Analytics',
description:
'See which placements drive scans, which devices your audience uses, and where your codes are being scanned all in one dashboard.',
'See which placements drive scans, which devices your audience uses, and where your codes are being scanned - all in one dashboard.',
ctaLabel: 'Explore QR code analytics',
},
{
@@ -158,17 +158,17 @@ export default function QRCodeGeneratorAlternativePage() {
</h1>
<p className="text-lg leading-relaxed mb-8" style={{ color: '#52525B' }}>
QR-Code-Generator.com deactivates dynamic QR codes after roughly two weeks right after you&apos;ve
QR-Code-Generator.com deactivates dynamic QR codes after roughly two weeks - right after you&apos;ve
printed the flyers. QR Master gives you 3 free dynamic codes that stay active permanently.
No hidden trial, no forced annual contract.
</p>
<ul className="space-y-3 mb-10">
{[
'3 permanently active dynamic QR codes free forever',
'3 permanently active dynamic QR codes - free forever',
'Transparent pricing: Pro at €9/mo, cancel anytime',
'GDPR-compliant analytics with hashed IPs, built in',
'Bulk creation up to 1,000 codes (Business plan)',
'Bulk creation: 1,000 static or 500 dynamic (Business plan)',
].map((item) => (
<li key={item} className="flex items-start gap-3">
<span
@@ -185,13 +185,13 @@ export default function QRCodeGeneratorAlternativePage() {
<div className="flex flex-col gap-3 sm:flex-row">
<TrackedCtaLink
href="/signup"
ctaLabel="Start Free No Credit Card"
ctaLabel="Start Free - No Credit Card"
ctaLocation="hero_primary"
pageType="commercial"
cluster="competitor"
>
<Button size="lg" className="w-full h-13 px-8 text-base sm:w-auto">
Start Free No Credit Card
Start Free - No Credit Card
</Button>
</TrackedCtaLink>
<TrackedCtaLink
@@ -292,7 +292,7 @@ export default function QRCodeGeneratorAlternativePage() {
<div className="grid gap-10 md:grid-cols-2">
<div className="space-y-5 text-base leading-relaxed" style={{ color: '#52525B' }}>
<p>
QR-Code-Generator.com markets dynamic QR codes as free to create. And they are for about two weeks.
QR-Code-Generator.com markets dynamic QR codes as free to create. And they are - for about two weeks.
After roughly 14 days, those dynamic codes stop redirecting. Anyone who scans them sees a dead page
or a prompt to upgrade.
</p>
@@ -303,7 +303,7 @@ export default function QRCodeGeneratorAlternativePage() {
</p>
<p>
Hundreds of reviews on Trustpilot describe feeling &ldquo;trapped&rdquo; because the alternative
replacing all the printed materials is more expensive. The annual plan costs around
- replacing all the printed materials - is more expensive. The annual plan costs around
25.99/month billed yearly, over 300 upfront.
</p>
</div>
@@ -320,7 +320,7 @@ export default function QRCodeGeneratorAlternativePage() {
no countdown, no automatic deactivation.
</p>
<p className="text-base leading-relaxed" style={{ color: '#27272A' }}>
If you need more than 3, you upgrade to Pro at 9/month month-to-month with no forced annual
If you need more than 3, you upgrade to Pro at 9/month - month-to-month with no forced annual
commitment. Static codes are unlimited and free forever.
</p>
</div>
@@ -396,7 +396,7 @@ export default function QRCodeGeneratorAlternativePage() {
</div>
</section>
{/* Detailed Comparisons 3 cards */}
{/* Detailed Comparisons - 3 cards */}
<section className="py-24" style={{ backgroundColor: '#F8F7F4' }}>
<div className="container mx-auto max-w-5xl px-4 sm:px-6 lg:px-8">
<h2 className="text-3xl sm:text-4xl font-bold tracking-tight mb-12" style={{ color: '#111110' }}>
@@ -407,7 +407,7 @@ export default function QRCodeGeneratorAlternativePage() {
{[
{
label: 'Pricing transparency',
body: 'QR-Code-Generator.com lists dynamic codes as free true for the 14-day trial. Reactivation requires an annual plan billed upfront (€100€300+).',
body: 'QR-Code-Generator.com lists dynamic codes as free - true for the 14-day trial. Reactivation requires an annual plan billed upfront (€100-€300+).',
highlight: 'QR Master pricing is explicit: Free for 3 dynamic codes, Pro at €9/month. No fine print about trial periods or forced billing cycles.',
accent: '#D97706',
},
@@ -456,7 +456,7 @@ export default function QRCodeGeneratorAlternativePage() {
},
{
title: 'Create a free QR Master account',
body: 'Sign up at qrmaster.net no credit card required. The free plan gives you 3 active dynamic QR codes immediately.',
body: 'Sign up at qrmaster.net - no credit card required. The free plan gives you 3 active dynamic QR codes immediately.',
},
{
title: 'Re-create your dynamic codes',
@@ -464,7 +464,7 @@ export default function QRCodeGeneratorAlternativePage() {
},
{
title: 'Update digital placements first',
body: 'Replace the QR code image on your website, email signatures, social media, and digital ads no reprinting needed.',
body: 'Replace the QR code image on your website, email signatures, social media, and digital ads - no reprinting needed.',
},
{
title: 'Plan your reprint cycle',
@@ -531,7 +531,7 @@ export default function QRCodeGeneratorAlternativePage() {
</p>
<ul className="space-y-4">
{[
'You only need static QR codes both platforms generate these for free, and static codes never expire',
'You only need static QR codes - both platforms generate these for free, and static codes never expire',
'You need one quick QR code for a presentation or digital-only use where deactivation doesn\'t matter',
'You\'re already on an active annual plan and aren\'t printing new materials anytime soon',
].map((item, idx) => (

View File

@@ -59,7 +59,7 @@ export default function AuthorPage({ params }: { params: { slug: string } }) {
<div className="space-y-3">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-extrabold text-gray-900">{author.name}</h1>
<Image src="/favicon1.png" alt="QR Master" width={24} height={24} className="rounded-full object-cover opacity-90" />
<Image src="/logo.svg" alt="QR Master" width={24} height={24} className="rounded-full object-cover opacity-90" />
</div>
<p className="text-lg text-blue-600 font-medium">{author.role}</p>
<p className="text-gray-600 max-w-xl">{author.bio}</p>

View File

@@ -8,6 +8,7 @@ import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import Breadcrumbs, { BreadcrumbItem } from '@/components/Breadcrumbs';
import { blogPosts } from '@/lib/blog-data';
import { REDIRECTED_BLOG_SLUGS } from "@/lib/content";
// Enable Incremental Static Regeneration (ISR)
// Revalidate every hour
@@ -63,6 +64,7 @@ export default function BlogPage() {
const publishedPosts = blogPosts
.filter(post => {
if (REDIRECTED_BLOG_SLUGS.has(post.slug)) return false;
const publishDate = post.datePublished ? new Date(post.datePublished) : new Date(post.date);
return publishDate <= currentDate;
})

View File

@@ -14,10 +14,10 @@ import { MarketingPageTracker } from '@/components/marketing/MarketingAnalytics'
export const metadata: Metadata = {
title: {
absolute: 'Bulk QR Code Generator for Excel, CSV and Google Sheets',
absolute: 'Bulk QR Code Generator - Static or Dynamic, from Excel',
},
description:
'Generate up to 1,000 QR codes from Excel, CSV, XLSX, or exported Google Sheets data. Upload, preview, batch-create, download ZIP files, or save to your dashboard.',
'Upload a CSV or Excel file and get up to 1,000 static QR codes at once, or up to 500 trackable dynamic ones on Business. Preview every row before you generate.',
keywords:
'bulk qr code generator, bulk qr code generator excel, batch qr code generator, qr code from excel, csv qr code generator, bulk qr generator, bulk qr code generator in google sheets, spreadsheet qr generation',
alternates: {
@@ -28,17 +28,17 @@ export const metadata: Metadata = {
},
},
openGraph: {
title: 'Bulk QR Code Generator for Excel, CSV and Google Sheets',
title: 'Bulk QR Code Generator - Static or Dynamic, from Excel',
description:
'Generate up to 1,000 QR codes from CSV, Excel, XLSX, or exported Google Sheets data.',
'One spreadsheet in, a full batch out. Up to 1,000 static codes, or up to 500 trackable dynamic ones.',
url: 'https://www.qrmaster.net/bulk-qr-code-generator',
type: 'website',
images: ['/og-image.png'],
},
twitter: {
title: 'Bulk QR Code Generator for Excel, CSV and Google Sheets',
title: 'Bulk QR Code Generator - Static or Dynamic, from Excel',
description:
'Generate up to 1,000 QR codes from CSV, Excel, XLSX, or exported Google Sheets data.',
'One spreadsheet in, a full batch out. Up to 1,000 static codes, or up to 500 trackable dynamic ones.',
},
};
@@ -59,9 +59,9 @@ const featureCards = [
'The current bulk creation flow limits each upload to 1,000 rows so the batch stays predictable and reviewable.',
},
{
title: 'Static QR output',
title: 'Static or dynamic output',
description:
'Bulk creation currently generates static QR codes. These codes do not include post-print editing or tracking.',
'Choose per upload. Static for large print batches. Dynamic when the destination has to stay editable and the scans have to be countable.',
},
{
title: 'ZIP download',
@@ -117,13 +117,13 @@ const useCases = [
{
title: 'Product labels and inserts',
description:
'Generate large static batches for packaging, inserts, manuals, or support labels when every unit needs a QR code.',
'Generate a batch for packaging, inserts, manuals, or support labels. Static when the link is permanent, dynamic when the linked page will change.',
points: ['One spreadsheet as input', 'Consistent file naming', 'Printable SVG output'],
},
{
title: 'Event materials',
description:
'Produce batches for badges, handouts, booth materials, or attendee resources when a static QR is enough.',
'Produce batches for badges, handouts, booth materials, or attendee resources. Use dynamic codes when the event page changes after print.',
points: ['Batch generation from one file', 'Preview before generation', 'Download everything together'],
},
{
@@ -149,7 +149,7 @@ const faqItems = [
{
question: 'Are bulk-generated QR codes dynamic or trackable?',
answer:
'No. The current bulk creation flow generates static QR codes, so those codes do not include post-print editing or tracking.',
'Both are available. Static is the default and runs up to 1,000 rows per upload. Dynamic codes stay editable after print and are trackable, capped at 500 on Business by your dynamic code allowance.',
},
{
question: 'What file formats can I upload?',
@@ -194,12 +194,12 @@ const softwareSchema = {
availability: 'https://schema.org/InStock',
},
description:
'Generate up to 1,000 static QR codes from CSV, Excel, XLSX, or exported Google Sheets files in the QR Master Business plan.',
'Generate up to 1,000 static QR codes, or up to 500 trackable dynamic codes, from CSV, Excel, XLSX, or exported Google Sheets files on the QR Master Business plan.',
featureList: [
'CSV, XLS, and XLSX upload',
'Excel and Google Sheets CSV export workflow',
'Up to 1,000 rows per upload',
'Static QR code generation',
'Static or dynamic QR code generation',
'ZIP download of generated SVG files',
'Optional save-to-dashboard step',
],
@@ -319,8 +319,9 @@ export default function BulkQRCodeGeneratorPage() {
Bulk QR Code Generator
</h1>
<p className="text-xl leading-relaxed text-gray-600">
Generate up to 1,000 static QR codes from a CSV or Excel file. Upload,
preview, download the batch as ZIP, or save it into your dashboard.
Stop mapping spreadsheet rows to QR codes by hand. Upload a
CSV or Excel file, preview every code, and download up to
1,000 print-ready codes in minutes - static, or dynamic when you need to edit and track them later.
</p>
</div>
@@ -328,7 +329,7 @@ export default function BulkQRCodeGeneratorPage() {
{[
'CSV, XLS, and XLSX upload',
'Up to 1,000 rows per upload',
'Static QR code output',
'Static or dynamic output per upload',
'ZIP download and optional save to dashboard',
].map((feature) => (
<div key={feature} className="flex items-center gap-3">
@@ -383,7 +384,7 @@ export default function BulkQRCodeGeneratorPage() {
))}
</div>
<p className="mt-4 text-center text-sm text-gray-600">
Designed for bulk static output, not dynamic tracking.
Static for large print batches. Dynamic when you need tracking.
</p>
</Card>
<div className="absolute -right-4 -top-4 rounded-full bg-green-500 px-4 py-2 text-sm font-semibold text-white shadow-lg">
@@ -396,9 +397,9 @@ export default function BulkQRCodeGeneratorPage() {
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<AnswerFirstBlock
whatIsIt="QR Master bulk creation is a spreadsheet-driven Business-plan workflow for generating up to 1,000 static QR codes in one upload. It is useful when you need many printable QR codes quickly, not when you need post-print editing or tracking."
whatIsIt="QR Master bulk creation is a spreadsheet-driven Business-plan workflow for generating up to 1,000 static QR codes in one upload, or up to 500 dynamic ones that stay editable and trackable after print."
whenToUse={[
'You need many static QR codes from one spreadsheet instead of one-by-one creation',
'You need many QR codes from one spreadsheet instead of one-by-one creation',
'You want SVG files downloaded together as a ZIP archive',
'You are preparing labels, inserts, event materials, or other repeatable print batches',
]}
@@ -564,7 +565,7 @@ export default function BulkQRCodeGeneratorPage() {
<div className="container mx-auto max-w-4xl px-4 text-center sm:px-6 lg:px-8">
<h2 className="mb-6 text-4xl font-bold">Generate bulk QR codes without one-by-one setup</h2>
<p className="mb-8 text-xl text-green-100">
Use the Business-plan bulk flow when you need a large static QR batch from a single spreadsheet.
One spreadsheet in, a full batch out. Static up to 1,000, or dynamic and trackable up to 500 on Business.
</p>
<div className="flex flex-col justify-center gap-4 sm:flex-row">
<Link href="/pricing">

View File

@@ -94,9 +94,12 @@ export function generateMetadata({ params }: PageProps): Metadata {
follow: true,
},
icons: {
icon: [{ url: "/favicon1.png", type: "image/png" }],
shortcut: "/favicon1.png",
apple: "/favicon1.png",
icon: [
{ url: "/favicon.svg", type: "image/svg+xml" },
{ url: "/favicon.ico", sizes: "16x16 32x32", type: "image/x-icon" },
],
shortcut: "/favicon.ico",
apple: "/logo.svg",
},
openGraph: {
title,

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>
<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>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>
</ul>
</section>

View File

@@ -26,8 +26,8 @@ import {
import { MiniGenerator } from '@/components/marketing/MiniGenerator';
export const metadata: Metadata = {
title: 'Free Custom QR Code Generator with Logo & Colors',
description: 'Create custom QR codes with your logo, brand colors, and unique frames. Free designer with instant preview. Download PNG/SVG. No signup needed to try.',
title: 'Custom QR Code Generator with Logo & Brand Colors',
description: 'Put your logo and brand colors into the code itself. Live preview as you design, print-ready SVG export that stays sharp at any size. No signup needed to try.',
keywords: [
'custom qr code generator',
'qr code with logo',
@@ -337,7 +337,7 @@ export default function CustomQRCodeGeneratorPage() {
{
href: '/tools/barcode-generator',
title: 'Free Barcode Generator',
description: 'Need a 1D barcode for retail or inventory? Create EAN-13, UPC-A, and Code 128 barcodes instantly no signup required.',
description: 'Need a 1D barcode for retail or inventory? Create EAN-13, UPC-A, and Code 128 barcodes instantly - no signup required.',
ctaLabel: 'Create a barcode',
},
{
@@ -369,7 +369,7 @@ export default function CustomQRCodeGeneratorPage() {
</h1>
<p className="text-xl text-gray-600 leading-relaxed">
Add your logo, choose custom colors, and design unique frames. Professional QR codes in minutes try it free, no signup required.
Add your logo, choose custom colors, and design unique frames. Professional QR codes in minutes - try it free, no signup required.
</p>
<div className="space-y-3">
@@ -417,7 +417,7 @@ export default function CustomQRCodeGeneratorPage() {
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-5xl">
<div className="text-center mb-12">
<h2 className="text-4xl font-bold text-gray-900 mb-4">
Your Logo Won't Break the QR Code Here's Why
Your Logo Won't Break the QR Code - Here's Why
</h2>
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
QR codes have built-in error correction. Our generator uses the highest level (H = 30% redundancy), which means up to 30% of the code can be covered or damaged and still scan perfectly.
@@ -681,7 +681,7 @@ export default function CustomQRCodeGeneratorPage() {
</div>
</section>
{/* WHY CUSTOM DESIGN MATTERS STATISTICS */}
{/* WHY CUSTOM DESIGN MATTERS - STATISTICS */}
<section className="py-16 bg-white">
<div className="container mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
<div className="flex items-center gap-2 mb-3">
@@ -692,27 +692,27 @@ export default function CustomQRCodeGeneratorPage() {
Why Brand Design in QR Codes Increases Engagement
</h2>
<p className="text-gray-600 mb-10 max-w-2xl">
A <strong>custom QR code</strong> with your brand colors and logo doesn't just look better it signals trust and gets scanned more often.
A <strong>custom QR code</strong> with your brand colors and logo doesn't just look better - it signals trust and gets scanned more often.
</p>
<div className="grid md:grid-cols-2 gap-6 mb-8">
<div className="bg-purple-50 border border-purple-100 rounded-2xl p-6">
<div className="text-4xl font-extrabold text-purple-600 mb-2">+80%</div>
<p className="text-gray-700 text-sm leading-relaxed mb-3">
Color increases brand recognition by up to 80%. A branded QR code using your brand colors is recognized and associated with your business faster than a generic black-and-white grid increasing scan intent.
Color increases brand recognition by up to 80%. A branded QR code using your brand colors is recognized and associated with your business faster than a generic black-and-white grid - increasing scan intent.
</p>
<p className="text-xs text-gray-500">
Source: <a href="https://www.loyola.edu/academia/marketing/insights/brand-recognition" target="_blank" rel="noopener noreferrer" className="underline hover:text-gray-700">University of Loyola Maryland</a> Color &amp; Brand Recognition Study
Source: <a href="https://www.loyola.edu/academia/marketing/insights/brand-recognition" target="_blank" rel="noopener noreferrer" className="underline hover:text-gray-700">University of Loyola Maryland</a> - Color &amp; Brand Recognition Study
</p>
</div>
<div className="bg-blue-50 border border-blue-100 rounded-2xl p-6">
<div className="text-4xl font-extrabold text-blue-600 mb-2">+40%</div>
<p className="text-gray-700 text-sm leading-relaxed mb-3">
Adding a recognizable brand element like a logo to a functional graphic increases user engagement and trust. Familiar visual cues reduce hesitation and increase the likelihood of scanning.
Adding a recognizable brand element - like a logo - to a functional graphic increases user engagement and trust. Familiar visual cues reduce hesitation and increase the likelihood of scanning.
</p>
<p className="text-xs text-gray-500">
Source: <a href="https://www.nngroup.com/articles/visual-design-trust/" target="_blank" rel="noopener noreferrer" className="underline hover:text-gray-700">Nielsen Norman Group</a> Visual Design and Trust Research
Source: <a href="https://www.nngroup.com/articles/visual-design-trust/" target="_blank" rel="noopener noreferrer" className="underline hover:text-gray-700">Nielsen Norman Group</a> - Visual Design and Trust Research
</p>
</div>
</div>

View File

@@ -0,0 +1,216 @@
import { Metadata } from 'next';
import {
ArrowRight,
Braces,
Chrome,
Code2,
ExternalLink,
Flame,
Github,
Package,
TerminalSquare,
type LucideIcon,
} from 'lucide-react';
export const metadata: Metadata = {
title: 'Developer Ecosystem | QR Master',
description:
'Create QR codes with QR Master in VS Code, Chrome, Firefox, Python, Node.js, GitHub Actions, and your own workflows.',
alternates: {
canonical: 'https://www.qrmaster.net/developers',
},
};
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',
icon: Code2,
description:
'Generate QR codes directly in VS Code, Cursor, VSCodium, and Windsurf preview tabs.',
command: 'qrmaster.generate',
linkLabel: 'Open VSX Registry',
url: 'https://open-vsx.org/extension/qrmaster/qrmaster-generator',
},
{
title: 'GitHub Action Marketplace',
icon: Braces,
description:
'Automate QR code generation in GitHub Actions workflows and build artifacts.',
command: 'uses: knuthtimo-lab/qrmaster-cli@v1.0.0',
linkLabel: 'GitHub Marketplace Action',
url: 'https://github.com/marketplace/actions/qr-master-code-generator',
},
{
title: 'Node.js CLI & npm Package',
icon: Package,
description:
'Generate vector SVG and PNG QR codes from any terminal or Node.js application.',
command: 'npx qrmaster-cli generate "https://www.qrmaster.net"',
linkLabel: 'View on npm',
url: 'https://www.npmjs.com/package/qrmaster-cli',
},
{
title: 'PyPI Python Package',
icon: TerminalSquare,
description:
'Use QR Master from Python scripts and Django or Flask applications.',
command: 'pip install qrmaster',
linkLabel: 'View on PyPI',
url: 'https://pypi.org/project/qrmaster/',
},
{
title: 'GitHub Repository',
icon: Github,
description:
'Explore the source, clone the CLI, open issues, and build on QR Master.',
command: 'git clone https://github.com/knuthtimo-lab/qrmaster-cli.git',
linkLabel: 'Explore on GitHub',
url: 'https://github.com/knuthtimo-lab/qrmaster-cli',
},
{
title: 'Chrome Web Store Extension',
icon: Chrome,
description:
'Generate instant QR codes for any webpage, active tab, selected text, Wi-Fi, or contact card in Chrome.',
command: 'Item ID: cieplgkdeempmjknljhabookhfhejgno',
linkLabel: 'Chrome Web Store',
url: 'https://chromewebstore.google.com/detail/cieplgkdeempmjknljhabookhfhejgno',
},
{
title: 'Firefox Add-ons Extension',
icon: Flame,
description:
'Generate instant QR codes for any webpage, active tab, selected text, Wi-Fi, or contact card in Firefox.',
command: 'Add-on ID: qr-master-qr-generator',
linkLabel: 'Firefox Add-ons',
url: 'https://addons.mozilla.org/addon/qr-master-qr-generator/',
},
];
export default function DevelopersPage() {
return (
<div className="min-h-screen bg-white text-slate-900">
<section className="relative overflow-hidden border-b border-slate-200 bg-gradient-to-br from-white via-slate-50 to-blue-50/70">
<div
aria-hidden="true"
className="absolute -right-32 -top-40 h-[32rem] w-[32rem] rounded-full bg-blue-200/30 blur-3xl"
/>
<div className="container relative mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:px-8 lg:py-24">
<nav aria-label="Breadcrumb" className="mb-8 text-sm text-slate-500">
<a className="transition-colors hover:text-slate-900" href="/">Home</a>
<span className="mx-2 text-slate-300">/</span>
<span className="text-slate-700">Developers</span>
</nav>
<div className="max-w-3xl">
<span className="inline-flex items-center rounded-md border border-blue-100 bg-blue-50 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-blue-700">
Developer ecosystem
</span>
<h1 className="mt-6 max-w-3xl text-4xl font-light leading-[1.1] tracking-[-0.03em] text-slate-900 sm:text-5xl lg:text-6xl">
QR tools for every workflow.
</h1>
<p className="mt-6 max-w-2xl text-base leading-relaxed text-slate-500 sm:text-lg">
Create, automate, and ship QR codes from the editor, terminal, browser, or CI workflow you already use.
</p>
<a
href="https://github.com/knuthtimo-lab/qrmaster-cli"
target="_blank"
rel="noopener noreferrer"
className="mt-8 inline-flex items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 text-sm font-semibold text-white shadow-sm transition-all hover:bg-blue-700 hover:shadow-md"
>
Explore the repository
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</a>
</div>
</div>
</section>
<section className="border-b border-slate-200 bg-slate-50/60 py-16 sm:py-20">
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="mb-10 max-w-2xl">
<span className="text-xs font-semibold uppercase tracking-wider text-blue-600">The toolkit</span>
<h2 className="mt-3 text-3xl font-semibold tracking-[-0.02em] text-slate-900 sm:text-4xl">
Choose your environment.
</h2>
<p className="mt-4 text-base leading-relaxed text-slate-500">
Each integration lets you generate QR codes without leaving the tools you already use.
</p>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{tools.map((tool) => {
const Icon = tool.icon;
return (
<article
key={tool.title}
className="group flex min-h-[19rem] flex-col rounded-xl border border-slate-200 bg-white p-7 shadow-sm transition-all duration-200 hover:-translate-y-1 hover:border-slate-300 hover:shadow-md"
>
<div className="flex items-start justify-between gap-4">
<div className="flex h-11 w-11 items-center justify-center rounded-lg border border-blue-100 bg-blue-50 text-blue-600">
<Icon className="h-5 w-5" strokeWidth={1.8} aria-hidden="true" />
</div>
{tool.status ? (
<span className="rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-[11px] font-semibold text-amber-700">
{tool.status}
</span>
) : null}
</div>
<h3 className="mt-6 text-xl font-semibold text-slate-900">{tool.title}</h3>
<p className="mt-3 text-sm leading-6 text-slate-500">{tool.description}</p>
<code className="mt-6 block overflow-x-auto rounded-md border border-slate-100 bg-slate-50 px-3 py-2.5 font-mono text-xs leading-5 text-slate-700">
{tool.command}
</code>
<a
href={tool.url}
target="_blank"
rel="noopener noreferrer"
className="mt-auto inline-flex w-fit items-center gap-2 pt-7 text-sm font-semibold text-blue-600 transition-colors hover:text-blue-700"
>
{tool.linkLabel}
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
</a>
</article>
);
})}
</div>
</div>
</section>
<section className="py-16 sm:py-20">
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-8 sm:p-12">
<span className="text-xs font-semibold uppercase tracking-wider text-blue-600">Open source</span>
<h2 className="mt-3 max-w-2xl text-3xl font-semibold tracking-[-0.02em] text-slate-900 sm:text-4xl">
Start with the CLI, then make it your own.
</h2>
<p className="mt-4 max-w-2xl text-base leading-relaxed text-slate-500">
Browse the repository for the CLI source, command examples, and GitHub Action setup.
</p>
<a
href="https://github.com/knuthtimo-lab/qrmaster-cli"
target="_blank"
rel="noopener noreferrer"
className="mt-7 inline-flex items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 text-sm font-semibold text-white shadow-sm transition-all hover:bg-blue-700 hover:shadow-md"
>
View the repository
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</a>
</div>
</div>
</section>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More