From 57585d8ad258645e7e8351aaace880179890da48 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 18:36:55 +0200 Subject: [PATCH] Tell a new viewer what to buy before asking them to fill in a form Someone with their own accounts had to work out on their own that Amber needs a paid subscription somewhere before it can find anything, and which of three services that means. The Zdroje box already turns their usernames into addon URLs; nothing anywhere told them which usernames to go and get, or what it would cost. Six steps as a new #start tab: change the generated password, choose services, create the accounts and pay, take a free TMDB key, enter it all in Zdroje, download. Steps 1, 5 and 6 hand off to the tabs that already do that work. This screen owns no credentials and no crypto on purpose, since a second encryptor for one blob is how a vault gets corrupted. The password is step 1, not step 6. Changing it re-keys the vault with a fresh salt, so every device already signed in holds a stale key until it authenticates again. Done first, while the vault is empty, there is nothing to re-encrypt and no paired television to strand. State is three fields on the user record and nothing more: skipped, chosen and pwChangedAt. Whether the sources exist is CONFIGS.length and whether TMDB is set is a field in the decrypted config, so storing those again would let two answers disagree. pwChangedAt is the one thing that cannot be derived, because PocketBase records no password-changed timestamp. Prices and click-paths live in a providers collection, editable in the admin UI, with priceCheckedAt rendered beside the number so a stale figure looks stale rather than reading as a promise. Owner supplied the three signup guides; TorBox Free is called out as unusable because it has no API access, which is the only way Amber talks to it. byGo also stops demanding a Czech host. Nothing downstream needed one, and someone who only wants anime was being told to buy a service they had no use for. Verified against PocketBase 0.39.6 from a throwaway data dir with this repo's real migrations, hooks and page: both migrations applied clean, the flow opened itself for a user with no sources, a choice persisted as {"chosen":["prehrajto","torbox"]} and nothing else, guides rendered numbered with working links, skipping raised the banner and survived a reload, resuming came back in. Co-Authored-By: Claude Opus 5 --- docs/onboarding-contract.md | 86 ++++ pb_migrations/1790500000_providers.js | 81 ++++ pb_migrations/1790500001_users_onboarding.js | 55 +++ pb_public/index.html | 430 ++++++++++++++++++- scripts/providers.seed.json | 125 ++++++ scripts/seed-providers.py | 89 ++++ 6 files changed, 843 insertions(+), 23 deletions(-) create mode 100644 pb_migrations/1790500000_providers.js create mode 100644 pb_migrations/1790500001_users_onboarding.js create mode 100644 scripts/providers.seed.json create mode 100644 scripts/seed-providers.py diff --git a/docs/onboarding-contract.md b/docs/onboarding-contract.md index de26199..70d3832 100644 --- a/docs/onboarding-contract.md +++ b/docs/onboarding-contract.md @@ -105,3 +105,89 @@ run in a real browser produced the user, the named profile, and an with every field intact (adult fields null/false) · `/get/tv` streamed a published dummy APK byte-identical with the attachment header · landing's download login handled the no-releases state. + +## The setup flow for someone with their own accounts (2026-08-12) + +The family path is the `onboarding_template` route above: one code, and the shared +credentials arrive already filled in. This is the other path, for someone who is +trusted with an account but brings their own subscriptions, and until now had to +work out on their own that Amber needs paid accounts somewhere before it can find +anything. + +`#start` is a tab in `index.html`, not a new page, because the deploy copies files +into `pb_public` and never deletes the ones a commit removed. That is why stale +`onboarding.html` and `settings.html` still answer 200 and need shadow routes in +`web.pb.js`, and one file avoids repeating it. + +### The six steps + +| | Step | Finished when | +|---|---|---| +| 1 | Change the generated password | `onboarding.pwChangedAt` is set | +| 2 | Choose services | `onboarding.chosen` is non-empty | +| 3 | Create accounts and pay | an `addon_config` row exists | +| 4 | Get a TMDB key | the decrypted config has `tmdbKey` | +| 5 | Enter the credentials in Zdroje | an `addon_config` row exists | +| 6 | Download and pair | never; you can always want the app again | + +Steps 1, 5 and 6 hand off to the existing Účet, Zdroje and Stáhnout tabs. This +screen owns no credentials and no crypto, deliberately: a second encryptor for one +blob is how a vault gets corrupted. Step 5 in particular is just a signpost to +`adByocBox`, which already turns usernames and keys into addon URLs by calling the +addon's `/encode` straight from the browser. + +**The password is step 1 and not step 6.** Changing it re-keys the vault with a +fresh salt, so every device already signed in holds a stale key until it +authenticates again. First, while the vault is still empty, there is nothing to +re-encrypt and no paired television to strand, and the password is still in memory +from the sign-in that just happened. + +### State: three fields, and everything else derived + +`users.onboarding` is json holding only `skipped`, `chosen` and `pwChangedAt`. +Nothing else needs storing: whether the sources exist is `CONFIGS.length`, and +whether TMDB is set is a field in the decrypted config. A second copy of a +derivable fact eventually disagrees with the first, and then the flow either nags +someone who has finished or congratulates someone who has not. + +`pwChangedAt` is the exception that cannot be derived, because PocketBase records +no password-changed timestamp and `updated` moves for any edit. Absent means "as +far as we know, still on the password the owner generated", which is the honest +default for a reminder. + +The flow opens itself only when there is no `addon_config` and `skipped` is not +set, so the family, who all have sources already, never see it. An explicit +`#hash` always wins so bookmarks still land where they say. + +### Content lives in `providers`, not in the page + +Prices and click-paths go stale on somebody else's schedule, and a wrong price on +our own page reads as a promise. `providers` is editable in the admin UI and +`priceCheckedAt` renders next to the number, so a stale figure looks stale. +`scripts/providers.seed.json` is the starting point; `scripts/seed-providers.py` +upserts by slug and leaves existing rows alone unless given `--force`, so it is +safe to run after any deploy. + +`slug` is load-bearing: the page keys its wiring off `torbox`, `prehrajto`, +`webshare` and `tmdb`, and the seeder refuses anything else. Everything else is +free text. + +`kind` separates the two questions the flow asks. `source` rows are a choice to +weigh up; `metadata` (TMDB) is not optional and is not offered as one, because +without a key there are no posters and no descriptions. + +### Verified locally (2026-08-12) + +Against PocketBase 0.39.6, the pinned version, run from a throwaway data dir with +this repo's real `pb_migrations`, `pb_hooks` and `pb_public`. Docker was not used: +its daemon is disabled on the dev box and the binary alone is enough. + +Both migrations applied clean on a fresh database. Then, as a locally created test +user with no sources: the flow opened itself; choosing prehraj.to and TorBox +persisted as `{"chosen":["prehrajto","torbox"]}` and nothing else; step 3 rendered +both guides numbered with working links; skipping moved to Profily and raised the +banner; a reload kept it skipped rather than reopening; resuming came back in. + +Not yet verified: the whole thing against the live instance, and a real TorBox or +webshare signup followed end to end by someone who does not already know the +answer. diff --git a/pb_migrations/1790500000_providers.js b/pb_migrations/1790500000_providers.js new file mode 100644 index 0000000..bdfe2e2 --- /dev/null +++ b/pb_migrations/1790500000_providers.js @@ -0,0 +1,81 @@ +/// + +// The services a BYOC user has to buy, described in data rather than in markup. +// +// **Why a collection and not copy in index.html.** Two of these fields go stale +// on somebody else's schedule: the price, and the click-path through a signup +// form the provider is free to redesign. A wrong price on our own page is worse +// than no price — it reads as a promise — and the fix must not require editing +// HTML and redeploying the backend the family streams through. Everything here +// is editable in the admin UI, and `priceCheckedAt` is rendered next to the +// number so a stale figure is visibly stale instead of quietly wrong. +// +// **Why not onboarding_template.** That collection holds the family's actual +// credentials and is superuser-only on every rule for that reason. Public-facing +// marketing copy has no business sharing a table with secrets, and widening its +// read rule to serve a price list is exactly the kind of accident this comment +// exists to prevent. +// +// **Why `kind`.** TMDB is not a source and is not optional — no key means no +// posters and no descriptions — so the chooser must not offer it as one of three +// things to weigh up. `source` rows are choices; `metadata` rows are steps that +// happen regardless. Keeping it a token rather than a boolean leaves room for a +// third kind without another migration. +// +// **Why `price` is text.** These are quoted in different currencies and +// different periods ("5 $/měsíc", "199 Kč/měsíc", "$30/rok"), and the page only +// ever displays it. A number plus a currency column would be three fields to get +// wrong in exchange for arithmetic nobody does. +// +// Read is limited to signed-in users. There is no public signup — accounts are +// owner-created — so nobody who cannot log in has any use for this, and the +// tighter rule is free. Writing stays superuser-only: this is content the owner +// maintains, not something a user submits. + +migrate( + (app) => { + const c = new Collection({ + type: "base", + name: "providers", + listRule: "@request.auth.id != ''", + viewRule: "@request.auth.id != ''", + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + // torbox | prehrajto | webshare | tmdb. The page keys its per-service + // wiring off this, so it is the one field that must not be edited + // casually — hence the note here rather than only in the UI. + { type: "text", name: "slug", required: true, max: 40 }, + { type: "text", name: "name", required: true, max: 80 }, + { type: "url", name: "url", required: true }, + { type: "text", name: "kind", required: true, max: 20 }, + // One line: what this unlocks, in the viewer's terms. + { type: "text", name: "tagline", max: 300 }, + { type: "json", name: "pros", maxSize: 4000 }, + { type: "json", name: "cons", maxSize: 4000 }, + { type: "text", name: "price", max: 120 }, + { type: "text", name: "priceNote", max: 300 }, + { type: "date", name: "priceCheckedAt" }, + // Ordered strings, rendered as a numbered list. Plain text on purpose: + // this is read by someone with a signup form open in the next tab, and + // markup would be one more thing to get wrong in an admin textarea. + { type: "json", name: "steps", maxSize: 20000 }, + { type: "number", name: "sort" }, + // A row is hidden rather than deleted when a service stops being worth + // recommending, so its guide survives for anyone who still has it. + { type: "bool", name: "enabled" }, + { type: "bool", name: "recommended" }, + { type: "autodate", name: "created", onCreate: true }, + { type: "autodate", name: "updated", onCreate: true, onUpdate: true }, + ], + indexes: [ + "CREATE UNIQUE INDEX idx_providers_slug ON providers (slug)", + ], + }) + app.save(c) + }, + (app) => { + app.delete(app.findCollectionByNameOrId("providers")) + }, +) diff --git a/pb_migrations/1790500001_users_onboarding.js b/pb_migrations/1790500001_users_onboarding.js new file mode 100644 index 0000000..ecc6a54 --- /dev/null +++ b/pb_migrations/1790500001_users_onboarding.js @@ -0,0 +1,55 @@ +/// + +// Where a user got to in the setup flow — **only the parts that cannot be +// derived.** +// +// Everything else about their progress is already knowable from data that +// exists: whether they have sources at all is a lookup in `addon_config`, which +// the page loads anyway. Storing "sourcesDone" beside it would create a second +// answer to a question that already has one, and the two would eventually +// disagree — at which point the flow either nags someone who is finished or +// congratulates someone who is not. +// +// So this holds three things and no more: +// +// { "skipped": true, // chose "nastavím později" +// "chosen": ["prehrajto", "torbox"], // which services they picked +// "pwChangedAt": "2026-08-12T09:00:00Z" } // they replaced the owner's password +// +// `pwChangedAt` is written by the web page when the change succeeds. It cannot be +// derived: PocketBase records no password-changed timestamp, and `updated` moves +// for any edit at all. Absent therefore means "as far as we know, still on the +// password Richard generated" — which is the honest default for a reminder, and +// the cost of being wrong is one dismissible nudge. +// +// **Per account, not localStorage.** Setup spans devices by nature — the flow is +// read on a phone or laptop while the app is installed on a television — so +// progress kept in one browser would ask someone who finished on their laptop to +// start again on their phone. +// +// Json rather than three columns: it is one opaque blob the page reads and writes +// whole, nothing filters or sorts on it, and adding a fourth thing later should +// not need a migration. `users` update rules are unchanged — a user may already +// PATCH their own record (that is how the rating default is set), and this rides +// on that. +// +// Existing accounts are untouched and read as `{}`: no `skipped`, so the family +// would be offered the flow — except they all have `addon_config` rows already, +// which is what actually suppresses it. Nobody who is set up sees anything. + +migrate( + (app) => { + const users = app.findCollectionByNameOrId("users") + users.fields.add(new Field({ + type: "json", + name: "onboarding", + maxSize: 4000, + })) + app.save(users) + }, + (app) => { + const users = app.findCollectionByNameOrId("users") + users.fields.removeByName("onboarding") + app.save(users) + }, +) diff --git a/pb_public/index.html b/pb_public/index.html index 64381cb..3f73e85 100644 --- a/pb_public/index.html +++ b/pb_public/index.html @@ -65,6 +65,42 @@ .warn { border-left:3px solid var(--amber); padding-left:12px; margin:14px 0; font-size:13px; color:var(--muted); line-height:1.5; } ol.steps { padding-left:20px; margin:0; font-size:14px; line-height:1.7; } + /* setup flow */ + .step { border:1px solid var(--line); border-radius:12px; padding:14px; + margin-bottom:10px; background:var(--sunk); } + .step.done { opacity:.6; } + /* A finished step is dimmed, but a button inside one is still live. Dimming it + with the rest made "Změnit výběr" read as disabled, which is the one thing + this app has repeatedly got wrong. */ + .step.done .btn, .step.done button.link { opacity:1; } + .step.now { border-color:var(--amber); } + .step h3 { font-size:14.5px; margin:0; display:flex; gap:9px; + align-items:center; font-weight:700; } + .step .num { display:inline-flex; align-items:center; justify-content:center; + width:22px; height:22px; flex:0 0 22px; border-radius:50%; font-size:12px; + background:var(--line); color:var(--muted); } + .step.done .num { background:var(--ok); color:#08210f; } + .step.now .num { background:var(--amber); color:#1b1206; } + .step .body { margin-top:10px; } + .step .body p { margin:0 0 8px; font-size:13.5px; color:var(--muted); + line-height:1.55; } + .step .btn { margin-top:12px; } + .prov { border:1px solid var(--line); border-radius:10px; padding:12px; + margin-bottom:8px; background:var(--card); } + .prov.on { border-color:var(--amber); } + .prov .top { display:flex; justify-content:space-between; gap:10px; + align-items:baseline; } + .prov .price { font-weight:700; white-space:nowrap; } + .prov ul { margin:8px 0 0; padding-left:17px; font-size:12.5px; + color:var(--muted); line-height:1.55; } + .prov ul.cons { margin-top:10px; } + .prov ul.cons li::marker { color:var(--err); } + .badge { display:inline-block; font-size:11px; font-weight:700; padding:2px 7px; + border-radius:999px; background:var(--amber); color:#1b1206; margin-left:6px; + vertical-align:1px; } + .badge.soft { background:var(--line); color:var(--fg); } + .guide { margin:0; padding-left:19px; font-size:13px; line-height:1.6; } + .guide li { margin-bottom:5px; } @@ -109,8 +145,26 @@ + + + + + +