diff --git a/landing/buy/success/index.html b/landing/buy/success/index.html index dca957f..fb65b39 100644 --- a/landing/buy/success/index.html +++ b/landing/buy/success/index.html @@ -10,6 +10,29 @@ /> + +
@@ -35,11 +58,21 @@

Activate Dictx Pro

Your License Key

-

- If no key is shown here, copy your key from Polar customer portal or receipt email. +

+ If no key is shown here, copy your key from Polar customer portal or + receipt email.

- Back to Dictx @@ -70,7 +103,8 @@

Your License Key

if (!key) return; try { await navigator.clipboard.writeText(key); - copyStatus.textContent = "Copied. Paste it in Dictx Settings -> About."; + copyStatus.textContent = + "Copied. Paste it in Dictx Settings -> About."; } catch (_error) { copyStatus.textContent = "Copy failed. Select and copy manually."; } diff --git a/landing/index.html b/landing/index.html index 96403f6..055d78a 100644 --- a/landing/index.html +++ b/landing/index.html @@ -20,6 +20,28 @@ href="./apple-touch-icon.png" /> + +
@@ -31,14 +53,21 @@ @@ -56,8 +85,14 @@

Transcription you can trust in real work.

  • Signed Pro builds
  • @@ -96,7 +131,11 @@

    Obsidian-ready output

    -
    +

    Free or Pro

    Free stays powerful for daily use. Pro removes setup friction and @@ -118,13 +157,17 @@

    Free or Pro

    href="https://github.com/splitlabs/dictx" target="_blank" rel="noreferrer" + data-fast-goal="github_click" + data-fast-goal-location="pricing" >View source

    Pro

    $29 one-time

    -

    Early access: first 100 installs get Pro free

    +

    + Early access: first 100 installs get Pro free +

    • Signed desktop binaries
    • In-app auto-updater
    • @@ -132,7 +175,9 @@

      Free or Pro

    • Professional checkout
    • Priority support contact
    - Get Dictx Pro + Get Dictx Pro
    @@ -185,7 +230,7 @@

    Free or Pro

    -
    +

    FAQ

    Is Dictx still open source? @@ -194,17 +239,16 @@

    FAQ

    What does Pro actually add?

    - Pro is the convenience layer: signed installers, in-app updates, - and priority support. Free still includes the full local dictation + Pro is the convenience layer: signed installers, in-app updates, and + priority support. Free still includes the full local dictation workflow.

    How does the first-100 free Pro claim work?

    - The app claims it automatically on first activation. If your - install lands in the first 100, Pro is enabled instantly at no - cost. + The app claims it automatically on first activation. If your install + lands in the first 100, Pro is enabled instantly at no cost.

    @@ -235,8 +279,16 @@

    FAQ

    signed installers and one-click updates.

    @@ -250,6 +302,32 @@

    FAQ

    document.getElementById("year").textContent = String( new Date().getFullYear(), ); + + // Buy clicks leave the page for checkout at once, and DataFast posts + // goals with a plain XHR that the browser cancels on navigation. So the + // goal is sent here (not via data-fast-goal, which would double-count) + // and a plain click waits briefly before following /buy. Modified + // clicks open a new tab and need no delay. + document.addEventListener("click", function (event) { + var link = event.target.closest("a[data-buy-location]"); + if (!link) return; + window.datafast("buy_pro_click", { + location: link.getAttribute("data-buy-location"), + }); + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) + return; + event.preventDefault(); + setTimeout(function () { + window.location.href = link.href; + }, 250); + }); diff --git a/landing/middleware.ts b/landing/middleware.ts new file mode 100644 index 0000000..5f5439f --- /dev/null +++ b/landing/middleware.ts @@ -0,0 +1,35 @@ +import { trackAICrawlerRequest } from "@datafast/ai-crawl"; + +// Provided by the Vercel runtime; declared so type-checking the middleware +// does not require @types/node in this dependency-free static project. +declare const process: { env: Record }; + +/** + * DataFast bot traffic (datafa.st/docs/bot-traffic-tracking) as Vercel + * Routing Middleware for this static project. + * + * A local user-agent check on document requests; only known AI crawlers + * produce one background POST, scheduled with context.waitUntil and never + * awaited, so humans never trigger a network call and no response waits on + * DataFast. The request always passes through to the static file or function + * untouched. No-op until DATAFAST_WEBSITE_ID is set. + */ +// Edge runtime on purpose. Vercel warns that Edge is deprecated for Routing +// Middleware, but on this static "Other" project the Node.js runtime served +// every matched page as a 500 on preview, while Edge served 200. +export const config = { + matcher: [ + "/((?!api/|js/|.*\\.(?:png|jpe?g|gif|svg|webp|avif|ico|css|js|map|woff2?|ttf)$).*)", + ], +}; + +export default function middleware( + request: Request, + context: { waitUntil(promise: Promise): void }, +) { + const websiteId = process.env.DATAFAST_WEBSITE_ID?.trim(); + if (websiteId) trackAICrawlerRequest(request, context, { websiteId }); + // Pass through untouched. This is exactly what next() from @vercel/functions + // returns, so the pass-through does not depend on runtime defaults. + return new Response(null, { headers: { "x-middleware-next": "1" } }); +} diff --git a/landing/package.json b/landing/package.json index 5bbefff..4e87f7d 100644 --- a/landing/package.json +++ b/landing/package.json @@ -1,3 +1,7 @@ { - "type": "commonjs" + "type": "commonjs", + "private": true, + "dependencies": { + "@datafast/ai-crawl": "1.0.9" + } } diff --git a/landing/vercel.json b/landing/vercel.json index 44056bb..e6fd465 100644 --- a/landing/vercel.json +++ b/landing/vercel.json @@ -1,4 +1,14 @@ { + "rewrites": [ + { + "source": "/js/script.cookieless.js", + "destination": "https://datafa.st/js/script.cookieless.js" + }, + { + "source": "/api/events", + "destination": "https://datafa.st/api/events" + } + ], "redirects": [ { "source": "/buy", diff --git a/scripts/datafast-funnels.mjs b/scripts/datafast-funnels.mjs new file mode 100644 index 0000000..2460344 --- /dev/null +++ b/scripts/datafast-funnels.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node +/** + * Create the Dictx landing's DataFast conversion funnels through the Account API. + * + * Funnels are dashboard objects, not code, so this is the one place their + * definitions live in the repo. Idempotent: existing funnels (by name) are left + * alone and reported; nothing is ever deleted or updated. Run again after adding + * a funnel below. + * + * DATAFAST_ADMIN_TOKEN=dft_... node scripts/datafast-funnels.mjs [--dry-run] + * + * The token comes from DataFast: Account settings -> API, scopes websites:read, + * funnels:read, funnels:write. It is an account credential: never commit it, + * never put it in Vercel; it is only needed on the machine running this script. + * + * Step semantics (datafa.st/docs/api/account/funnels/create): pageview steps + * match a URL path, goal steps match a goal *name* only. The landing is + * static HTML, so goals come from data-fast-goal attributes and the buy-click + * handler in landing/index.html. + */ + +const API = "https://datafa.st/api/v1/admin"; +const DOMAIN = process.env.DATAFAST_SITE_DOMAIN ?? "dictx.splitlabs.io"; +const DRY_RUN = process.argv.includes("--dry-run"); + +// Names are stable identifiers: renaming one here creates a second funnel. +export const FUNNELS = [ + { + // buy_pro_click is sent by the landing's click handler; payment is the + // provider goal DataFast emits once the payment provider is connected. + name: "Landing to Pro purchase", + steps: [ + { name: "Landing", type: "pageview", url: "/" }, + { name: "Buy Pro clicked", type: "goal", goalName: "buy_pro_click" }, + { name: "Payment", type: "goal", goalName: "payment" }, + ], + }, + { + name: "Pricing to purchase", + steps: [ + { name: "Saw pricing", type: "goal", goalName: "scroll_pricing" }, + { name: "Buy Pro clicked", type: "goal", goalName: "buy_pro_click" }, + { name: "Payment", type: "goal", goalName: "payment" }, + { + name: "License key copied", + type: "goal", + goalName: "license_key_copied", + }, + ], + }, + { + name: "Landing to download", + steps: [ + { name: "Landing", type: "pageview", url: "/" }, + { name: "Download DMG", type: "goal", goalName: "download_dmg" }, + ], + }, +]; + +async function api(path, init = {}) { + const token = process.env.DATAFAST_ADMIN_TOKEN; + if (!token) throw new Error("DATAFAST_ADMIN_TOKEN is not set"); + const response = await fetch(`${API}${path}`, { + ...init, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + ...(init.headers ?? {}), + }, + }); + const json = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error( + `${init.method ?? "GET"} ${path} -> ${response.status}: ${JSON.stringify( + json, + ).slice(0, 300)}`, + ); + } + return json; +} + +async function main() { + const websites = (await api("/websites")).data ?? []; + const site = websites.find( + (w) => (w.domain ?? w.hostname ?? "").replace(/^www\./, "") === DOMAIN, + ); + if (!site) { + throw new Error( + `no DataFast website for ${DOMAIN}; have: ${websites + .map((w) => w.domain ?? w.hostname) + .join(", ")}`, + ); + } + const websiteId = site._id ?? site.id; + const existing = (await api(`/websites/${websiteId}/funnels`)).data ?? []; + const byName = new Map(existing.map((f) => [f.name, f])); + + for (const funnel of FUNNELS) { + if (byName.has(funnel.name)) { + console.log( + `= ${funnel.name} (exists, ${ + byName.get(funnel.name).steps?.length ?? "?" + } steps)`, + ); + continue; + } + if (DRY_RUN) { + console.log( + `+ ${funnel.name} (would create: ${funnel.steps + .map((s) => s.goalName ?? s.url) + .join(" -> ")})`, + ); + continue; + } + await api(`/websites/${websiteId}/funnels`, { + method: "POST", + body: JSON.stringify(funnel), + }); + console.log(`+ ${funnel.name} (created)`); + } +} + +if ( + process.argv[1] && + import.meta.url.endsWith(process.argv[1].split("/").pop()) +) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +}