Skip to content

Latest commit

 

History

66 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Digi Dental — two-page conversion funnel

Site selling a done-for-you AI receptionist for dental practices. Two pages, and only two:

URL File Job
/ index.html Raise a hand. Hero, what missed calls cost (sourced), before/after, the ROI calculator, one CTA.
/how-it-works/ how-it-works/index.html Convince a researcher. Live voice demo, recorded call, dashboard tour, full pricing, FAQ, the case study, the founder, final CTA.

Both pages load the same two shared files, which is what keeps them one site rather than two lookalikes:

  • dd.css — the entire stylesheet, linked from each page's static <head> (render-blocking there on purpose: the pre-hydration rules at the bottom of the file only work if they are in force during the raw-HTML pass).
  • dd-logic.js — the entire logic class, exposed as DDLogic.create(DCLogic). Each page's <script data-dc-script> is one line. The FAQ text, the pricing numbers, the booking flow and the legal copy exist once, here, so they cannot drift between the two pages. Page-specific behaviour keys off document.documentElement.dataset.page (home | deep).

Repo scaffolding for the production build (React + Vite + Tailwind + Vercel functions + Supabase):

  • api/demo-session.ts — one-demo-per-IP gate (called by startDemo() before every demo call)
  • api/notify-lead.ts — Supabase insert + owner email via Resend (honeypot + per-IP rate limit)
  • api/event.ts — first-party funnel analytics, one row per event in site_events
  • api/video.ts — stable URLs for the two marketing videos, so no expiring token lives in the page
  • api/site-info.ts — public JSON summary for AI agents (no deps, no secrets)
  • supabase/migrations/001 leads + demo sessions, 002 site events (+ the KPI queries) … 010 two-page analytics (paste-ready copy: supabase/APPLY_010_ONLY.sql)
  • .env.example — every required variable, placeholder values only
  • vercel.json — response headers for the SEO/AI files (content types, CORS, caching)

SEO and AI discoverability

Canonical origin: https://www.digidental.us. The bare apex digidental.us is an A record to Vercel (216.198.79.1) which 308-redirects to www.

The pages render client-side, so anything inside <x-dc> (including <helmet>) is invisible to crawlers that don't run JavaScript. All crawler-facing tags therefore live in the static <head> of each page: title, description, canonical, robots, hreflang, Open Graph/Twitter, and a JSON-LD @graph.

The entity nodes — Organization, WebSite, Service with offers, Person (founder, an E-E-A-T signal), ImageObject logo and the Article node for the case study — are byte-identical on both pages, and a test enforces that: two copies that drift describe two organisations. What differs is the page-specific part. / adds its own WebPage and the overview VideoObject; /how-it-works/ adds its WebPage, a BreadcrumbList, the FAQPage, and the recorded-call VideoObject. FAQPage is deliberately only on the page that renders the FAQ — marking up an answer the visitor cannot see is a structured-data violation.

Two pages, two intents. / targets the brand and category term; /how-it-works/ targets the research queries (how it works, what it costs, is it HIPAA-compliant, does it work with Dentrix). Titles and descriptions are asserted to differ, because two pages fighting over one query is the classic way a split makes rankings worse rather than better.

Supporting files served from the root: robots.txt (search engines + ~20 named AI crawlers, points at the sitemap), sitemap.xml (with image and video extensions — the <video> elements are client-rendered, so the clips would otherwise be undiscoverable), llms.txt (plain-text summary for LLMs, including the full FAQ verbatim), og-image.png (social card), and /api/site-info (JSON).

What a non-rendering crawler sees

Googlebot executes JavaScript. GPTBot, ClaudeBot and PerplexityBot largely do not. The dc-runtime keeps its content as real markup, so the copy is in the served HTML — but two things polluted it before the runtime mounts:

  • sc-if is a directive, not an HTML conditional. With no JS every branch renders, so the error boundary ("A momentary hiccup. This page had trouble rendering.") was literally the first thing an AI crawler read.
  • Unresolved {{ holes }} render as literal text — 17 of them.

