Public, unauthenticated endpoint that registers a visitor’s interest in TalkIDE and returns their queue position, the total count on the waitlist, and a unique referral code.
- The form collects: email (required), name (required), role (required, enum), project_idea (optional free text).
- Submission is idempotent — if the email is already registered the existing record is returned with its current position and referral code. No error is shown to the user; the UX flow is identical to a first-time registration.
- Queue position is calculated as:
MAX(1, rank_by_created_at - confirmed_referrals × 50), whererank_by_created_atis the 1-based ordinal position of the entry ordered bycreated_at ASC. - Confirmed referrals are capped at
WAITLIST_REFERRAL_CAP = 10(configurable constant). Maximum position benefit is therefore −500 places. The cap prevents gaming by mass-invite bots while still rewarding genuine advocacy. - A Liquibase migration
0023-create-waitlist-tables.xmlcreates the required tables (see Backend section). - After a successful join, the backend sends a confirmation e-mail via
EmailSender(see ADR-025):- 201 Created (new registration): confirmation e-mail is always sent.
- 200 OK (idempotent — duplicate e-mail): confirmation e-mail is sent only if no successfully delivered confirmation was recorded in
email_logfor this recipient within the lastWAITLIST_CONFIRMATION_RESEND_WINDOW_HOURS = 24hours (configurable constant). If a recent successful delivery exists, the e-mail is silently skipped and 200 OK is returned as usual. productionprofile: real e-mail sent via Mailgun fromnoreply@mail.talkide.app; subject “You’re on the TalkIDE waitlist!”; body includes referral link.!productionprofile (NoOpEmailSender): no e-mail is sent; content is logged to console at INFO level.
- The throttle check queries
email_logfor the most recent record of typeWAITLIST_CONFIRMATIONfor the given recipient withstatus = SENT. If such a record exists and itscreated_atis within the resend window, sending is skipped. No new column is added towaitlist_entry(its Liquibase changeset0026is immutable). - E-mail sending is best-effort — a failure from
EmailSenderis caught, logged, and recorded inemail_logwithstatus=FAILED. The join request response is never affected by e-mail delivery status.
sequenceDiagram
actor User
User->>+FE: opens /waitlist page
FE->>-User: renders form (email, name, role selector, project_idea textarea)
User->>+FE: fills form and clicks "Join the waitlist"
FE->>FE: validate form
alt form is invalid
FE-->>User: show inline error messages <br> keep submit button disabled
end
FE->>+BE: POST /api/v1/waitlist <br> WaitlistJoinRequest
BE->>BE: validate request
alt request is invalid
BE-->>FE: 400 Bad Request <br> ErrorResponse
end
BE->>DB: SELECT * FROM waitlist_entry WHERE email = ?
alt email already registered (idempotent)
BE->>DB: SELECT position and referral stats for existing entry
BE->>DB: SELECT latest WAITLIST_CONFIRMATION from email_log <br> WHERE recipient = ? AND status = 'SENT'
alt last sent confirmation is within WAITLIST_CONFIRMATION_RESEND_WINDOW_HOURS (24 h)
BE->>BE: skip e-mail send (throttled)
else no recent confirmation found
BE->>+BE: send confirmation e-mail via EmailSender (best-effort)
BE-->>-BE: result recorded in email_log; response unaffected
end
BE->>-FE: 200 OK <br> WaitlistJoinResponse (existing record)
FE->>-User: show success screen with position + referral block
else new registration
BE->>DB: INSERT INTO waitlist_entry (email, name, role, project_idea, referral_code, referred_by_id)
BE->>DB: SELECT rank and total count
BE->>BE: calculate position (rank - min(confirmed_referrals, WAITLIST_REFERRAL_CAP) × 50, floor 1)
BE->>+BE: send confirmation e-mail via EmailSender (best-effort)
BE-->>-BE: result recorded in email_log; response unaffected
BE->>-FE: 201 Created <br> WaitlistJoinResponse
FE->>-User: show success screen with position + referral block
end
POST /api/v1/waitlist WaitlistJoinRequest:
{
"email": "jane@example.com",
"name": "Jane Doe",
"role": "maker",
"project_idea": "A booking site for my pottery studio",
"referred_by_code": "janedoe-x4n2"
}
201 Created WaitlistJoinResponse (new entry):
{
"data": {
"position": 847,
"total": 2184,
"referral_code": "janedoe-x4n2",
"referral_url": "https://talkide.app/r/janedoe-x4n2",
"confirmed_referrals": 0
}
}
200 OK WaitlistJoinResponse (existing email — idempotent):
{
"data": {
"position": 312,
"total": 2184,
"referral_code": "janedoe-x4n2",
"referral_url": "https://talkide.app/r/janedoe-x4n2",
"confirmed_referrals": 3
}
}
400 Bad Request (validation) ErrorResponse:
{
"status": 400,
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"errors": [
{ "field": "email", "message": "must be a valid email address" }
]
}
UX Guidelines
User Flow
- User přichází na
/waitlist(přímá URL, nebo kliknutím na CTA “Join the waitlist” z landing page). - Systém zobrazí plnohodnotnou stránku: vlevo pitch + perks, vpravo formulářová karta. Pozadí:
var(--bg-1)s animovanýmAuthBackdrop(amber blob vpravo nahoře, indigo blob vlevo dole, dot grid overlay). - Uživatel vidí horní lištu
MiniTopBar(logo + “Back” tlačítko vlevo s ikonouChevronLeft; žádné “Sign in” tlačítko, protožehideSignIn=true). - Uživatel vyplňuje email (autofocus) → name → role (kliknutím na segment) → project_idea (volitelné).
- Po každém opuštění povinného pole (blur) validace zobrazí chybu pod polem s červeným okrajem inputu.
- Tlačítko “Join the waitlist” je aktivní ihned po načtení; submit provede ještě frontend validaci celého formuláře, případná chyba zabrání odeslání.
- Po úspěšném odeslání (201 nebo 200 — idempotentní) se stránka přepne do success stavu (jednosloupec, karta vycentrovaná) — přechod je okamžitý bez page navigation.
- Uživatel vidí zelený check ikon, personalizovaný nadpis, potvrzení emailu, blok s queue position a referral blok “Skip the queue”.
- Tlačítko “Back to TalkIDE” vrátí uživatele na landing page (volá
onBack).
Layout
Form stav (step === "form"):
- Screen type: marketing page + formulář
- Responsive: 2 sloupce na
≥ md(max-width 980px,grid-template-columns: 1fr 1fr, gap 32px); na mobilu (< md) 1 sloupec, pitch nahoře, karta dole - Container:
padding: 32px 24px 64px, vertikálně centrováno pomocíalign-items: centerna flex wrapperu; celkovýmin-height: 100vh - Levý sloupec (pitch + perks): badge “PRIVATE BETA” pill (
var(--bg-2),var(--line-2), text 12pxvar(--fg-2); vnitřní labelvar(--amber)mono 10px 600), nadpish1var(--font-display)600 44px line-height 1.05 letter-spacing -0.025em, perex 15pxvar(--fg-2), 4 perk řádky (ikona v 32×32 tilevar(--amber-soft)/var(--amber-line)/ border-radius 8px, název 13px 500var(--fg-1), popis 12pxvar(--fg-3)) - Pravý sloupec (formulářová karta):
var(--bg-2),var(--line-2)border, border-radius 20px (var(--r-xl)), padding 36px,var(--shadow-strong)
Success stav (step === "success"):
- Grid přepnut na
grid-template-columns: 1fr(jednosloupec) - Karta: max-width 540px,
margin: 0 auto,var(--bg-2),var(--line-2)border, border-radius 20px, padding 48px 44px,var(--shadow-strong), text-align center
Komponenty
| Element | Komponenta / styl | Poznámka |
|---|---|---|
| TopBar | MiniTopBar (z auth.jsx) | hideSignIn=true; levý slot = “Back” <button> s ChevronLeft ikonou, 7px 10px padding, border-radius 8, 13px var(--fg-2) |
| Pozadí | AuthBackdrop (z auth.jsx) | Amber blob TR + indigo blob BL + grain + dot grid; z-index: 0 |
| PRIVATE BETA badge | <div> inline-flex pill | var(--bg-2) bg, var(--line-2) border, border-radius 999; inner span var(--amber) bg, var(--primary-fg) color, font-mono 10px 600 |
| Email input | AuthInput type=“email” | autoFocus; label AuthLabel required |
| Name input | AuthInput | label AuthLabel required |
| Role selector | 2×2 button grid | Wrapper: var(--bg-1) bg, var(--line-2) border, border-radius 10px, padding 3px; aktivní button: var(--bg-3) bg, var(--fg-1) color; neaktivní: transparent, var(--fg-3); font 12px 500 |
| Project idea | <textarea> | 3 řádky, var(--bg-1) bg, var(--line-2) border, border-radius 10px, var(--fg-1) color, font 14px; label ukazuje “(optional)” v var(--fg-4) váze 400 |
| Submit button | .btn.primary (full-width) | width 100%, padding 12px, font 14px, margin-top 20px; ikona ArrowRight 14px vpravo; disabled stav: opacity 0.5, cursor not-allowed |
| ”Already invited?” | Inline link | 13px var(--fg-3), link var(--amber) 500; odděleno border-top: 1px solid var(--line-1), margin-top 18px, padding-top 16px |
| Success check ikona | <div> circle | 64×64px, var(--green-soft) bg, oklch(0.78 0.13 150 / 0.4) border, var(--green) color; ikona Check size 28 |
| Queue position blok | <div> | linear-gradient(135deg, var(--amber-soft), transparent 70%) bg, var(--amber-line) border, border-radius 16px, padding 24px; label mono 10px 0.12em spacing uppercase var(--amber); číslo var(--font-display) 600 56px -0.03em; subtitle 13px var(--fg-3) |
| Referral blok | <div> | var(--bg-1) bg, var(--line-2) border, border-radius 14px, padding 20px, text-align left |
| Invite link row | Inline div | var(--bg-2) bg, var(--line-2) border, border-radius 10px, padding 8px 10px; text font-mono 12px var(--fg-2) truncated; Copy button var(--amber) bg, var(--primary-fg) color, 5px 12px padding, border-radius 7px, 12px 500 |
| Share buttons | 3× <button> flex-row | Rovnoměrná šíře (flex: 1), var(--bg-2) bg, var(--line-2) border, border-radius 8px, 8px 10px padding, 12px var(--fg-2) |
| ”Back to TalkIDE” | .btn.ghost | padding 8px 14px, margin-top 20px |
Validační chování
- Field-level (blur): červený border na
AuthInput, chybová zpráva 12pxvar(--rose)pod polem,roleje pre-selected “maker” — nelze zrušit výběr - Form-level (submit): pokud povinné pole prázdné, focus se přesune na první chybný input; submit je blokován
- API chyba 400: chybové zprávy z
errors[]pole mapovány na příslušný field; generická chyba jako banner nad submit tlačítkem - Idempotentní případ (200 OK): žádná chybová zpráva; UX flow identický s 201 — přímý přechod na success screen
Accessibility
- Všechny inputy mají asociovaný
<label>(přesAuthLabelkomponentu) - Chybové zprávy linkované přes
aria-describedbyna příslušný input - Focus management: autofocus na email field při mount; při submit chybě focus přesunut na první invalídní pole
- Role selector tlačítka:
type="button"(nevypne submit),aria-pressedstav pro aktivní variantu - Klávesová navigace: Tab order odpovídá vizuálnímu pořadí (email → name → role → project_idea → submit)
- Kontrast: veškerý text splňuje WCAG AA —
var(--fg-1)navar(--bg-2), amber tlačítka svar(--primary-fg)
Loading a Error stavy
- Odesílání formuláře: submit tlačítko zobrazí spinner vlevo a text “Joining…”; pole jsou disabled po dobu requestu
- Network error / 5xx: banner nad submit tlačítkem — “Something went wrong. Please try again.” s
var(--rose)border a bgvar(--rose-soft), 13px - Přechod na success: okamžitý (bez skeleton / fade animace) — grid se překonfiguruje z 2 sloupců na 1, nová karta se renderuje
Frontend
Validations
| Field | Constraints | Size | Pattern | Note |
|---|---|---|---|---|
| not_blank | ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ | |||
| name | not_blank | 1 - 100 | ||
| role | not_null, one_of_enum | maker|agency|internal|other | Rendered as 2×2 button grid; default selection “maker” | |
| project_idea | optional | 0 - 1000 | Textarea, optional field; label shows “(optional)“ | |
| referred_by_code | optional | Never shown in form; injected from URL param ?r= if present |
Backend
Validations
| Field | Constraints | Size | Pattern | Note |
|---|---|---|---|---|
| not_blank, email | ||||
| name | not_blank | 1 - 100 | ||
| role | not_null, enum | Accepted values: MAKER, AGENCY, INTERNAL, OTHER | ||
| project_idea | optional | 0 - 1000 | Null if not provided | |
| referred_by_code | optional | If provided and does not match any existing referral_code, silently ignored (no error) |
Database Schema Recommendation
New migration file: 0023-create-waitlist-tables.xml
CREATE TABLE waitlist_entry (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
role VARCHAR(20) NOT NULL, -- MAKER | AGENCY | INTERNAL | OTHER
project_idea TEXT,
referral_code VARCHAR(64) UNIQUE NOT NULL, -- generated: slug(name) + "-" + 4 random alphanum chars
referred_by_id BIGINT REFERENCES waitlist_entry(id), -- self-referential FK, nullable
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_waitlist_entry_email ON waitlist_entry (email);
CREATE INDEX idx_waitlist_entry_referral_code ON waitlist_entry (referral_code);
CREATE INDEX idx_waitlist_entry_referred_by_id ON waitlist_entry (referred_by_id);
Position calculation query (reference):
-- rank among all entries by sign-up time
WITH ranked AS (
SELECT id,
ROW_NUMBER() OVER (ORDER BY created_at ASC) AS raw_rank
FROM waitlist_entry
),
referral_counts AS (
SELECT referred_by_id AS entry_id,
COUNT(*) AS confirmed_referrals
FROM waitlist_entry
WHERE referred_by_id IS NOT NULL
GROUP BY referred_by_id
)
SELECT
r.raw_rank,
GREATEST(1, r.raw_rank - LEAST(COALESCE(rc.confirmed_referrals, 0), 10) * 50) AS position,
(SELECT COUNT(*) FROM waitlist_entry) AS total
FROM ranked r
LEFT JOIN referral_counts rc ON rc.entry_id = r.id
WHERE r.id = :entryId;
WAITLIST_REFERRAL_CAP = 10 is a backend application constant (not stored in DB). If the business wants to change the cap, it is a single-line config change in application.yaml or the use-case service class.
Test Cases
| GIVEN | WHEN | THEN |
|---|---|---|
| email not yet on waitlist, valid request | join waitlist is called | 201 Created; new entry persisted; position, total, referral_code returned; confirmation e-mail sent; email_log entry with status=SENT |
| email already on waitlist, no previous SENT confirmation in email_log | join waitlist is called | 200 OK; existing entry data returned; confirmation e-mail sent; email_log entry with status=SENT |
| email already on waitlist, last SENT confirmation in email_log is < 24 h ago | join waitlist is called | 200 OK; existing entry data returned; e-mail NOT sent (throttled); no new email_log entry created |
| email already on waitlist, last SENT confirmation in email_log is > 24 h ago | join waitlist is called | 200 OK; existing entry data returned; confirmation e-mail sent again; new email_log entry with status=SENT |
| email already on waitlist, 3 confirmed referrals | join waitlist is called | 200 OK; position reflects referral discount (raw_rank - 150) |
| valid request with referred_by_code matching existing entry | join waitlist is called | 201 Created; referred_by_id FK set to matching entry’s id |
| valid request with referred_by_code that does not exist | join waitlist is called | 201 Created; referred_by_id is null; no error |
| valid new registration, EmailSender throws | join waitlist is called | 201 Created; entry persisted; email_log entry with status=FAILED; no exception propagated to FE |
| email already on waitlist (> 24 h window), EmailSender throws | join waitlist is called | 200 OK; existing entry returned; email_log entry with status=FAILED; no exception propagated to FE |
| email field blank | join waitlist is called | 400 VALIDATION_ERROR |
| email field invalid format | join waitlist is called | 400 VALIDATION_ERROR |
| name field blank | join waitlist is called | 400 VALIDATION_ERROR |
| role field missing | join waitlist is called | 400 VALIDATION_ERROR |
| role field unknown value | join waitlist is called | 400 VALIDATION_ERROR |
| project_idea exceeds 1000 characters | join waitlist is called | 400 VALIDATION_ERROR |
| empty request body | join waitlist is called | 400 VALIDATION_ERROR |
FEEDBACK
Handoff waitlist.jsx je excelentně detailní — inline styly s přesnými tokeny mi umožnily napsat guidelines bez odhadování. Chybělo mi: (1) jaké CSS třídy .btn.primary / .btn.ghost reálně generují (musel jsem odvodit ze styles.css, který v bundlu nebyl přiložen); (2) breakpoint hodnota pro přepnutí z 2 na 1 sloupec není v kódu explicitně — inferoval jsem md = 768px z obecné konvence projektu, ale handoff by ho mohl uvést.
Thanks for the feedback.