Skip to content

Playbook: a new product in the line

A developer who is launching the next product of the Exority Studio line and has to give it what AI Sticker Pack already has: a landing page with a request form, a server that stores every request, and status cards in the team’s shared Telegram group. You have never spoken to the author of the existing code.

  • The reference chain is: landing form → server action → POST /leads with a shared secret → SQLite row → a bot loop publishes a card with status buttons into the requests group → commands with a product prefix.
  • Several products’ bots live in one Telegram group. Everything visible there carries a product prefix: commands (/stickers_stats), callback data (stickers_lead:…), the request hashtag (#stickers_42). A new product takes its own prefix, for example dj_ or raffle_.
  • In the product repository the prefix is not one setting. It is hard-coded in about twenty places across five files. The table in “What to reuse and what to parametrise” lists every one.
  • Two ways to build it: copy the leads module into the new product’s backend (works today, about ten files) or extract a shared multi-product leads service (a proposal; no such code exists).
  • DJ Controller already has a landing page, but its form posts straight to Telegram with different fields. It needs a schema decision before it can use the leads server.
  • Roll out the server first, then the landing page. Removing the landing page’s secret reverts to direct sending.
flowchart LR
    V["Visitor"] --> F["Landing form"]
    F --> A["Server action on Vercel"]
    A -- "POST /leads + secret header" --> API["Leads API process"]
    API --> DB[("SQLite: leads")]
    DB --> O["Outbox loop in the bot process"]
    O -- "card with status buttons" --> G["Shared requests group"]
    G -- "button presses and prefixed commands" --> O
    A -. "fallback: sendMessage" .-> G

The pieces, with the files that implement them for AI Sticker Pack:

Piece Where File
Form, honeypot, minimum fill time, per-IP limit landing src/app/actions/submitRequest.ts
Routing: leads server, fallback, console landing src/lib/deliverRequest.ts
POST /leads: secret, validation, limits, dedup, insert product, API process bot/leads_api.py
Pure logic: validation, card text, keyboard, stats, CSV product bot/services/leads.py
Outbox loop, reminders, digest, retention product, bot process bot/leads_bot.py
When to remind, when to send the digest, when to erase product bot/services/leads_schedule.py
Status buttons and group commands product, bot process bot/handlers/leads.py
Tables leads, lead_status_history, lead_repeats product bot/db/models.py
Settings LEADS_* and their validation product bot/config.py
The original specification (Russian) product docs/LEADS_TZ.md

The flow itself is described step by step on Lead submission and Lead status.

Decision What it is Why
Outbox The API only inserts a row and answers. A loop in the bot process picks up rows with published_at IS NULL every 2 s and sends the card. Retries after 5 s, 15 s, 1 min, 5 min, then every 15 min; an admin alert after 5 failures A request survives a crash or restart of the bot and is published when the bot comes back. A background task inside the API would be lost on restart. One process is the only sender to Telegram
Idempotent request_id The form creates a UUID when it opens and sends it with every attempt. request_id is UNIQUE; a repeat returns 200 with the existing number A retry after a dropped connection must not create a second request
Shared secret The landing server sends a header; the API compares it with hmac.compare_digest. Wrong secret: 401, and after 5 failures in 10 minutes from one caller, 429. No secret configured: 404 The endpoint is public on the internet but only the landing server may write. A disabled endpoint does not reveal that it exists
HMAC IP hash The landing passes the visitor’s IP; the server stores only HMAC-SHA256(salt, ip), truncated to 32 hex characters The server sees Vercel’s address, not the visitor’s, so the landing has to pass it. Rate limiting needs a stable key per visitor, and storing a raw IP is avoidable personal data. Without the salt the hash cannot be reversed by enumeration
Fallback channel On timeout (5 s), network error, 5xx, 401 or 404, the landing sends the request straight to the group with sendMessage, with a first line starting ⚠️ Запасной канал (“fallback channel”) and the request_id The visitor’s request is never lost because the server is down. Such a request is not in the database; an admin handles it by hand
No fallback on 429 and 422 429 returns “too many requests”; 422 returns an error and is logged A fallback on 429 would be a way around the server’s limit. 422 means the landing and the server disagree on the rules: a bug to fix, not to paper over
Dedup of repeats The same Telegram handle (case-insensitive) or the same phone within 24 hours of an existing request that is neither spam nor erased is recorded in lead_repeats. The original card is rewritten with a “repeat” block and a short reply pings the group People submit twice. One card per person per day keeps the group readable and the statistics honest
Prefix isolation Commands, callback data and the hashtag all start with the product’s prefix Several products’ bots are members of one group. A bare /stats would be answered by every bot that can read the group, and a hashtag without a prefix would mix products in search
Kill switch LEADS_ENABLED=0 removes the router and the loop and makes /leads answer 404 Rollback without a deploy
Guest flow isolation The product’s guest-facing routers are filtered to private chats; the leads router is filtered to the one group and is included last Once a product bot joins a group, its /start must not launch the guest flow there

Limits currently configured by default: 5 requests per visitor per 10 minutes and 60 requests per hour overall (LEADS_IP_LIMIT, LEADS_IP_WINDOW_SEC, LEADS_HOURLY_LIMIT in bot/config.py). A request body over 16 KB is refused with 413.

These parts contain no product name and can be copied unchanged.

Part Notes
The three tables and their indexes Names are generic: leads, lead_status_history, lead_repeats
Outbox loop, back-off schedule, alert thresholds run_outbox, publish_due, notify_repeats
SlidingLimit, secret_ok, ip_hash, clean_ip, parse_utm Pure functions
Status set and button layout new, in_work, contacted, deal, refused, spam
Reminder and digest timing Weekdays 9:00–21:00 for reminders, 10:00 daily digest with catch-up until 12:00, in the Tashkent time zone
Retention Spam erased after 30 days, other requests after 365 days from the last status change
Test-request convention A request with тест or test in the name or comment can be deleted completely
Environment variable names LEADS_ENABLED, LEADS_SECRET, LEADS_IP_SALT, LEADS_CHAT_ID, LEADS_ADMIN_IDS, LEADS_CARD_PII, the three limits. They are per process, so they do not collide between products
Log markers LEAD_NEW, LEAD_PUBLISHED, LEAD_STATUS, LEADS_CMD and the rest are not prefixed; each product logs to its own service unit

Every place the stickers prefix or the product name is hard-coded

Section titled “Every place the stickers prefix or the product name is hard-coded”

Found by searching the leads module of the product repository at the verified commit. Line numbers drift; search for the quoted text.

# What File Current value For DJ Controller For Raffle
1 Status-button callback prefix bot/services/leads.py, CALLBACK_PREFIX stickers_lead dj_lead raffle_lead
2 Picker-button callback prefix bot/services/leads.py, PICK_PREFIX stickers_pick dj_pick raffle_pick
3 Request hashtag bot/services/leads.py, HASHTAG #stickers_ #dj_ #raffle_
4 Command menu (names and descriptions with examples) bot/services/leads.py, COMMANDS stickers_stats, stickers_new, stickers_lead, stickers_export, stickers_forget dj_stats … raffle_stats …
5 Command handlers — literal strings, not derived from COMMANDS bot/handlers/leads.py, five Command("stickers_…") decorators same five names dj_… raffle_…
6 Card title bot/services/leads.py, card_text Заявка #stickers_<n> · AI Sticker Pack · DJ Controller · Raffle
7 Statistics title bot/services/leads.py, stats_text 📊 Заявки AI Sticker Pack — … product name product name
8 Product metric in statistics bot/services/leads.py, stats_text; db.packs_stats a line counting generated sticker packs replace with the product’s own metric or drop same
9 Digest title and product metric bot/services/leads_schedule.py, digest_text; db.packs_between ☀️ Стикеры за сутки (…), Паков сгенерировано: N rename; replace or drop the metric same
10 Digest “already sent” settings key bot/leads_bot.py, DIGEST_SETTING stickers_digest_last dj_digest_last raffle_digest_last
11 CSV file name bot/handlers/leads.py, cmd_export stickers_leads_<date>.csv dj_leads_<date>.csv raffle_leads_<date>.csv
12 CSV caption bot/handlers/leads.py, cmd_export Заявки AI Sticker Pack: N шт. product name product name
13 Number parser strips the hashtag prefix bot/handlers/leads.py, _number_str removeprefix("stickers_") dj_ raffle_
14 Hints inside bot answers bot/handlers/leads.py: answers that mention /stickers_lead, /stickers_forget, /stickers_export; FALLBACK_TEXT six strings rewrite each rewrite each
15 Hint inside an admin alert bot/leads_bot.py, publish_due …видна в /stickers_new… /dj_new /raffle_new
16 Default bot username in the export hint bot/handlers/leads.py, cmd_export a literal fallback used when BOT_USERNAME is unset the product’s <bot_username> same
17 Secret header name bot/leads_api.py, SECRET_HEADER X-Stickers-Leads-Secret X-Dj-Leads-Secret X-Raffle-Leads-Secret
18 Secret header name, landing side landing src/lib/deliverRequest.ts, LEADS_HEADER X-Stickers-Leads-Secret must equal row 17 must equal row 17
19 Direct and fallback message title landing src/lib/requestMessage.ts AI Sticker Pack — заявка с лендинга product name product name
20 Browser storage keys landing src/lib/requestSent.ts, src/lib/theme.ts ai-sticker-pack.request-sent, ai-sticker-pack.theme dj-controller.… (already so in dj-landing) raffle.…
21 Accepted locales bot/services/leads.py, LOCALES ru, uz, en; anything else is stored as en DJ’s landing has en and ru only — no change needed match the landing
22 Alert de-duplication keys bot/leads_api.py, bot/leads_bot.py leads_hourly_limit, leads_publish unchanged in a separate backend; must be prefixed in a shared process same
23 Tests tests/test_leads_*.py the prefix appears in seven of the eight files — all except test_leads_api.py replace when porting replace when porting

Rows 1–4 are constants. Rows 5–16 are strings scattered through handlers and texts: changing the constants alone leaves the bot answering to the old command names.

Recommended first step, whichever option you choose: in the copy, introduce one constant PREFIX = "dj" and derive rows 1–5, 10, 11 and 13–15 from it, and one constant PRODUCT_NAME for rows 6, 7, 9 and 12. Then the next product is a two-line change.

Telegram limits to keep in mind when choosing a prefix: a command name is at most 32 characters of lowercase Latin letters, digits and underscores; callback data is at most 64 bytes. raffle_lead:<id>:contacted fits comfortably.

Option A — copy the leads module into the new product’s backend

Section titled “Option A — copy the leads module into the new product’s backend”

Each product runs its own leads API and its own outbox loop, with its own database and its own bot.

What the module needs from its host, which you must provide in the new backend:

Dependency Used for
A settings object with the LEADS_* fields and start-up validation all files
An async database layer with about 25 lead_*, leads_* and repeat_* methods API, loop, handlers
A schema migration step that creates the three tables start-up
A key-value settings table (get_setting, set_setting) the digest’s “already sent today” mark
An admin alert function with a cooldown key flood and publish-failure alerts
A FastAPI app to mount the router on, and a reverse proxy that forwards /leads API
An aiogram dispatcher in polling mode whose allowed_updates include messages and callback queries handlers
A supervisor that runs the outbox loop beside polling and never lets it die bot process

Option B — a shared multi-product leads service

Section titled “Option B — a shared multi-product leads service”

One service owns POST /leads for all products, one database with a product column, and one registry that maps a product to its prefix, display name, secret, group and admins. Sketch:

  • leads.product TEXT NOT NULL; the request number stays per product so hashtags remain #dj_1, #dj_2.
  • The secret identifies the product, or the body carries product and each product has its own secret.
  • Commands, callback prefixes and titles are generated from the registry.
  • Either one shared bot posts every product’s cards, or the service holds one bot token per product and runs one polling loop per token.
  • Product metrics in statistics and the digest (generated packs, played tracks, tickets) come from a small per-product plug-in or are dropped.
Criterion A: copy per product B: shared service
Exists today yes, as the AI Sticker Pack implementation no
Effort for the second product port about ten files and eight test files; rename per the table above design, build and migrate before any product benefits
Effort for the fifth product the same again, plus four copies to keep in sync one registry entry
A bug fix applied once per copy applied once
Failure isolation one product’s outage does not touch the others one outage stops requests for every product (the landing fallback still delivers to the group)
Stack requirement the new backend must be Python with aiogram, FastAPI and SQLite, or the module is rewritten none on the products
Cross-product view none; statistics are per product one export, one dashboard
Personal data spread over several databases and backups in one place: one retention job, one erase command, one bigger target
Product-specific metrics in the digest trivial, same process and database needs a plug-in or an extra call
Secrets independent per product one service holds all of them

Guidance. For the second product, choose A and do the constant extraction above while copying. Revisit B when a third product arrives or when the copies start to diverge. Whether the DJ Controller and Raffle backends are Python at all was not examined for this page; if one is not, option A becomes a rewrite and B becomes more attractive.

Starting from a landing page built on the shared template (see the last section):

  1. Decide the fields. The leads server validates name, telegram and phone strictly, and its repeat detection keys on Telegram handle and phone. Either adopt those three required fields plus optional date, guests, comment, or change server validation, the card, the CSV columns and repeat detection for your product.

  2. Copy four files from ai-stickers-landing/src/lib/: deliverRequest.ts, requestSchema.ts, requestMessage.ts, rateLimit.ts. Also requestFields.ts (field list without the validator, so the form does not load zod on the first screen), submitState.ts and requestSent.ts.

  3. Copy the server action src/app/actions/submitRequest.ts.

  4. Rename LEADS_HEADER, the message title and the storage key (rows 18–20 above).

  5. Form component. Add hidden inputs locale, utm, startedAt and requestId, and the honeypot field company. Generate requestId with crypto.randomUUID() when the form mounts, not on submit, so a retry reuses it. Load the schema with await import on submit.

  6. Dictionaries. Add labels, placeholders, error codes (nameRequired, tooLong, telegramInvalid, phoneInvalid), the consent line under the button and the “sent” screen texts in every language.

  7. Environment. Add to .env.example and to Vercel:

    Variable Value
    LEADS_ENDPOINT the product’s https://<server>/leads, Production only
    LEADS_SECRET the same value as on the server
    TELEGRAM_BOT_TOKEN the product bot’s token, for the fallback
    TELEGRAM_CHAT_ID <LEADS_CHAT_ID>
    NEXT_PUBLIC_SITE_URL the landing’s public address

    None of the first four may be prefixed NEXT_PUBLIC_.

  8. Tests. Port deliverRequest.test.ts (ten cases: success, duplicate, timeout fallback, network fallback, 429, 422, both channels failing, no leads variables, no variables at all, secret never in client code), requestSchema.test.ts and requestSent.test.ts. Add them to the test script in package.json.

  9. Keep both sides’ validation identical. The server’s test file reuses the landing’s examples on purpose. A rule changed on one side only produces 422 for real visitors.

Verified in dj-landing/src/app/actions/submitRequest.ts: the action formats a message and calls Telegram’s sendMessage directly. There is no leads server call.

Aspect dj-landing ai-stickers-landing To do for DJ
Delivery inline in the action: sendMessage, or console.info without variables deliverRequest.ts: leads server, then fallback, then console copy deliverRequest.ts, call it from the action
Request storage none SQLite on the leads server stand up the server side first
Fields name, role, contact, date, comment name, telegram, phone, date, guests, comment decide: adopt Telegram + phone, or extend the server
role required, one of dj, venue, organizer, other does not exist add a column and a card line, or fold it into comment
Contact one free-text field, 3–120 characters Telegram handle normalised to @name; phone normalised to +digits the server rejects a free-text contact with 422
guests does not exist optional, up to 40 characters simply omit; the server treats it as optional
date length up to 40 up to 60 no conflict
Validator full zod, imported statically zod/mini, imported on submit switch when copying requestSchema.ts
Schema helpers fieldErrors inside requestSchema.ts split into requestFields.ts copy the split
Message built inline; first line Заявка с лендинга (“request from the landing”) — no product name requestMessage.ts; title names the product; the handle is a link entity copy and set the product name: in a shared group an unnamed message cannot be attributed
Link previews disable_web_page_preview: true link_preview_options: { is_disabled: true } comes with the copy
request_id none UUID from the form, validated as v4, regenerated server-side if missing add the hidden input
Visitor IP to the server not sent client_ip from x-forwarded-for comes with the copy
Timeout on outgoing calls none 5 s to the leads server, 10 s to Telegram comes with the copy
Honeypot, 3 s minimum fill, 5 per 10 min per IP present, identical present nothing
“Sent” flag dj-controller.request-sent, never expires ai-sticker-pack.request-sent, expires after 24 h optional; copy requestSent.ts and keep the DJ key
Environment TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, NEXT_PUBLIC_SITE_URL those plus LEADS_ENDPOINT, LEADS_SECRET add two variables
Tests dictionaries, schema dictionaries, packs, schema, delivery, “sent” flag, scene steps port delivery and “sent” tests
Locales en, ru; default en ru, uz, en; default ru the server already accepts both

For Raffle there is no landing page yet. Start it from ai-stickers-landing, not from dj-landing: it is the newer of the two and already contains the delivery code, the watchdog and the performance work.

One group receives the requests of every product.

  1. Create or choose the product’s bot. Its token goes to the backend and, for the fallback, to the landing’s Vercel variables.
  2. Add the bot to the requests group and make it an administrator. At start-up the reference implementation checks the bot’s membership (check_group) and logs a warning if it is only a member or not in the group at all.
  3. Get the group id and set LEADS_CHAT_ID=<LEADS_CHAT_ID> on the server. A supergroup id is negative.
  4. Set LEADS_ADMIN_IDS to the Telegram user ids of the people who may press buttons and call commands. These people should also be administrators of the group in Telegram, see the next point.
  5. Command menu. At start-up the bot registers its commands with the scope “administrators of this chat” (BotCommandScopeChatAdministrators). Only group administrators see the menu. A person in LEADS_ADMIN_IDS who is not a group administrator can still type the commands; the bot checks permissions against LEADS_ADMIN_IDS, not against group roles.
  6. Start a private chat with the bot once, for every admin who will use the export command. The CSV goes to the caller’s private chat, and a bot cannot write first.
Bot’s situation in the group What it receives
Member, privacy mode on (Telegram’s default) only commands addressed to it (/dj_stats@<bot_username>), replies to its own messages, and button presses on its own messages
Administrator, or privacy mode off every message in the group

Consequences:

  • A product bot that is only a member does not react to a command typed without @<bot_username>. Making it an administrator fixes that, and is also what the menu scope needs.
  • Every bot that is an administrator can read every card posted by every other product, including names, handles and phone numbers. So can every human member. Decide whether that is acceptable before adding a product. LEADS_CARD_PII=0 publishes cards without contacts; the contacts then stay in the database only.
  • Because administrator bots receive all messages, an unprefixed command such as /stats would reach all of them. That is the reason for the prefix.
  • A button press is delivered only to the bot that sent the message. With one bot per product, callback prefixes are a second line of defence; with a shared bot they are the only routing.
One bot per product One shared bot
Fits option A (and B with several tokens) B only
Polling each backend polls its own token one process must poll: Telegram allows a single consumer of updates per bot token
Card author in the group the product’s own bot, so the source is obvious one bot; only the title and hashtag tell products apart
Guest-facing behaviour the product bot also serves its guests in private chats; group handling must be isolated from that the shared bot can be dedicated to the team
Fallback from the landing uses the same product bot’s token every landing holds the shared token
Menu each bot shows its own five commands to group admins one menu with 5 × N commands
Blast radius of a leaked token one product all products

The reference implementation uses one bot per product, and that bot is also the product’s guest bot. Note one effect: a bot does not receive its own messages, so fallback messages sent with the same token never enter the database. They stay as plain messages for manual handling.

  1. Server, in a test group. Deploy the backend with the leads module. Set LEADS_SECRET (at least 24 characters), LEADS_IP_SALT, LEADS_ADMIN_IDS, and LEADS_CHAT_ID pointing at a test group. In production mode the reference implementation refuses to start with a shorter secret or an empty salt when the secret is set.

    Terminal window
    python -c "import secrets; print(secrets.token_urlsafe(32))"
  2. Check the logs for the lines that say the command menu was set and which chat the loop publishes to.

  3. Send a test request with curl, marked as a test:

    Terminal window
    curl -sS -X POST "https://<server>/leads" \
    -H "Content-Type: application/json" \
    -H "X-Dj-Leads-Secret: <LEADS_SECRET>" \
    -d '{"request_id":"<fresh UUID v4>","name":"ТЕСТ Guest Name","telegram":"@example_user","phone":"+<digits>","comment":"ТЕСТ","locale":"ru"}'

    Expect 201 and {"ok": true, "lead": <n>, "duplicate": false}, then a card in the test group within a few seconds. Send the same body again and expect 200 with "duplicate": true and no second card.

  4. Exercise the card. Press each status button, call each command, and call the export.

  5. Delete the test request completely: /dj_forget <n> тест. This works only because the name or comment contains the test marker. Confirm the statistics no longer count it.

  6. Switch LEADS_CHAT_ID to the real group and restart. Nothing reaches the group yet, because no landing calls the endpoint.

  7. Landing. Set LEADS_ENDPOINT and LEADS_SECRET in Vercel for Production and redeploy.

  8. Send one test request through the real form, with ТЕСТ in the name. Check the card, then delete it as in step 5.

  9. Break it on purpose once. Point a Preview deployment’s LEADS_ENDPOINT at an address that does not answer and submit. A message with the fallback warning line must appear in the group and the visitor must see success.

Level Action Effect
Landing only remove LEADS_SECRET (or LEADS_ENDPOINT) in Vercel and redeploy the form sends straight to Telegram, as before the integration. Requests are no longer stored
Server, module off set LEADS_ENABLED=0 and restart both processes /leads answers 404; the landing treats 404 as “fall back” and sends straight to the group. Buttons on old cards answer with a notice instead of spinning
Server, full deploy the previous backend version as above; the three tables stay in the database and are harmless

Do the landing rollback first if both are needed: it stops new writes immediately.

Server side:

  1. Prefix chosen; it is unique among the bots in the group; command names fit 32 characters.
  2. Every row of the hard-coded table changed; a search for the old prefix in the new module returns nothing.
  3. Product name set in the card title, statistics title, digest title and CSV caption.
  4. Product metric in statistics and digest replaced or removed.
  5. Three tables created by the migration on a copy of the production database.
  6. LEADS_SECRET (24 characters or more) and LEADS_IP_SALT generated and stored only in the server’s environment file and in Vercel.
  7. Guest-facing routers of the product bot filtered to private chats.
  8. Leads router included last; the fallback router for orphaned buttons included after it.
  9. Outbox loop runs under the bot’s supervisor and survives an exception in one iteration.
  10. Reverse proxy forwards /leads to the API process.

Telegram:

  1. Bot is an administrator of the requests group.
  2. LEADS_CHAT_ID and LEADS_ADMIN_IDS set; every admin is a group administrator and has started a private chat with the bot.
  3. Team has agreed that cards with contacts are visible to all group members and administrator bots, or LEADS_CARD_PII=0 is set.

Landing:

  1. Field set decided and identical in the landing schema and the server validation.
  2. deliverRequest.ts, requestSchema.ts, requestMessage.ts, rateLimit.ts and helpers copied; header name, message title and storage key renamed.
  3. Form sends requestId, locale, utm, startedAt and the honeypot.
  4. Consent line under the submit button present in every language.
  5. No delivery variable has a NEXT_PUBLIC_ prefix; the test that checks this is ported and passes.
  6. .env.example and the README list the new variables.

Rollout:

  1. Verified in a test group before the real one.
  2. Test request sent by curl, duplicate confirmed, card exercised, test request deleted.
  3. Test request sent through the real form and deleted.
  4. Fallback verified once with an unreachable endpoint.
  5. Rollback steps written into the product’s deploy notes.
  6. Documentation for the new product updated in the same change.

The work is done when all of the following are true.

  • Tests ported and green. On the server, the eight files tests/test_leads_api.py, _bot.py, _commands.py, _housekeeping.py, _pick.py, _repeats.py, _schedule.py and _wiring.py, with the new prefix. On the landing, deliverRequest.test.ts, requestSchema.test.ts and requestSent.test.ts. No test makes a network call or talks to Telegram.
  • The product’s existing tests are still green, including a test that the guest /start is silent in the group and works in a private chat.
  • A test request has made the full trip: submitted through the production form with the test marker, stored with a number, published as a card, moved through at least two statuses by an admin, visible in /<prefix>_stats and in the CSV, then deleted completely with /<prefix>_forget <n> тест and gone from the statistics.
  • A duplicate has been refused: the same request_id twice gives one card.
  • A repeat has been merged: a second request with the same phone within 24 hours updates the first card instead of creating a new one.
  • The fallback has been seen once in the group, with its warning line.
  • Both rollbacks have been tried on a Preview deployment or a staging server.
  • No personal data in logs: a search of the service logs for the test name, handle and phone finds nothing.
  • An operator who was not involved can find the runbook for missing cards and follow it. Use Leads missing or in fallback as the model.

dj-landing was the first landing page of the line and ai-stickers-landing was built on its structure. A third landing page should start from ai-stickers-landing.

Part Files State in dj-landing State in ai-stickers-landing
Stack package.json Next.js 16, React 19, Tailwind v4, zod, plus the motion library the same without motion; all movement is CSS
Locale routing src/proxy.ts, src/i18n/config.ts en, ru; default en; cookie NEXT_LOCALE ru, uz, en; default ru; a “published” flag per locale
Dictionaries src/i18n/dictionaries/*.ts, src/i18n/index.ts en.ts defines the type the same pattern
Dictionary test src/i18n/dictionaries.test.ts same keys, nothing empty, forbidden promises, one form of address adds digits, apostrophes, dash and routing rules
Motion vocabulary src/lib/motion.ts, tokens in src/app/globals.css --dur-fast 180, --dur-base 400, --dur-slow 650, --dur-scene 900, --dur-flip 260; two easing curves; useMotionGate identical values, plus one spring and --dur-pack
Scroll reveal src/lib/reveal.ts inline gate and data-rv identical contract
Hero scene machine components/hero/scene/steps.ts, useSceneMachine.ts one sequence, one driver; rests after three cycles the same machine with an opening, a pause and a jump
Section shell and common sections components/sections/shell.tsx, Faq, HowItWorks, Setup, RequestForm present present
Site chrome components/site/SiteHeader, SiteFooter, LocaleSwitch present present, plus a theme toggle and a brand mark
Form protection src/lib/rateLimit.ts, honeypot, minimum fill time present the same limiter: 5 per IP per 10 minutes, in memory
SEO files src/app/robots.ts, sitemap.ts, opengraph-image.tsx per locale present present
Quality scripts scripts/shots.mjs, audit.mjs, scroll-film.mjs; SHOTS_BASE; widths 360, 768, 1280, 1920 present, without a watchdog present, with a watchdog, plus lighthouse, js-weight, contrast
Repository rules CLAUDE.md, docs/TZ.md, docs/design-plan.md present present
Hosting Vercel, Node 22, no Docker same same
Part dj-landing ai-stickers-landing For a new product
Palette near-black with a lime primary and a cyan live colour graphite with an amber primary and a coral accent; a light theme choose a new fixed palette; keep the token names
Fonts Unbounded, Onest, JetBrains Mono Unbounded, Onest, IBM Plex Mono (it has the Uzbek ʻ) keep Unbounded and Onest; pick the mono by glyph coverage
Hero scene content DJ window, room screen, waveform, cover art tablet kiosk, phone with Telegram, sticker pack draw the product’s own story; keep “one step, one device, one action”
Mock-up sections DJ control, room showcase, Serato integration, venues showcase with kiosk, sticker ribbon, group chat, consent card product-specific
Content data src/content/tracks.ts, generated covers (scripts/covers.ts) src/content/packs.ts, sticker pipeline (demo-packs, sticker-gap, assets) product-specific
Form fields name, role, contact, date, comment name, Telegram, phone, date, guests, comment see “Landing side”
Delivery direct to Telegram leads server with fallback leads server with fallback
Promise lists what the DJ product may and may not claim what the sticker product may and may not claim write the lists first; the dictionary test encodes them
Audit checks its own audit.mjs, written for its own scene (not examined in detail for this page) the checks listed on Landing quality tooling, many tied to its own class names copy the script, keep the generic groups, rewrite the scene groups
  1. Copy ai-stickers-landing without public/stickers, public/outfits, src/content/packs.ts, the three asset scripts and the hero scene’s content components.
  2. Replace the palette tokens and run npm run contrast until every pair passes.
  3. Write CLAUDE.md for the new product first: palette, fonts, what may and may not be promised, tone per language. Then encode the promise lists in dictionaries.test.ts.
  4. Rename storage keys, the message title and the secret header.
  5. Keep scripts/lib/watchdog.mjs, shots.mjs, lighthouse.mjs, js-weight.mjs and contrast.mjs as they are. In audit.mjs keep the groups for languages, themes, structure, header geometry, headline lines, overflow, palette, hover, form, layout shift and reduced motion; rewrite the groups that name hero-scene classes.
  6. Build the hero scene on steps.ts and useSceneMachine.ts. Follow Animation rules.
  7. Wire the form as described above, after the server side is live.

Last verified: 2026-11-13 against ai-sticker-pack@b715418 and ai-stickers-landing@e350ff5 (playbook example: dj-landing@558457c).