Both are now hidden by CSS scoped to x-dc / sc-if / sc-for, tags the runtime removes from the DOM on hydration. That scoping is the whole safety argument: the rules can only match before hydration, so they are provably inert for anyone running JavaScript. Do not widen those selectors.

The FAQ is rendered from faqData by a loop, so it does not exist pre-hydration at all. A <noscript> mirror on /how-it-works/ carries the identical text, generated from that page's own FAQPage schema and pinned by tests. Both pages come out clean: no template syntax, no crash boundary, and every Q&A readable.

True SSR would need a build step or a server framework; this is the progressive- enhancement equivalent for static files.

npm run test:seo guards all of it. 154 static checks, most of them run against both pages: each canonical pointing at its own URL, self-referencing hreflang, no dangling JSON-LD @ids, entity nodes identical across pages, the FAQ schema matching both faqData and the visible FAQ, one <h1> per page with no skipped levels, every <img> with alt and reserved layout space, no relative image paths (a subdirectory page turns uploads/x.png into a 404), complete OG/Twitter cards, the two pages linking to each other in both directions, every nav anchor existing on the page it points at, and admin/API kept out of robots.txt. SEO decays silently; nothing here throws on its own.

The three FAQ copies

faqData in dd-logic.js is the source. Two hand-written copies exist because crawlers need them: the FAQPage schema in how-it-works/index.html's <head>, and the <noscript> mirror below the FAQ section. npm run test:seo fails if any of the three drifts, and also if llms.txt stops republishing a question. Change all of them together.

Note: the HTML is regenerated by Claude Design exports. A fresh export overwrites the static <head> — re-apply the SEO block after each sync, then run npm run test:seo to confirm nothing was lost.

The cited case study

The one piece of Digi Dental writing that lives off this domain: The Real Cost of a Missed Call at Your Dental Practice, published on Medium under Benny's byline.

