iClosed-style lead capture + intl-tel-input phone fields#136
iClosed-style lead capture + intl-tel-input phone fields#136huntervcx wants to merge 12 commits into
Conversation
Capture name/email/notes that guests type into the public booking form
before they submit, so hosts can follow up on abandoned bookings. The
feature is gated three ways:
1. Global admin toggle on auth_config (off by default).
2. Per-event-type opt-in (off by default).
3. RGPD: a "have you informed bookers?" acknowledgement is required
to flip the per-event-type toggle on, and a notice is rendered on
the booking form whenever capture is active.
Captured rows live in a new partial_bookings table, are upserted on
each debounced keystroke from the public booking form, and are
auto-purged after a configurable retention window (default 30 days,
clamped to 1..=365) by a background task in calrs serve. Successful
bookings flip the matching row to completed so the dashboard only
shows actually-abandoned leads.
Backend layout:
* migrations/056_lead_capture.sql — partial_bookings table plus the
two gating columns on event_types and auth_config.
* src/leads/ — config (gating), db (CRUD + purge), shared types.
* src/web/mod.rs — /api/lead-capture POST endpoint (rate-limited
per IP), /dashboard/leads page, admin global toggle and per-event
toggle handlers, render-time context wiring for all four booking
form variants (personal, team, dynamic-group, reschedule).
Frontend:
* templates/book.html — debounced fetch() of the form fields to
/api/lead-capture under a sessionStorage lead_id, plus the RGPD
notice block.
* templates/dashboard_leads.html — listing of recent abandoned
leads scoped to the current host (admin sees all).
* templates/admin.html — global toggle + retention input.
* templates/event_type_form.html — per-event-type toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
iClosed-style: when lead capture is active for an event type, show a small name+email form *over* a blurred slot grid and only reveal the calendar after the guest reveals who they are. Same admin/per-event gating as the booking-form capture — the global RGPD off-switch on auth_config disables both at once. * templates/slots.html — overlay HTML, CSS (filter: blur(8px) + pointer-events: none on .slots-main when the gate is active) and the small fetch() script that POSTs name/email to /api/lead-capture using the existing sessionStorage lead_id. The gate also captures on blur so a guest who types and then abandons is still recorded. Per-event sessionStorage key avoids re-prompting on reload. * templates/book.html — read sessionStorage handoff (calrs_lead_name, calrs_lead_email) to pre-fill the booking form so the guest doesn't retype what they just gave the gate. * src/web/mod.rs — wire (lc_active, lc_retention) into the four slot-page renders (team, dynamic-group, user, legacy). Reschedule flow intentionally skipped: the guest already booked once. * i18n — slots-lead-gate-title/subtitle/button (EN + FR). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The lead-capture fetch() in both the slots gate and the booking form read the CSRF token from a cookie named `calrs_csrf`, but main renamed it to `__Host-calrs_csrf` (security hardening). The regex never matched the `__Host-` prefix, so the token was empty, every POST to /api/lead-capture was rejected by the CSRF check, and no lead was ever recorded. Align both reads with the name used everywhere else. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… UTM, worklist Builds on the iClosed-style lead capture with the follow-up features that make captured leads actionable. Migration 057 adds the backing columns (guest_phone on bookings, collect_phone on event_types, and utm_*/ referrer/contacted_at/archived_at/notified_at on partial_bookings). Team visibility (olivierlambert#1): a team event type's leads are now visible to every member of its team, not just the creator. list_recent_for_user and stats_for_user resolve scope as "my own leads OR leads on event types in teams I belong to"; worklist actions gate on the same access check. Guest phone (olivierlambert#2): calrs had no concept of a guest phone. Event types can opt in ("Ask for a phone number"), which adds an optional phone field to the booking form and the lead-capture gate, stores it on the booking, and shows it on the leads dashboard. Wired through all four public booking inserts and both event-type edit forms. Abandonment alerts (olivierlambert#3): a 5-minute background pass emails the host when a lead has sat untouched for 30 min without completing (once per lead, via notified_at; capped to leads <48h old to avoid backlog spam). Respects the global RGPD off-switch and no-ops without SMTP. Conversion stats (olivierlambert#4): started / completed / open / conversion-% tiles on the leads dashboard, scoped like the list. UTM + referrer (olivierlambert#5): the gate and booking-form capture now send utm_* query params and document.referrer; stored (COALESCE'd so the first value sticks) and surfaced as a "Source" column. Worklist (olivierlambert#6): mark-contacted (toggle) and archive actions per lead, with CSRF + ownership checks; archived leads drop out of the default view. PartialBooking moves from a 14-field tuple to a FromRow struct now that the row has 20 columns. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Notice: replace the prominent lead-capture notice paragraph with a
layered (collapsed <details>) notice — a small one-line "How your details
are used" summary that expands to the full text, with an optional "Learn
more" link to the admin's company URL. This is the GDPR-recommended
layered-notice pattern: transparency is preserved (the information is one
click away at the point of data entry) while the page stays uncluttered.
Shared styling lives in base.html; applied to both the booking form and
the slots gate.
Tests: cover the follow-up features end-to-end.
* leads/db: stats counting, archived/completed exclusion from the list,
owner-vs-team-vs-stranger access control, contacted toggle, team
member visibility of a team event type's lead, and the abandonment
notification window (due / too-fresh / too-old / already-notified).
* web: worklist archive is 403 for a non-owner and works for an admin,
plus dashboard-leads and public-slots gate render smoke tests.
Test harness fix: setup_test_db used `sqlite::memory:` with a multi-
connection pool, which gives each connection its own empty database —
migrations land on one connection and later queries flake on another.
Switch to a per-test shared-cache named memory DB so every pooled
connection sees the same schema. Removes latent flakiness for the whole
web test suite.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts: # i18n/en/main.ftl # i18n/fr/main.ftl # src/db.rs # src/web/mod.rs # templates/admin.html # templates/book.html
…one obligation, persist on blank - Lead capture toggle moves from a standalone POST into the main event-type form's "Save changes". The "I have informed bookers" checkbox is replaced by a confirm() dialog on enable and a persisted lead_capture_acknowledged_at; old toggle route kept as a fallback. - New legal_mentions_url admin field. When set, the public booking pages replace the verbose data-handling notice with a single link in the footer (next to "powered by calrs"); when empty, the notice itself moves to the footer and is only shown while capture is active. - collect_phone becomes a 0/1/2 select (off / optional / required). Server-side validation refuses bookings missing a required phone; guest_phone is included in ICS DESCRIPTION and host notification emails. - Lead-gate email field on slots.html gets the same TLD pattern + custom-validity script as the booking form (fix(book) olivierlambert#129 didn't cover the gate, so "arthur@perrot" passed through). - partial_bookings upsert COALESCEs name/email/phone/notes so a guest who types then clears a field keeps the captured value (matches the iClosed UX expectation that abandoned data is sticky). - Admin "Lead capture" panel relabels retention as "Auto-purge captured leads after" and shows the RGPD reminder only when capture is enabled and retention > 90 days. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Move all lead-capture HTTP handlers, forms, helpers and the background
purge/notifier loop from src/web/mod.rs (~580 lines) to src/web/leads.rs.
Phone validation helpers stay in mod.rs since they cross-cut the booking
flow, not lead capture. `pub use leads::run_lead_purge_loop` preserves
the path main.rs imports.
- Promote impersonation_ctx, sidebar_context, CsrfForm to pub(crate) so
leads.rs can call them.
- Drop the now-dead POST /dashboard/event-types/{slug}/lead-capture route
+ toggle_event_type_lead_capture handler + LeadCaptureToggleForm struct.
The toggle is part of the main event-type form since phase 2.
- Fix phone validation to count chars instead of bytes so a 64-char phone
with non-ASCII formatting marks isn't rejected by the server while
passing the HTML `maxlength=64` attribute on the client.
- Replace the chars().take(10) hack on lead_capture_acknowledged_at with
a proper NaiveDateTime parse + format, falling back to the raw value if
the column ever stores a non-SQLite-default format.
- Add Fluent keys for every new English string introduced in phase 2
(event-type-form-phone-*, event-type-form-lead-capture-*,
admin-legal-*, admin-lead-*). EN only; other locales fall back to EN at
runtime per the existing i18n contract.
- Document the BookForm/4-handler duplication as a standalone plan in
notes/booking-handler-duplication.md, with the test invariants a
unified BookingFlow refactor would unlock.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vendors intl-tel-input 25.3.1 (MIT) into assets/intl-tel-input/ and serves it from same-origin /static/intl-tel-input/. Zero external calls at runtime — the JS, CSS, flags sprite and globe icon are baked into the binary via include_bytes!/include_str!, and the geo-IP lookup is replaced with a navigator.language hint that defaults to FR. book.html and the slots.html lead-gate now wrap their phone inputs in intl-tel-input when collect_phone > 0: a flag dropdown, format-as-you-type, and isValidNumber() check on submit. The visible input only carries the national-format number; a sibling hidden input named "phone" is populated with the E.164 form (iti.getNumber()), preserving the existing server contract — validate_phone_input and the bookings.guest_phone column still see a single phone string, just better-shaped. CSS url(...) references in the vendored file were rewritten to flat paths so all seven assets live under one route prefix. Adds the en-only Fluent key book-phone-invalid for the client-side error caption. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (6)
🚧 Files skipped from review as they are similar to previous changes (3)
📝 WalkthroughWalkthroughThis PR implements a complete lead-capture feature for incomplete booking attempts. It adds a ChangesLead Capture & Abandoned Booking Notifications
Estimated Code Review Effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ast-grep (0.43.0)src/web/mod.rsComment |
There was a problem hiding this comment.
Actionable comments posted: 12
🧹 Nitpick comments (1)
src/leads/config.rs (1)
44-71: 💤 Low valueOptional: Eliminate TOCTOU race with SQLite upsert.
The UPDATE-or-INSERT pattern (check existence at lines 51-53, then branch) has a time-of-check to time-of-use race. Two concurrent admin requests could both see "no row exists" and both attempt INSERT, causing the second to fail with a uniqueness violation.
Practical risk: Low.
auth_configis a singleton updated only from the admin panel, so concurrent writes are rare.Cleaner alternative: SQLite supports
INSERT ... ON CONFLICT DO UPDATE(same pattern already used insrc/leads/db.rsline 94-117 forupsert_partial), which is atomic and removes the extra round-trip.♻️ Refactor: use SQLite upsert for atomicity
pub async fn set_global_settings( pool: &SqlitePool, enabled: bool, retention_days: i64, ) -> Result<()> { let retention_days = retention_days.clamp(1, 365); - // auth_config is a singleton — UPDATE if a row exists, otherwise INSERT. - let existing: Option<(i64,)> = sqlx::query_as("SELECT 1 FROM auth_config LIMIT 1") - .fetch_optional(pool) - .await?; - if existing.is_some() { - sqlx::query("UPDATE auth_config SET lead_capture_enabled = ?, lead_retention_days = ?") - .bind(enabled as i64) - .bind(retention_days) - .execute(pool) - .await?; - } else { - sqlx::query( - "INSERT INTO auth_config (lead_capture_enabled, lead_retention_days) - VALUES (?, ?)", - ) - .bind(enabled as i64) - .bind(retention_days) - .execute(pool) - .await?; - } + sqlx::query( + "INSERT INTO auth_config (id, lead_capture_enabled, lead_retention_days) + VALUES ('singleton', ?, ?) + ON CONFLICT(id) DO UPDATE SET + lead_capture_enabled = excluded.lead_capture_enabled, + lead_retention_days = excluded.lead_retention_days", + ) + .bind(enabled as i64) + .bind(retention_days) + .execute(pool) + .await?; Ok(()) }(Note: Assumes
auth_config.idis the PK. AdjustON CONFLICTclause to match the actual unique constraint.)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/leads/config.rs` around lines 44 - 71, The set_global_settings function currently does a SELECT then UPDATE/INSERT which risks a TOCTOU race; replace that pattern with a single atomic SQLite upsert using INSERT ... ON CONFLICT DO UPDATE (similar to upsert_partial in src/leads/db.rs) so you no longer check `existing`—construct an INSERT into auth_config (lead_capture_enabled, lead_retention_days[, id if needed]) with an ON CONFLICT (id or the actual unique key) DO UPDATE SET lead_capture_enabled = excluded.lead_capture_enabled, lead_retention_days = excluded.lead_retention_days and execute that via sqlx::query(...).execute(pool).await?; keep the retention_days.clamp and binding of values but remove the SELECT and branching logic in set_global_settings.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@assets/intl-tel-input/intlTelInput.min.css`:
- Line 1: The .iti__a11y-text rule uses the deprecated clip property; update the
visually-hidden helper to use modern clip-path-based hiding by removing
clip:rect(...) and replacing it with clip-path: inset(0 0 0 0) (or equivalent
inset values) while keeping
width:1px;height:1px;overflow:hidden;position:absolute; (add white-space:nowrap
if needed for screen-reader consistency); locate and update the .iti__a11y-text
selector in the stylesheet to use clip-path instead of clip so Stylelint
warnings are resolved and the behavior remains the same.
In `@CLAUDE.md`:
- Around line 96-98: Update the migration list in CLAUDE.md to reflect the
renumbering and include the missing migrations referenced in src/db.rs: replace
the current 056/057 entries with the sequential entries 057_lead_capture.sql,
058_lead_followups.sql, and 059_lead_capture_legal_and_phone.sql (with their
short descriptions), and also ensure any mention of 056_meeting_links (if
present in src/db.rs) is added or adjusted so all registered migrations and
filenames in src/db.rs match the documented list.
In `@src/db.rs`:
- Around line 239-254: Two migrations share the same numeric prefix
("056_lead_capture" and "056_meeting_links"), breaking the sequential migration
numbering convention; rename the migration identifiers and corresponding .sql
files so numbers are unique and sequential (e.g., bump "056_lead_capture" →
"057_lead_capture", "057_lead_followups" → "058_lead_followups", and
"058_lead_capture_legal_and_phone" → "059_lead_capture_legal_and_phone" or
follow the correct order per desired sequence), update the strings in src/db.rs
where the tuples ("056_lead_capture", "056_meeting_links", "057_lead_followups",
"058_lead_capture_legal_and_phone") are listed, rename the files in migrations/
to match the new prefixes, and update any tests and CLAUDE.md references to the
old migration numbers to the new ones.
In `@src/leads/config.rs`:
- Around line 19-24: The DB query failures in global_settings and
event_type_capture_enabled are being swallowed by .unwrap_or(None); change these
to capture the Result error, log it with the project's structured logger
(include context like function name and the query), and then either return the
explicit fail-safe defaults (capture disabled, 30-day retention for
GlobalSettings; capture disabled for event type) or propagate the error;
specifically replace the .unwrap_or(None) on the sqlx::query_as(...)
.fetch_optional(...).await call in global_settings and the analogous call in
event_type_capture_enabled with error handling that logs the error and then
returns the appropriate safe default (or returns Err) instead of silently
swallowing it.
In `@src/leads/db.rs`:
- Around line 148-160: The mark_completed function currently swallows any DB
error (let _ = ...execute().await) which hides failures; change it to capture
the Result from sqlx::query(...).execute(pool).await, match on it, and log any
Err with structured context including the function name, lead_id and the error
(e.g., using tracing::error! or the project's logger) so DB update failures are
visible; keep the function signature but ensure success is still ignored and
only errors are logged for operational visibility.
- Around line 345-368: The function due_for_notification currently swallows DB
errors via .unwrap_or_default(), causing silent failures; change
due_for_notification to return
Result<Vec<(String,String,Option<String>,String,String)>, sqlx::Error> (or a
crate-specific error type), remove .unwrap_or_default(), and propagate the query
error by replacing .await.unwrap_or_default() with .await? (or
.await.map_err(...)?), then update any callers to handle the Result (log and
surface the error in the background notification task). This preserves the same
query and row type but ensures DB failures are not silently ignored and can be
logged/handled upstream.
- Around line 294-309: The current user_can_access function swallows DB errors
via .unwrap_or(None) which can incorrectly return false; change user_can_access
to return Result<bool, sqlx::Error> (or a crate-specific error type), remove
.unwrap_or(None) and propagate the error from .fetch_optional(...).await (i.e.
use ? to bubble up the sqlx error) and return Ok(row.is_some()); update all
callers of user_can_access (HTTP handlers) to handle the Result (map DB errors
to 500 and authorization failures to 403/404) so operational DB failures are
visible instead of silently denying access.
- Around line 372-377: The update in function mark_notified silently discards
errors from sqlx::query(...).execute(pool).await which can cause duplicate
emails; change mark_notified to check the Result, log any Err (include the id
and error) via your logger or tracing (or return a Result) and only treat
success as setting notified_at—specifically inspect the outcome of the
sqlx::query/execute call in mark_notified and emit an error log containing id
and the error when it fails so failures are visible and retried behavior is
correct.
In `@src/web/leads.rs`:
- Around line 76-83: The current sqlx::query does a blind UPDATE which may
affect zero rows and ignores SQL errors; change it to perform an upsert (INSERT
... ON CONFLICT(id) DO UPDATE ... or equivalent for your DB) using the same bind
values and await the Result, check for errors and ensure the write succeeded,
and only then update state.legal_mentions_url (the RwLock) and return
Redirect::to("/dashboard/admin").into_response(); if the DB write fails
return/propagate an error response instead of updating the cache.
- Around line 123-150: apply_lead_capture_toggle() currently swallows both the
initial SELECT and subsequent UPDATE errors and always returns Ok(()), causing
the UI to believe a toggle succeeded when SQLite rejected it; change the
function to propagate SQL errors instead of unwrapping/ignoring them: replace
the unwrap_or(None).flatten() on the SELECT with a ?-style error return (or map
the error into the function's Result), check the fetch_optional result and
return Err on failure, and propagate the result of .execute(pool).await
(returning that error) for both the acknowledging_now and non-acknowledging
branches so callers (e.g., set_global_settings()) can surface a failure response
for lead_capture changes. Ensure the function signature and caller code are
updated to accept and handle the propagated Result.
- Around line 155-169: The payload and single-user resolution are ambiguous
because LeadCapturePayload currently only carries event_type_slug and team_slug,
allowing slug collisions across hosts to bind leads to the wrong
event_type_id/host_user_id (which are overwritten on upsert); modify
LeadCapturePayload to include a server-trusted identifier (either username:
Option<String> for public URLs /u/{username}/{slug} or event_type_id:
Option<i64>) and change the lookup logic that resolves event types (the
single-user slug lookup path that writes partial_bookings.event_type_id and
host_user_id) to prefer event_type_id if present or to require and use username
+ event_type_slug when team_slug is None, falling back to team_slug behavior
only for team-scoped lookups.
In `@src/web/mod.rs`:
- Around line 2227-2239: The normalize_phone function currently truncates to 64
bytes which can mismatch validate_phone_input's 64-character limit; change
normalize_phone to cap by characters not bytes so it matches
validate_phone_input: after trimming, iterate over trimmed.chars().take(64) and
collect those chars into a String (preserving UTF-8 safety), return None for
empty as before, and ensure the cap value (64) is the same constant/number used
in validate_phone_input (or extract a shared constant) so validation and
normalization stay consistent.
---
Nitpick comments:
In `@src/leads/config.rs`:
- Around line 44-71: The set_global_settings function currently does a SELECT
then UPDATE/INSERT which risks a TOCTOU race; replace that pattern with a single
atomic SQLite upsert using INSERT ... ON CONFLICT DO UPDATE (similar to
upsert_partial in src/leads/db.rs) so you no longer check `existing`—construct
an INSERT into auth_config (lead_capture_enabled, lead_retention_days[, id if
needed]) with an ON CONFLICT (id or the actual unique key) DO UPDATE SET
lead_capture_enabled = excluded.lead_capture_enabled, lead_retention_days =
excluded.lead_retention_days and execute that via
sqlx::query(...).execute(pool).await?; keep the retention_days.clamp and binding
of values but remove the SELECT and branching logic in set_global_settings.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: fd02c9ce-95ed-4447-a2f3-8a55bd500d7a
⛔ Files ignored due to path filters (1)
assets/intl-tel-input/intlTelInput.min.jsis excluded by!**/*.min.js
📒 Files selected for processing (25)
CLAUDE.mdassets/intl-tel-input/flags.webpassets/intl-tel-input/flags@2x.webpassets/intl-tel-input/globe.webpassets/intl-tel-input/globe@2x.webpassets/intl-tel-input/intlTelInput.min.cssassets/intl-tel-input/utils.jsmigrations/056_lead_capture.sqlmigrations/057_lead_followups.sqlmigrations/058_lead_capture_legal_and_phone.sqlsrc/db.rssrc/email.rssrc/leads/config.rssrc/leads/db.rssrc/leads/mod.rssrc/main.rssrc/web/leads.rssrc/web/mod.rstemplates/admin.htmltemplates/base.htmltemplates/book.htmltemplates/dashboard_base.htmltemplates/dashboard_leads.htmltemplates/event_type_form.htmltemplates/slots.html
👮 Files not reviewed due to content moderation or server errors (7)
- templates/book.html
- templates/slots.html
- templates/event_type_form.html
- templates/admin.html
- templates/base.html
- templates/dashboard_base.html
- templates/dashboard_leads.html
|
I'm not very fond of tracking tools integration, maybe that would be the right time to have a plugin/extensions mechanism 🤔 |
Yeah me neither at first, but lot of demand from our clients (+a strong trend around it since ~6months), so I'd rather understand how it works than have everyone blindly send their data to US-based services. That's also why I added an admin switch to completely disable the feature for organizations that don't want it or have stricter compliance requirements (disabled by default org wide, and on new events). |
…visibility
- Lead capture single-user lookup now scopes by username, not just slug.
Public booking URLs are /u/{username}/{slug} and slugs are only unique per
account, so a slug shared across hosts could bind a lead to the wrong
event type/host (and every keystroke upsert reinforced it). LeadCapturePayload
carries a server-trusted username (sent by book.html and the slots lead-gate);
the query mirrors the booking handler's WHERE u.username = ? AND et.slug = ?.
The legacy /{slug} route keeps the global-slug fallback.
- normalize_phone caps by chars (chars().take(64)) instead of bytes so a
multi-byte number can't pass validate_phone_input then get silently truncated.
- Log the previously-swallowed DB errors in the abandonment-notifier path:
due_for_notification (silent failure = no alerts) and mark_notified (silent
failure = duplicate alerts) now emit tracing::warn.
- CLAUDE.md migration list documents 056_meeting_links and
058_lead_capture_legal_and_phone, and why the 056 prefix is shared.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
What's inside
Lead capture (iClosed-style). When a guest types into a public booking form, every keystroke is debounced-POSTed to /api/lead-capture and upserted into partial_bookings. If they never submit, the host sees the abandoned lead on /dashboard/leads (worklist with contacted/archive actions, UTM + referrer attribution, conversion / abandonment / completed counts). Gated by an admin-wide kill switch plus a per-event-type opt-in that requires the host to acknowledge once that bookers are informed. Configurable auto-purge window (default 30 days; RGPD reminder banner above 90).
A blurred-calendar gate on the slots page mirrors the iClosed flow — name/email/phone are collected before the available times are revealed.
Footer-only RGPD notice. The data-handling notice now lives at the bottom of public booking pages, next to "powered by calrs". Admins can replace it with a single link to their own legal-mentions page via a new admin field.
Phone collection — three states + intl-tel-input. collect_phone becomes off / optional / required per event type. Required is enforced server-side. We vendor intl-tel-input@25.3.1 (MIT) into assets/intl-tel-input/ and serve it from same-origin /static/intl-tel-input/ (zero external calls — no geo-IP lookup, region detected from navigator.language with FR fallback). The phone fields on both the booking form and the lead-capture gate now ship with a flag picker, format-as-you-type, and libphonenumber validation on submit. Captured guest_phone is included in the calendar event DESCRIPTION and host notification emails.
Code structure : Lead-capture HTTP layer is in src/web/leads.rs (~580 lines), following the existing captcha.rs pattern.
additionnal library : intl-tel-input 25.3.1 (MIT), vendored in
assets/intl-tel-input/(7 files, ~408KB) — JS, CSS, libphonenumber utils.js, sprites webp flags + globe.What is still rough and need polishing :
Not perfect, but functionality is there to test and get reviews / ideas
Summary by CodeRabbit
Release Notes