Tapmore — self-serve QR pages for local businesses
Owners of cafés, restaurants, shops, showrooms, pharmacies, salons, clinics, gyms and other local businesses sign up, go through a five-step setup and get a live customer page with printable QR codes, all without help from us. A customer who scans a QR code gets the menu (or the products or services), Wi-Fi, a loyalty card that staff stamp at the till, a private feedback box with a Google review invitation for every customer, and a Sudoku to pass the time.
The business type changes only the wording and defaults (business.ts): a shop gets "Products", "Counter 1" and "customers"; a salon gets "Services" and a booking link; food businesses keep veg marks, allergens and calories, which other types never show. Data, routes and code names stay venue / menu / table throughout. Owners manage everything from a dashboard, can connect their Google Business Profile so every Google review gets a reply (AI-drafted, posted automatically or after approval), and pay one yearly subscription per venue. Guest-page behaviour is modelled on the analysis in docs/candour-analysis.md, with our own brand, copy and backend.
Plans, to-dos, decisions and the change log live in docs/ROADMAP.md.
Run it
pnpm install
pnpm dev # http://localhost:3000
pnpm test # unit tests (vitest)
pnpm lint
pnpm build && pnpm start
Requires Node 22+. The database is PostgreSQL:
- Locally, with nothing set up, the app runs an embedded Postgres (PGlite) saved in
./data/pg. Uploaded images and feedback photos go to ./data/ too (override with DATA_DIR). Only one process can open that local database at a time.
- With
DATABASE_URL set, it connects to that server instead: Supabase, Neon, AWS RDS or your own Postgres. Moving between hosts is a change of that one URL plus a data copy (pg_dump / pg_restore).
Migrations run automatically on start; restart the dev server after pulling a schema change.
Try the owner flow locally
- Open
/signup and create an account. You land in the setup wizard (business, menu or products, look, online, rewards), and the venue starts on a 7-day free trial (no card). Items can be typed in with categories, imported from photos with AI, or linked from another website.
- Edit the guest page, menu and loyalty settings from the dashboard. The phone preview reloads on save.
- Download QR codes or a print sheet of table cards from QR codes.
- Emails aren't sent in development. They go to the
outbox table and the server log, so copy the verification or reset link from there.
- To see
/admin, put your email in ADMIN_EMAILS and verify it.
- Billing runs in dev mode without Razorpay keys: "Subscribe" switches the venue on for a year instantly and "Cancel subscription" ends it straight away.
Five demo venues are seeded on first start (free access, no owner; hidden from the admin console and its numbers):
| URL | Shows |
|---|
/s?i=demo | Stamp card with reward tiers, hosted menu, plain Wi-Fi, Sudoku |
/s?i=demo-crm | Honeycomb Café: Bento tiles on White & Honey, Indian menu (₹, veg / non-veg marks), Wi-Fi email gate, birthday ask |
/s?i=demo-rewards | Rewards-only membership, external ordering link, custom link cards |
/s?i=demo-salon | Glow Salon: services with "From" prices, booking link, a stamp per visit |
/s?i=demo-shop | Sharma General Store: featured products and offers, a stamp per ₹500 spent |
Rewards come in three kinds: a stamp card (optionally with refer-a-friend stamps), a rewards-only members' club, or refer a friend only, with no stamps. With refer a friend only, a member shares their link; the friend gets the owner's offer (e.g. 10% off) on their first visit, and the member gets theirs once the friend has been in. Staff give both from the till ("Give welcome offer", "Give friend reward").
Subscription
One plan, billed per venue: ₹999 a year, with a 7-day free trial and no card needed. Every feature is included. No GST is charged while the business isn't GST-registered; once it is, set CHARGES_GST = true in src/lib/plans.ts (prices then show ₹999 + 18% GST, ₹1,178.82 in total) and set GSTIN.
When a venue has neither a paid subscription nor trial days left, its guest page (and everything guests reach through it: menu, loyalty card, till stamping) goes offline. The owner can still sign in, edit and subscribe, and the page comes back exactly as it was. A failed renewal keeps the page up while Razorpay retries; it goes offline when Razorpay halts the subscription. Prices and the access rules live in src/lib/plans.ts.
Emails
Every email is plain text from MAIL_FROM and is copied to the outbox table (kept 90 days; with Resend on, private links in the copy are blanked out). There are no marketing emails.
| Email | Sent when |
|---|
| Confirm your email | Sign-up, or a new link requested |
| Reset your password | "Forgot password" |
| You're invited to the till | An owner invites a staff member |
| Reconnect Google | A venue's Google access stops working (once) |
| Your {venue} rewards card | A guest joins the card (on the page or at the till), or a returning member asks for their card again |
| Your {reward} is ready | A stamp completes a reward (once per reward per day) |
| Happy birthday from {venue} | On a member's birthday, in the venue's daytime, while its birthday reward is on (with an unsubscribe link) |
Payment receipts and renewal notices come from Razorpay. Resend's free plan sends 100 emails a day, roughly 20 busy venues; upgrade before that.
Going to production
- Database and files:
- Set
DATABASE_URL to your Postgres.
- Supabase on Vercel (or any serverless host): Project → Connect → copy the Transaction pooler string (port 6543), with your database password filled in. Vercel runs many copies of the app at once; the Session pooler (port 5432) allows only about 15 connections and runs out ("max clients reached in session mode").
- Supabase on a single server (Railway, a VPS): the Session pooler string (IPv4, port 5432) is fine.
- Self-hosted:
postgres://user:pass@host:5432/tapmore, plus DATABASE_SSL=off if it has no TLS.
- To have the server's certificate checked, set
DATABASE_SSL_CA to the provider's CA (PEM text or a file path; Supabase: Project Settings → Database → SSL Configuration), or DATABASE_SSL=verify for a publicly signed certificate. Without either the link is encrypted but unchecked, and startup warns.
- For images, set the
S3_* variables to a bucket.
- Supabase: Storage → create a private bucket
tapmore → Storage settings → S3 access keys. Use the endpoint shown there (https://<project>.supabase.co/storage/v1/s3) and the project's region.
- MinIO, R2 or AWS: use their endpoint and keys.
- Without
S3_*, images stay on the server's disk under DATA_DIR. That needs a persistent volume, so it won't work on serverless hosts.
- Check both before starting:
node --env-file=.env.local scripts/check-connections.mjs.
- Rate limits are counted in the database (the
rate_limits table), so they hold across any number of instances, Vercel included. They read the visitor's address from the proxy's end of X-Forwarded-For: set TRUSTED_PROXY_COUNT=2 with Cloudflare in front of the host, or CLIENT_IP_HEADER to a header your CDN sets.
- Every table has row-level security on with no policies. Supabase's automatic REST API can't read them; the app connects as the tables' owner and isn't affected.
- Set
APP_URL to your public origin (e.g. https://www.tapmore.in). It's encoded into every QR code, so it must never change.
- Email: create a Resend account, verify your sending domain, then set and .
Configuration
| Env var | Purpose |
|---|
APP_URL | Public origin encoded into QR codes and links (falls back to the request host) |
NEXT_PUBLIC_SUPPORT_EMAIL | Support address on the help page, policies and emails (build time) |
LEGAL_ADDRESS, LEGAL_JURISDICTION_CITY, SUPPORT_PHONE | Business details on the legal and contact pages (required in production) |
LEGAL_ENTITY_NAME, LEGAL_ENTITY_TYPE | The business that runs Tapmore, e.g. Tapmore + a partnership firm. Shown in the legal fine print, the Contact page and on payments (required in production) |
LEGAL_REGISTRATION | Optional: Udyam number, firm registration, or later CIN / LLPIN, printed next to the business name |
SUPPORT_HOURS, GSTIN | Optional: support hours (default Mon–Sat 10–6 IST), GSTIN once registered |
GRIEVANCE_OFFICER_NAME, GRIEVANCE_OFFICER_EMAIL | The Grievance Officer named on the Contact and privacy pages (email defaults to the support address); startup warns until set |
DATABASE_URL | PostgreSQL connection string (Supabase, Neon, RDS, self-hosted). Without it, an embedded Postgres in DATA_DIR/pg is used |
DATABASE_SSL | off for a server without TLS (localhost is detected automatically); verify to check a publicly signed certificate |
DATABASE_SSL_CA | The database's CA certificate (PEM text or file path), so its identity is checked |
DATABASE_POOL_SIZE | Connections per server (default 5) |
S3_BUCKET, S3_ENDPOINT, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY | Store images and feedback photos in any S3-compatible bucket (Supabase Storage, MinIO, R2, AWS). Without them they're saved under DATA_DIR |
DATA_DIR | Local folder for the embedded database and, without S3_*, images and feedback photos (default ./data) |
|
Routes
Owners
| Path | What |
|---|
/ | Marketing page with pricing and demos |
/signup, /login, /forgot-password, /reset-password | Accounts (email + password) |
/onboarding | Five-step setup wizard that creates a venue (business, menu or products, look, online, rewards) |
/dashboard | Venue list (or straight into your only venue) |
/dashboard/<venue> | Overview: scans, guests, feedback, setup checklist |
/dashboard/<venue>/design, /menu, /loyalty | Editors for the guest page, menus and loyalty/capture |
/dashboard/<venue>/guests, /feedback | Guest list (with CSV export) and feedback inbox |
/dashboard/<venue>/reviews | Google reviews: connect the Business Profile, reply settings, drafts to approve, replies posted |
/dashboard/<venue>/qr, /qr/print | QR downloads (PNG/SVG, per-table sources) and printable table cards |
/dashboard/<venue>/billing, /settings | Plan, venue details, page address, delete venue |
/dashboard/account | Name, password, delete account |
/admin | Operator view: all accounts and venues, suspend, extend trials, give free access, cancel a renewal, delete a venue, revenue, account actions (resend emails, reset link, block, delete) and an activity log of every admin change |
/staff, /staff/stamp?c=<card> | Till screens for paired staff devices: find a member, add stamps, redeem rewards, undo |
Guests
| Path | What |
|---|
/s?i=<code>&s=<source> | Landing page (server-rendered, venue theme applied before first paint). cta=box|anon forces the feedback-card A/B variant. |
/menu?i=<code> | Hosted menu, products or services: search, section jump bar; food businesses also get the allergen filter and dietary tags. Each business group has its own looks (cards with photos or a printed menu; a product grid or a simple list; a service list or a rate card), and shops and services can add "Ask/Book on WhatsApp" buttons |
/card/<id>?t=<token> | Member's web card (linked from the enrolment email), where they can also delete their card and details |
/consent?token= | Old offers-confirmation links (offers consent is no longer asked; joining is agreeing to the membership) |
/privacy, /terms, /refunds, /shipping, /contact | Privacy policy (DPDP), terms, cancellation and refunds, delivery policy, contact |
/unsubscribe?token= | Stop birthday emails from one venue (also one-click from mail apps) |
/media/<venue>/<file> | Images uploaded by owners |
APIs: api/auth/*, api/account, api/dashboard/venues/* and api/admin/* are cookie-authenticated, and their writes are refused from other origins. api/billing/webhook is signed by Razorpay. api/google/connect and api/google/callback run the Google sign-in (the callback checks a one-time state token bound to the owner and venue); api/google/notifications takes Pub/Sub pushes and checks GOOGLE_PUSH_TOKEN. The guest APIs (api/venues, api/loyalty, api/feedback, api/guests, api/consent, api/events) need no sign-in and are rate limited.
Security notes
- Passwords are hashed with scrypt. Sessions are random tokens in an HttpOnly, SameSite=Lax cookie, stored hashed in
sessions. A password reset or change signs out other devices.
- Every dashboard page and API checks venue ownership on the server.
src/proxy.ts only does an early redirect to the login page.
- Uploads are identified by their bytes (JPEG/PNG/WebP/GIF only, so no SVG) and capped at 5 MB. Guests' feedback photos are only served to the venue's owner.
- Saved configs pass a strict zod schema (
src/lib/venue/schema.ts): http(s) links only, hex colours only, length limits.
- Guests can't earn stamps on their own: a feedback stamp needs the one-time receipt from that feedback post, and join stamps are decided by the server. A returning member is emailed their card link, never shown it, since anyone can type an email.
- Behind the Wi-Fi email gate, the password isn't sent to the page until the guest has given an email.
- Old page addresses stay tied to their venue (and deleted venues' addresses stay reserved), so a printed QR code can never lead to someone else's page.
- Pages can only be framed by our own (dashboard preview), with HSTS, nosniff and a strict referrer policy on every response.
- Owners' Google refresh tokens are encrypted with AES-256-GCM (
src/server/crypto.ts). Disconnecting revokes the token at Google and deletes every review and reply saved for the venue. Review text goes to the AI with an instruction to treat it as data, and pre-connection reviews are never answered automatically.
- A daily clean-up deletes expired sessions and links, email copies after 90 days and page analytics after 400 days. Email copies keep no working private links once real email is on.
Code map
src/app/ holds the routes: pages and api/* handlers.
src/components/landing/ holds the guest page. src/components/dashboard/ holds the owner UI: editors, wizard and shell.
src/lib/ holds pure logic shared by client and server: plans, venue schema and entitlements, theme, feature rules, validation, i18n, sudoku, API contracts.
src/server/ holds the database, auth, repositories and services. Only route handlers and server pages import it.