It earns its place in the SEO/AEO surface because it is a second indexable document making the same claims with the same numbers — which is what an answer engine corroborates a vendor's own page against. It is wired in six places, all from the single CASE_STUDY constant in dd-logic.js:

  1. An Article node in both pages' JSON-LD, @id'd to the Medium URL, authored by the #founder entity and about the #service.
  2. citation on both WebPage nodes; subjectOf on Service.
  3. https://medium.com/@bennyco in the founder's sameAs (a profile, so it belongs there — the article itself does not).
  4. A visible cited-source card on / (in the cost section) and on /how-it-works/ (its own #case-study section), plus a footer link on both pages.
  5. A section in llms.txt and a publications array in /api/site-info.
  6. Two FAQ entries drawn from it, which is what turns it into answer-engine coverage for "what is a missed call worth" and "do the top practices answer after hours".

It is ours, and every surface says so. Not press, not an independent industry study — Digi Dental's own analysis under the founder's byline. Tests assert that wording stays in llms.txt, /api/site-info and the page copy, because the site's whole credibility argument is built on sourced numbers and an honest demo, and one overclaimed citation would undo both.

Dashboard preview accordion

Four screenshots in [data-section="dashboard"], one open at a time.

Click-driven, deliberately. It was onMouseEnter, which meant moving the cursor toward a panel re-selected every panel it crossed — while each was mid width-transition, so the element under the pointer kept changing and the selection thrashed. npm run test:render sweeps the cursor across all four and asserts the selection does not move.

Two layouts, same 0.55s curve:

  • ≥1000px — a row. Collapsed panels are 74px rails with rotated labels; the open one animates width.
  • <1000px — a column. The rail becomes a tappable header and the body animates grid-template-rows: 0fr → 1fr, which resolves to the content's real height. max-height was rejected: a guessed ceiling makes the easing finish early and stutter.

.dd-dash-body is reset to display:block on desktop so the grid transition cannot fight the width transition.

Changing the domain

Canonical, hreflang, Open Graph, JSON-LD @ids, sitemap, robots and llms.txt all carry an absolute base URL. To move to a different domain, update every occurrence in one pass from the repo root:

grep -rl 'www.digidental.us' --exclude-dir=.git --exclude-dir=node_modules . \
  | xargs sed -i 's|www\.digidental\.us|YOUR-DOMAIN.com|g'

Then update ORIGIN in test/seo.test.mjs and run npm run test:seo — it fails on any file left behind. Point the domain at the Vercel project (Settings → Domains) and set it as the primary domain so other hosts redirect to it.

Google Search Console

Verify the domain property (digidental.us), not a URL prefix. A domain property covers the apex, www, http and https and every subdomain in one record, so nothing needs re-verifying when a hostname changes.

  1. https://search.google.com/search-console → Add property → Domaindigidental.us.
  2. Google shows one TXT record. In Namecheap → Domain List → digidental.us → Advanced DNS → Add New Record: type TXT Record, Host @, Value the google-site-verification=… string, TTL Automatic. Do not delete the existing SPF TXT record — a domain may hold many TXT records but only one starting v=spf1.
  3. Wait for propagation, then press Verify.
  4. Sitemaps → submit sitemap.xml.
  5. Settings → Crawl stats is the closest thing to a log-file analyser available on this stack: Vercel's Hobby plan exposes no raw access logs, so bot behaviour is read from Search Console rather than from the origin.

If DNS verification is blocked for any reason, the fallback is a <meta name="google-site-verification" content="…"> in the static <head> of index.html, directly under the canonical link (a URL-prefix property covers /how-it-works/ too, since it sits under the same prefix). That only verifies the exact URL prefix it is served from, which is why DNS is preferred.

Where real keys go

Real values are entered ONLY in your local .env (gitignored) and in the hosting dashboard (Vercel → Project → Settings → Environment Variables). Never in code.

  • VITE_* vars are public by design (anon key is protected by RLS; the Voice Infrastructure key is the PUBLIC web key).
  • RESEND_API_KEY, RESEND_FROM, SUPABASE_SERVICE_ROLE_KEY, IP_HASH_SALT, NOTIFY_EMAIL_TO, SITE_TOKEN, VIDEO_VSL_URL and VIDEO_DEMO_URL are server-side only.

Demo call hard limits

The client stops the call at 60s, but the real limit lives in the Voice Infrastructure dashboard: set maxDurationSeconds: 60 and a spend cap on the demo assistant.

The visitor-level limit is enforced server-side, in two steps: startDemo() asks /api/demo-session whether this hashed IP may call, and goLive() posts { claim: true } to spend it once the call is actually connected — so a denied microphone or a dropped connection costs the visitor nothing. Only an explicit already_demoed blocks a call; transport errors deliberately fail open, because a hot lead must never be turned away by our own hiccup. localStorage remains as the instant, no-round-trip layer above it.

Analytics

Every funnel step fires through track() in dd-logic.js, on both pages. Every event carries path, so the two pages are separable in the data. Three sinks, one taxonomy:

  1. /api/event → the site_events table. Always on, no third-party tracker. Apply supabase/APPLY_TO_SUPABASE.sql first (migrations 005, 006 and 007 in one paste) — until site_events exists, events are accepted and dropped. If the dashboard reports functions missing from the schema cache, only the tail of that 39 KB file failed to paste: run the matching single-migration file instead — supabase/APPLY_007_ONLY.sql or supabase/APPLY_008_ONLY.sql. Each is ~10 KB and prints the functions it created, so a truncated paste is visible rather than silent. (002 and 003 are superseded by 005; they are kept only as history and must not be run.) Then apply supabase/APPLY_010_ONLY.sql — the split into two pages made three of 006's aggregates wrong (see Two pages, one dataset below). Nothing breaks while you wait: every event already records the page it happened on, so no history is lost, and the Pages view says the split is unavailable rather than showing zeroes.
  2. Plausible — set PLAUSIBLE_DOMAIN in dd-logic.js to the live domain and the script loads itself; every event mirrors automatically.
  3. GA4 — drop a gtag snippet in and every event mirrors to it. Nothing else to wire.

Identity

Three separate ideas, deliberately not conflated:

Stored in Lives for Used for
visitor_id localStorage until storage is cleared counting people, returning visits, journeys
session_id sessionStorage one visit, 30-min idle rollover grouping the steps of a visit
ip_hash server only per request abuse control only, never identity

Both ids are random and carry no personal data. ip_hash used to double as the visitor count, which was wrong in both directions: IPs rotate on mobile networks and are shared across an office. The privacy copy in dd-logic.js (LEGAL.privacy) discloses the stored identifier.

Taxonomy

Events carry a category so a whole class can be filtered without matching on names. The allowlist in api/event.ts is derived from the same map, so the two cannot drift.

  • BEHAVIORpage_view, view_hero, section_view, scroll_depth, section_time, video_play, video_progress, video_watch, calc_interact, session_end, element_click
  • INTENTcta_impression, cta_click, form_open, form_step_1..5, demo_start, exit_intent_shown, exit_intent_demo
  • CONVERSIONdemo_complete, form_submit, calendly_click, lead_captured, contact_click
  • TECHNICALdemo_error, demo_blocked, form_error, page_not_found

cta_impression is what makes click-through a real rate rather than a reflection of page position: mark any new button with data-cta="<placement>" and the impression fires when it scrolls into view. The placement string must match the location the click handler reports, or the two will not line up. Videos are named with data-clip, never by DOM order.

element_click and contact_click come from a single delegated listener on the document, so every link and button is counted without anyone having to instrument it — including controls added later. That matters: before it existed, fewer than half the WhatsApp and email links on the page had a handler, so "how many people actually emailed me" had no answer. contact_click carries channel (whatsapp / email / phone), and Calendly is folded in alongside it by rpc_contact so all four routes to you are compared on one footing.

Two pages, one dataset

006 was written when the site was one document, and three of its aggregates quietly assumed that. Migration 010 fixes all three, entirely in the reporting layer — no table changes, no backfill, every number recomputed from history already stored.

  • Section names are not unique across the two pages. case_study and final exist on both. rpc_sections grouped on the name alone, so those rows were two different sections added together — reach, median dwell and exits all merged. It is keyed by (page, section) now, and the dashboard labels each row with the page.
  • reach_pct divided by every visitor to the site. A section on /how-it-works/ can only be seen by the people who got there, so every deep-page section read as underperforming by exactly the share of traffic that never left the home page. The denominator is now that page's own visitors. rpc_scroll had the same problem, plus it averaged two documents of very different heights into one curve; it is per page now.
  • rpc_funnel stepped straight from "landed" to "saw the demo section", and the demo section lives only on the deep page. The largest single drop on the site — people who never make the jump between pages — was invisible, folded into the demo step. There is now a "Reached How it works" step between them.

rpc_pages is new: views, visitors, which page a visit started on, and the cross-over rate between the two. _dd_page() maps a stored path to home / deep / other and is tolerant of /how-it-works without the trailing slash and of /index.html.

Anything added to the site later that exists on both pages needs no special handling — the page comes from the event's own path, not from the section name.

The dashboard's own views

/admin.html is one page with real views rather than a long scroll: the sidebar swaps content instead of jumping a few hundred pixels. The Overview leads with one large chart that switches between visitors, clicks, visits, demos, forms, contacts and leads — every series arrives in a single rpc_series call, so switching is instant and all of them cover the same days.

Pages (rpc_pages) is where the two-page funnel is read: traffic per document, which page each visit started on, the cross-over rate, and — because neither means anything without knowing which document it happened on — section reach and scroll depth, split by page. Every panel in it degrades to the old site-wide numbers if 010 has not been applied, and says so.

Click log (rpc_click_log) is the record behind the click-through rates: one row per click with the timestamp, what was clicked, which section, the channel, country/region/city, device, source and the anonymous visitor id. Nothing new is collected; it is the events already stored, shown individually rather than only in aggregate.

Audit (rpc_monthly, rpc_monthly_breakdown) works in calendar months rather than a rolling window, so one month can be compared with the last, and splits those conversions by source, country, device or campaign.

Export covers .md (a full written report), .csv (every dataset), .json (raw), and .png of the current chart, rendered from its own SVG onto a canvas.

Aggregation

All of it happens in Postgres, in the functions created by 006 and amended by 010. /api/stats just calls them and returns the result, so the payload stays small no matter how many events exist. Adding a panel means adding a function, not looping in JavaScript.

Failures are reported per panel, not per request: one missing function greys out one card and names itself in the banner, rather than failing the whole load. That is what makes it safe to ship a dashboard change before its migration has been pasted.

Admin dashboard

/admin.html is a private traffic dashboard built to answer 24 specific business questions; each panel is labelled with the ones it covers. Visitors and returning visitors, the funnel in unique visitors, CTA impressions/clicks/CTR and downstream submits, section reach and exit rate, video completion, scroll reach, traffic source through to leads, countries, devices, referrers, errors and dead links, conversion path analysis, a visitor list with a full journey drill-down, and the lead table with attribution.

  • Set ADMIN_PASSWORD in Vercel → Settings → Environment Variables. Until it is set, /api/stats and /api/admin-login return 503 and the dashboard shows nothing. It never falls open.
  • Set ADMIN_SESSION_SECRET to a long random value. It signs the session cookie and is deliberately separate from the password, because a signing key should be random and a password you type is not. Falls back to the password if unset.
  • Bump ADMIN_SESSION_VERSION to sign every session out at once. ADMIN_SESSION_HOURS controls how long a login lasts (default 168 = 7 days).
  • Login attempts are counted in Postgres, so the limit survives cold starts. The old in-memory limit reset on every new lambda instance.
  • /api/stats aggregates server-side with the service-role key, so the browser never holds a database credential and raw event rows never leave the server.
  • The page is noindex, no-store, disallowed in robots.txt and refuses to be framed — but that is tidiness. The password is the security.
  • Needs migrations 005 and 006 applied, otherwise there is nothing to read.

Voice demo (Vapi)

The in-browser demo call uses the official @vapi-ai/web SDK, pinned to an exact version, loaded from the <helmet> block of how-it-works/index.html — the only page with a demo on it, and therefore the only page that should pay for the module. / never loads it, and dd-logic.js skips the whole Vapi path when PAGE !== 'deep'.

It previously loaded cdn.jsdelivr.net/gh/VapiAI/html-script-tag@**latest**. An unpinned dependency means every page load takes whatever upstream published last, so the call path can change with no deploy on our side — which is how working calls became error-assistant-did-not-receive-customer-audio. Change the pinned version deliberately, and test a real call afterwards. Never go back to a floating tag.

Configuration. VAPI_PUBLIC_KEY and VAPI_ASSISTANT_ID are class properties in dd-logic.js (mirrored by VITE_VAPI_PUBLIC_KEY / VITE_VAPI_ASSISTANT_ID in .env for a bundled build). The public web key is the only Vapi key that may appear client-side; a private/server key must never be in the browser. The assistant id is validated as a UUID before any call: if it is missing, a placeholder, or malformed, the page shows an error and starts no call at all, rather than creating one with no assistant attached.

Microphone. Permission is requested explicitly before connecting, and the resulting track is inspected (readyState, enabled, muted). NotAllowedError, NotFoundError, NotReadableError and OverconstrainedError each get their own message, and a denial shows browser-specific instructions with a Retry. Once live, getLocalAudioLevel() is sampled: eight seconds of silence with nothing ever heard raises a "we can't hear you" hint, which is the client-side view of the same failure Vapi reports as did not receive customer audio.

Lifecycle. One client per page, listeners attached once, so retries never duplicate handlers or create a second call. start() returns a Promise and is handled as one — the old code called it inside a try/catch and returned, so a rejection escaped and the UI hung on "Connecting…". The call is only shown as connected when the SDK emits call-start; the microphone is released on call-end, never during setup.

Requirements. Calls start from a click, never on load. Microphone access needs HTTPS (or localhost). vercel.json sets Permissions-Policy: … microphone=(self). If the page is ever embedded, the parent iframe needs allow="microphone; autoplay". There is no CSP on the site today; if one is added it must permit the jsDelivr script and Daily's WebRTC endpoints (*.daily.co, wss:), or calls will fail to connect.

Diagnostics are development-only — localhost, a Vercel preview, or ?debug=1. They log configuration state, device label, track state and call lifecycle. No transcripts, no audio, no keys, no visitor identifiers.

Tests. npm test drives the real page in headless Chromium with the SDK and microphone stubbed: missing assistant id, permission failures, duplicate clicks, listener reuse and cleanup.

Videos

Both clips are served through /api/video?clip=vsl|demo, so the page carries no expiring token. The signed URLs hardcoded as a fallback in api/video.ts expire 2027-07-24. Permanent fix: make the digi_dental-VSL bucket public and set VIDEO_VSL_URL / VIDEO_DEMO_URL to the public URLs. Interim fix: paste fresh signed URLs into those two env vars — no redeploy of the page needed either way.

Dashboard preview section

The section between the FAQ and the final CTA shows four captures of the Vapi console the practice owner works in. It sits there deliberately: objections have just been answered and the price is known, so the last thing before the ask is evidence that a real system exists.

The interaction is the supplied React accordion — one panel open, the others collapsed to labelled rails — ported natively. This site has no React build step, no Tailwind and no components/ui directory; it is a single static HTML file driven by the dc-runtime in support.js. Dropping in a .tsx component would require adding a bundler and restructuring the deployment, so the behaviour was reproduced in the existing architecture instead. (To use the component as written you would need npx shadcn@latest init, Tailwind and TypeScript, which means converting this page into a React app — a much larger change than the section warrants.)

On aspect ratios. These are full-page captures whose dimensions are not known when the markup is written, so nothing assumes one. Each frame takes its ratio from the image's own naturalWidth/naturalHeight on load, and captures taller than roughly 1.2:1 scroll inside their frame rather than stretching the page. Verified against both a 1:4 and a 2.4:1 image: rendered height matches the natural ratio exactly, so nothing is cropped or letterboxed.

Images are served through /api/image?name=… for the same reason as the videos: the supplied Supabase signed URLs expire 2027-08-10. Set IMAGE_*_URL env vars, or make the bucket public, and the expiry stops mattering. The founder portrait uses the same route.

Before launch checklist

No PLACEHOLDER strings remain in rendered copy. What is still open, in priority order:

  1. Legal review. The Privacy and Terms copy in dd-logic.js (LEGAL), the HIPAA FAQ answer and the contracts/data FAQ answer are final, honest copy that claims no certification — but they have not been read by a lawyer. The LEGAL block carries a LEGAL REVIEW REQUIRED comment listing exactly what to check (subprocessors, jurisdiction sections, entity name and governing law, which are deliberately not invented).
  2. Commercial promises. The split start is confirmed: $1,000 up front, the second $1,000 due only if a real patient is booked within 14 days of go-live. It appears in the pricing section, LEGAL.terms in dd-logic.js, llms.txt and api/site-info.ts — change all four together. The go-live line is deliberately a speed claim with no refund remedy, because that promise was never confirmed; the comment above the badge on both pages has the exact wording to turn it into a real guarantee if you decide to stand behind one.
  3. Care-plan pricing. $149/mo and $299/mo are carried over unverified from the previous copy. Confirm they are current before the next push; if they changed, update the pricing section in how-it-works/index.html, the JSON-LD offers on both pages, llms.txt and api/site-info.ts.
  4. Resend sender. Verify a domain in Resend and set RESEND_FROM; until then leads send from onboarding@resend.dev and land in spam.
  5. Supabase publishable key. The browser-fallback key in dd-logic.js is a new-style sb_publishable_… key, which really is that short. Confirm it with the curl in the comment above it: 401 means replace it, 403 means it is fine and RLS is working.
  6. Founder block. Replace the monogram with a real photo and rewrite the two sentences in your own words. No testimonials, client counts or usage numbers until they are real.
  7. Statistics were sourced June 2026 — refresh annually. Stat 4 is now labelled as our own arithmetic rather than an uncited external figure.

Stat count-up

The four figures in [data-section="cost"] count up over 3.4s via animateCount.

The count grows the string, not just the number. "0""8,000" is four characters wider, and the stat around it reads $<count>–$10,000. Measured, that span's layout width went 23 → 69 → 101 → 103px during a single run. On a phone the line fits one row at the start of that swing and needs two by the end, so it re-wrapped mid-count and the block jumped — worse on Android, where Chrome re-runs font boosting whenever the layout changes.

The fix holds the box open with a hidden copy of the final string and paints the running value over it, absolutely positioned so it contributes no width. Reserving with the real final glyphs rather than a measured pixel value keeps it exact when Fraunces finishes loading and the metrics change underneath, and it does not rely on the font carrying tabular figures. Width is now a single value for the whole run.

Scoped to max-width:900px. Desktop columns are wide enough that the line never re-wraps, and there the number growing outward from centre is the nicer read.

When measuring this, use offsetWidth, not getBoundingClientRect() — the stats sit in a data-reveal block whose entrance transform inflates the visual rect and looks exactly like layout drift.

Security posture

Findings closed from the security audit, and what each one actually prevented:

  • Client IP. Every IP-keyed control read x-forwarded-for[0]. A proxy chain prepends the client's own value, so that element is whatever the caller typed — one header bypassed the free-demo gate, the lead rate limit and the login throttle at once. lib/http.ts prefers x-vercel-forwarded-for and otherwise takes the last hop.
  • Demo gate. Was check-then-insert, so two simultaneous requests both won. Migration 009 adds a unique index on demo_sessions.ip_hash; the claim is an upsert with ignoreDuplicates and a returned row is the proof. The check now fails closed — an outage costs one retry, not uncapped voice minutes — and the client distinguishes already_demoed from unavailable so our outage never spends a visitor's free demo.
  • Lead email. Visitor fields were interpolated raw into the owner's inbox. All escaped, plain-text part added, control characters stripped, malformed addresses rejected.
  • Admin session. 24 hours, and ADMIN_SESSION_SECRET is now required — no silent fallback to signing with the password.
  • CSP. Shipped, and npm run test:render serves vercel.json's real headers so it is exercised rather than trusted. 'unsafe-eval' is load-bearing: the dc-runtime compiles each logic class from a string, and without it the page renders 259 characters and mounts nothing. unpkg (React) and jsdelivr (voice SDK, GSAP) are both required in script-src. connect-src is deliberately broad because the voice SDK negotiates WebRTC through hosts chosen at call time.

Still open, and why: the signed storage URLs in api/image.ts and api/video.ts stay until the buckets are public and IMAGE_*_URL / VIDEO_*_URL are set — emptying them now breaks every image and both videos. They are download-only and expire 2027-08-10; npm run test:security pins them to those two files so the count can only go down.

Conversion changes

From the marketing audit. Every one is placement or framing — no commercial term moved.

  • The split start now leads the hero trust line. It was the strongest argument on the page and lived in pricing, where anyone who bounced early never saw it.
  • The live demo is a real secondary button, not an underlined line of text.
  • One CTA name everywhere: "Claim your practice audit slot" is gone.
  • Year-one cost as a <details> in the pricing card, because owners annualise whatever they are shown and a guess is always worse than the arithmetic.
  • The three objections that stop a decision — HIPAA, PMS, go-live — render inline under the demo and the pricing card, from the same faqData as the FAQ section, so an answer cannot say two different things in two places.
  • The founder card sells accountability, not endorsement. There is a SOCIAL PROOF SLOT for real customer quotes: populate SOCIAL_PROOF and the section renders itself, tracked as data-section="social_proof". Leave it empty until a real practice agrees to be quoted — the page's credibility rests on sourced numbers and a live demo, and one invented quote undoes both.

About

24/7 human-sounding AI receptionist that answers every call and writes appointments straight into your calendar, set up entirely for you.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages