Internal Documentation internal
TalkIDE internal documentation

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), where rank_by_created_at is the 1-based ordinal position of the entry ordered by created_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.xml creates 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_log for this recipient within the last WAITLIST_CONFIRMATION_RESEND_WINDOW_HOURS = 24 hours (configurable constant). If a recent successful delivery exists, the e-mail is silently skipped and 200 OK is returned as usual.
    • production profile: real e-mail sent via Mailgun from noreply@mail.talkide.app; subject “You’re on the TalkIDE waitlist!”; body includes referral link.
    • !production profile (NoOpEmailSender): no e-mail is sent; content is logged to console at INFO level.
  • The throttle check queries email_log for the most recent record of type WAITLIST_CONFIRMATION for the given recipient with status = SENT. If such a record exists and its created_at is within the resend window, sending is skipped. No new column is added to waitlist_entry (its Liquibase changeset 0026 is immutable).
  • E-mail sending is best-effort — a failure from EmailSender is caught, logged, and recorded in email_log with status=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

  1. User přichází na /waitlist (přímá URL, nebo kliknutím na CTA “Join the waitlist” z landing page).
  2. Systém zobrazí plnohodnotnou stránku: vlevo pitch + perks, vpravo formulářová karta. Pozadí: var(--bg-1) s animovaným AuthBackdrop (amber blob vpravo nahoře, indigo blob vlevo dole, dot grid overlay).
  3. Uživatel vidí horní lištu MiniTopBar (logo + “Back” tlačítko vlevo s ikonou ChevronLeft; žádné “Sign in” tlačítko, protože hideSignIn=true).
  4. Uživatel vyplňuje email (autofocus) → name → role (kliknutím na segment) → project_idea (volitelné).
  5. Po každém opuštění povinného pole (blur) validace zobrazí chybu pod polem s červeným okrajem inputu.
  6. 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í.
  7. 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.
  8. Uživatel vidí zelený check ikon, personalizovaný nadpis, potvrzení emailu, blok s queue position a referral blok “Skip the queue”.
  9. 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: center na flex wrapperu; celkový min-height: 100vh
  • Levý sloupec (pitch + perks): badge “PRIVATE BETA” pill (var(--bg-2), var(--line-2), text 12px var(--fg-2); vnitřní label var(--amber) mono 10px 600), nadpis h1 var(--font-display) 600 44px line-height 1.05 letter-spacing -0.025em, perex 15px var(--fg-2), 4 perk řádky (ikona v 32×32 tile var(--amber-soft) / var(--amber-line) / border-radius 8px, název 13px 500 var(--fg-1), popis 12px var(--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

ElementKomponenta / stylPoznámka
TopBarMiniTopBar (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 pillvar(--bg-2) bg, var(--line-2) border, border-radius 999; inner span var(--amber) bg, var(--primary-fg) color, font-mono 10px 600
Email inputAuthInput type=“email”autoFocus; label AuthLabel required
Name inputAuthInputlabel AuthLabel required
Role selector2×2 button gridWrapper: 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 link13px 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> circle64×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 rowInline divvar(--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<button> flex-rowRovnomě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.ghostpadding 8px 14px, margin-top 20px

Validační chování

  • Field-level (blur): červený border na AuthInput, chybová zpráva 12px var(--rose) pod polem, role je 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řes AuthLabel komponentu)
  • Chybové zprávy linkované přes aria-describedby na 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-pressed stav 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) na var(--bg-2), amber tlačítka s var(--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 bg var(--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

FieldConstraintsSizePatternNote
emailnot_blank^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$
namenot_blank1 - 100
rolenot_null, one_of_enummaker|agency|internal|otherRendered as 2×2 button grid; default selection “maker”
project_ideaoptional0 - 1000Textarea, optional field; label shows “(optional)“
referred_by_codeoptionalNever shown in form; injected from URL param ?r= if present

Backend

Validations

FieldConstraintsSizePatternNote
emailnot_blank, email
namenot_blank1 - 100
rolenot_null, enumAccepted values: MAKER, AGENCY, INTERNAL, OTHER
project_ideaoptional0 - 1000Null if not provided
referred_by_codeoptionalIf 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

GIVENWHENTHEN
email not yet on waitlist, valid requestjoin waitlist is called201 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_logjoin waitlist is called200 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 agojoin waitlist is called200 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 agojoin waitlist is called200 OK; existing entry data returned; confirmation e-mail sent again; new email_log entry with status=SENT
email already on waitlist, 3 confirmed referralsjoin waitlist is called200 OK; position reflects referral discount (raw_rank - 150)
valid request with referred_by_code matching existing entryjoin waitlist is called201 Created; referred_by_id FK set to matching entry’s id
valid request with referred_by_code that does not existjoin waitlist is called201 Created; referred_by_id is null; no error
valid new registration, EmailSender throwsjoin waitlist is called201 Created; entry persisted; email_log entry with status=FAILED; no exception propagated to FE
email already on waitlist (> 24 h window), EmailSender throwsjoin waitlist is called200 OK; existing entry returned; email_log entry with status=FAILED; no exception propagated to FE
email field blankjoin waitlist is called400 VALIDATION_ERROR
email field invalid formatjoin waitlist is called400 VALIDATION_ERROR
name field blankjoin waitlist is called400 VALIDATION_ERROR
role field missingjoin waitlist is called400 VALIDATION_ERROR
role field unknown valuejoin waitlist is called400 VALIDATION_ERROR
project_idea exceeds 1000 charactersjoin waitlist is called400 VALIDATION_ERROR
empty request bodyjoin waitlist is called400 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.


Was this page helpful?

Thanks for the feedback.