diff --git a/PROGRESS.md b/PROGRESS.md index 492fc75..343bf37 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -21,13 +21,13 @@ - Transactional emails (order confirmation, shipping notification) - Demo content polished and ready for production -**Tier 1 MVP complete.** CI pipeline done. Hosting & deployment done (including observability). PageSpeed CI done (99-100 mobile, 97+ desktop). Usability fixes done. Shipping costs at checkout done. Per-colour product images with gallery filtering done (both providers). Printful integration complete (sync, orders, shipping, webhooks, mockup enrichment, catalog colours). CSS migration Phases 0-7 complete — project is fully Tailwind-free (hand-written CSS, 9.8 KB gzipped shop, 17.8 KB gzipped admin). +**Tier 1 MVP complete.** CI pipeline done. Hosting & deployment done (including observability). PageSpeed CI done (99-100 mobile, 97+ desktop). Usability fixes done. Shipping costs at checkout done. Per-colour product images with gallery filtering done (both providers). Printful integration complete (sync, orders, shipping, webhooks, mockup enrichment, catalog colours). CSS migration Phases 0-7 complete — project is fully Tailwind-free (hand-written CSS, 9.8 KB gzipped shop, 17.8 KB gzipped admin). Setup and launch readiness complete — `/setup` onboarding page, dashboard launch checklist, provider registry, provider-agnostic setup status. ## Task list Ordered by dependency level — admin shell chain first (unblocks most downstream work). -Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [setup-wizard.md](docs/plans/setup-wizard.md) | [search.md](docs/plans/search.md) | [products-refactor.md](/home/jamey/.claude/plans/snug-roaming-zebra.md) | [shipping-sync.md](docs/plans/shipping-sync.md) | [printful-integration.md](docs/plans/printful-integration.md) | [provider-strategy.md](docs/plans/provider-strategy.md) | [css-migration.md](docs/plans/css-migration.md) +Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [setup-wizard.md](docs/plans/setup-wizard.md) | [setup-and-launch.md](docs/plans/setup-and-launch.md) | [search.md](docs/plans/search.md) | [products-refactor.md](/home/jamey/.claude/plans/snug-roaming-zebra.md) | [shipping-sync.md](docs/plans/shipping-sync.md) | [printful-integration.md](docs/plans/printful-integration.md) | [provider-strategy.md](docs/plans/provider-strategy.md) | [css-migration.md](docs/plans/css-migration.md) | # | Task | Depends on | Est | Status | |---|------|------------|-----|--------| @@ -58,8 +58,17 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [setup-wizard.md](doc | ~~28~~ | ~~Printful mockup generation worker~~ | 25 | — | done (existing pipeline) | | ~~29~~ | ~~Printful webhooks~~ | 25 | 1.5h | done | | | **Next up** | | | | -| 30 | Admin UI tweaks for Printful | 25 | 1h | | +| ~~30~~ | ~~Admin UI tweaks for Printful~~ | 25 | 30m | done | | 31 | Printful tests + integration testing | 24-30 | 4.5h | | +| | **Setup and launch readiness** ([plan](docs/plans/setup-and-launch.md)) | | | | +| ~~41~~ | ~~Provider + payment registries~~ | — | 30m | done | +| ~~42~~ | ~~Make Setup provider-agnostic + add checklist fields~~ | 41 | 45m | done | +| ~~43~~ | ~~Setup LiveView (`/setup`) — account, provider, payments~~ | 41, 42 | 2.5h | done | +| ~~44~~ | ~~Dashboard launch checklist component + go-live~~ | 42 | 2h | done | +| ~~45~~ | ~~Router, auth flow, redirects~~ | 43, 44 | 30m | done | +| ~~46~~ | ~~CSS additions (~200 lines)~~ | 43, 44 | 20m | done | +| ~~47~~ | ~~Tests (setup, dashboard checklist, auth flow)~~ | 43-46 | 2h | done | +| ~~48~~ | ~~Remove old `/admin/setup`~~ | 43-47 | 15m | done | | | **CSS migration — Tailwind + DaisyUI to modern CSS** | | | | | ~~32~~ | ~~Phase 0: Foundation + screenshot tooling~~ | 30-31 | 1.5h | done | | ~~33~~ | ~~Phase 1: Layout primitives + reset~~ | 32 | 1.5h | done | @@ -71,7 +80,7 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [setup-wizard.md](doc | ~~39~~ | ~~Phase 7: Remove Tailwind entirely~~ | 38 | 1.5h | done | | 40 | Phase 8: Optimisation + modern enhancements | 39 | 2.5h | | -**Total remaining: ~8-9 hours across ~5 sessions** (admin Printful tweaks, Printful tests, CSS optimisation) +**Total remaining: ~7 hours across ~3-4 sessions** (Printful tests, CSS optimisation) See [css-migration.md](docs/plans/css-migration.md) for full plan with architecture, visual regression testing strategy, and acceptance criteria per phase. @@ -110,7 +119,7 @@ Issues from hands-on testing of the deployed prod site (Feb 2025). 16 of 18 comp ### Tier 5 — Platform vision -16. **Hosted platform** — Marketing/brochure site for Berrypod as a service. Subscribe/sign-up flow. Multi-tenancy with per-tenant databases. Stripe Connect for customer shops (each merchant connects their own Stripe account via OAuth). +16. **Hosted platform** — Marketing/brochure site for Berrypod as a service. Subscribe/sign-up flow. Multi-tenancy with per-tenant databases. **OAuth connect flows** for providers and payments: register as a Printify/Printful OAuth app so hosted users get a one-click "Connect with Printify" button on the setup page; use Stripe Connect so merchants authorise via OAuth redirect instead of pasting API keys. The setup UI already supports this — check for OAuth credentials and show the connect button when available, fall back to the API key form on self-hosted installs where no OAuth app is configured. Provider metadata (`connect_mode: :oauth | :api_key`) drives which form renders. 17. **Migration & export** — Let shop owners export their data (products, orders, customers, theme settings). Import from other platforms (Shopify, WooCommerce). Portable data as a selling point for the self-hosted story. 18. **Internationalisation (i18n)** — Multi-language support via Gettext (already in Phoenix). Currency formatting. RTL layout support. Per-shop locale configuration. **Note:** `ex_money`/`ex_cldr` are currently used *only* for `Cart.format_price/1` (a single GBP formatting call) but add ~13 MB to the release (ex_cldr 9.5 MB, digital_token 3.7 MB, ex_cldr_numbers, ex_cldr_currencies). Consider replacing with a simple `format_price/2` function that handles GBP/EUR/USD directly — all three use 2 decimal places and are trivial to format. Re-add `ex_money` later if proper locale-aware number formatting is needed (e.g., German `12.345,67 €`). diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index a758c20..cab6b9e 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -639,3 +639,330 @@ [data-theme="light"] .theme-toggle-indicator { left: 33.333333%; } [data-theme="dark"] .theme-toggle-indicator { left: 66.666667%; } + +/* ── Dashboard stats grid ── */ + +.admin-stats-grid { + display: grid; + grid-template-columns: repeat(1, 1fr); + gap: 1rem; + margin-top: 1.5rem; +} + +@media (min-width: 640px) { + .admin-stats-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +/* ── Setup page ── */ + +.setup-page { + max-width: 36rem; + margin: 0 auto; + padding: 2rem 1rem; +} + +.setup-header { + text-align: center; + margin-bottom: 2rem; +} + +.setup-title { + font-size: 1.5rem; + font-weight: 700; + line-height: 1.2; +} + +.setup-subtitle { + color: var(--color-base-content-60, rgba(0 0 0 / 0.6)); + margin-top: 0.25rem; + font-size: 0.875rem; +} + +.setup-sections { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.setup-card { + border: 1px solid var(--color-base-300, #d4d4d4); + border-radius: 0.5rem; + padding: 1.25rem; + transition: border-color 150ms; +} + +.setup-card-done { + border-color: var(--color-success, #22c55e); + background: color-mix(in oklab, var(--color-success, #22c55e) 5%, transparent); +} + +.setup-card-header { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.setup-card-number { + display: flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border-radius: 50%; + font-size: 0.75rem; + font-weight: 600; + flex-shrink: 0; + background: var(--color-base-200, #e5e5e5); + color: var(--color-base-content-60, rgba(0 0 0 / 0.6)); +} + +.setup-card-number-done { + background: var(--color-success, #22c55e); + color: white; +} + +.setup-card-title { + font-size: 0.9375rem; + font-weight: 600; +} + +.setup-card-summary { + margin-top: 0.25rem; + padding-left: 2.5rem; + font-size: 0.8125rem; + color: var(--color-base-content-60, rgba(0 0 0 / 0.6)); +} + +.setup-card-body { + margin-top: 1rem; + padding-left: 2.5rem; +} + +.setup-hint { + font-size: 0.8125rem; + color: var(--color-base-content-60, rgba(0 0 0 / 0.6)); + margin-bottom: 0.75rem; +} + +.setup-link { + color: var(--color-base-content, #171717); + text-decoration: underline; +} + +.setup-key-hint { + font-size: 0.75rem; + color: var(--color-base-content-60, rgba(0 0 0 / 0.6)); + margin-top: 0.25rem; +} + +.setup-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.setup-test-result { + margin-top: 0.5rem; + font-size: 0.8125rem; +} + +.setup-test-ok { + color: var(--color-success, #22c55e); + display: flex; + align-items: center; + gap: 0.25rem; +} + +.setup-test-error { + color: var(--color-error, #dc2626); + display: flex; + align-items: center; + gap: 0.25rem; +} + +/* Provider picker grid */ +.setup-provider-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.5rem; + margin-bottom: 1rem; +} + +.setup-provider-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.125rem; + padding: 0.75rem; + border: 1px solid var(--color-base-300, #d4d4d4); + border-radius: 0.375rem; + text-align: left; + cursor: pointer; + transition: border-color 150ms, background 150ms; + + &:hover:not(:disabled) { + border-color: var(--color-base-content, #171717); + } +} + +.setup-provider-card-selected { + border-color: var(--color-base-content, #171717); + background: var(--color-base-200, #e5e5e5); +} + +.setup-provider-card-disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.setup-provider-name { + font-size: 0.875rem; + font-weight: 600; +} + +.setup-provider-tagline { + font-size: 0.75rem; + color: var(--color-base-content-60, rgba(0 0 0 / 0.6)); +} + +.setup-provider-badge { + font-size: 0.6875rem; + padding: 0.125rem 0.375rem; + border-radius: 9999px; + background: var(--color-base-200, #e5e5e5); + color: var(--color-base-content-60, rgba(0 0 0 / 0.6)); +} + +.setup-provider-form { + margin-top: 0.75rem; +} + +/* Setup complete card */ +.setup-complete { + text-align: center; + margin-top: 2rem; + padding: 2rem; + border: 1px solid var(--color-success, #22c55e); + border-radius: 0.5rem; + background: color-mix(in oklab, var(--color-success, #22c55e) 5%, transparent); +} + +.setup-complete-icon { + width: 3rem; + height: 3rem; + color: var(--color-success, #22c55e); + margin: 0 auto 0.75rem; +} + +.setup-complete h2 { + font-size: 1.125rem; + font-weight: 600; +} + +.setup-complete p { + font-size: 0.875rem; + color: var(--color-base-content-60, rgba(0 0 0 / 0.6)); + margin: 0.25rem 0 1rem; +} + +/* ── Dashboard launch checklist ── */ + +.admin-checklist { + border: 1px solid var(--color-base-300, #d4d4d4); + border-radius: 0.5rem; + padding: 1.25rem; + margin-bottom: 1.5rem; +} + +.admin-checklist-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} + +.admin-checklist-title { + font-size: 1rem; + font-weight: 600; +} + +.admin-checklist-progress { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; + color: var(--color-base-content-60, rgba(0 0 0 / 0.6)); +} + +.admin-checklist-bar { + width: 6rem; + height: 0.375rem; + border-radius: 9999px; + background: var(--color-base-200, #e5e5e5); + overflow: hidden; +} + +.admin-checklist-bar-fill { + height: 100%; + border-radius: 9999px; + background: var(--color-success, #22c55e); + transition: width 300ms; +} + +.admin-checklist-items { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.admin-checklist-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0; +} + +.admin-checklist-check { + display: flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + border-radius: 50%; + flex-shrink: 0; + border: 1.5px solid var(--color-base-300, #d4d4d4); + color: transparent; +} + +.admin-checklist-check-done { + background: var(--color-success, #22c55e); + border-color: var(--color-success, #22c55e); + color: white; +} + +.admin-checklist-label { + flex: 1; + font-size: 0.875rem; +} + +.admin-checklist-label-done { + color: var(--color-base-content-60, rgba(0 0 0 / 0.6)); +} + +.admin-checklist-action { + flex-shrink: 0; +} + +.admin-checklist-footer { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 1rem; + padding-top: 0.75rem; + border-top: 1px solid var(--color-base-200, #e5e5e5); +} diff --git a/docs/plans/setup-and-launch.md b/docs/plans/setup-and-launch.md new file mode 100644 index 0000000..14f752f --- /dev/null +++ b/docs/plans/setup-and-launch.md @@ -0,0 +1,353 @@ +# Unified setup and launch readiness + +## Context + +The current first-run experience is disjointed: `/users/register` is a standalone page that closes after one user, the login page still links to it, and `/admin/setup` is a 3-step wizard hardcoded to Printify that bundles "go live" into the initial setup. Research into Shopify, WooCommerce and Squarespace shows that every successful platform separates initial setup (get the plumbing working) from launch readiness (guide the owner to a shop worth opening). + +## Two-phase design + +### Phase A: Initial setup (`/setup`) + +Quick, focused, one-time. Gets the system functional. 5-10 minutes. + +- Create admin account (email + magic link) +- Connect a print provider (provider-agnostic: Printify, Printful, etc.) +- Connect payments (Stripe) + +No theme customisation, no "go live", no product sync waiting. Once all three connections are made, redirect to `/admin`. The `/setup` page is never shown again. + +### Phase B: Launch checklist (persistent on `/admin` dashboard) + +Ongoing guidance that lives on the admin dashboard until all items are complete or dismissed. Inspired by Shopify's setup guide and WooCommerce's task list. Items auto-complete based on real state. Uses the Zeigarnik effect — seeing a partially-complete checklist motivates finishing it. + +Checklist items: + +| # | Item | Auto-completes when | Links to | +|---|------|-------------------|----------| +| 1 | Sync your products | `product_count > 0` | `/admin/providers` | +| 2 | Customise your theme | Theme preset changed from default | `/admin/theme` | +| 3 | Review your pages | At least one content page edited (future: page editor) | `/admin/settings` | +| 4 | Place a test order | At least one order exists | `/` (the shop) | +| 5 | Go live | `site_live? == true` | Button right on the checklist | + +"Go live" is the final step, only enabled when at least items 1 (products synced) and the provider + Stripe connections from setup are still valid. Items 2-4 are recommended but not blocking — the admin can dismiss them or skip straight to go live. + +When all items are complete (or dismissed), the checklist is replaced by the normal dashboard stats (orders, revenue, products) — same as what WooCommerce does. + +### Coming-soon page (branded holding page) + +Until "go live" is flipped, unauthenticated visitors see a themed page using the current theme settings (logo, colours, fonts, site name). Not a sad blank wall — a preview of the brand. + +Content: +- Site name / logo +- "We're getting things ready. Check back soon." +- Optional: email signup for launch notification (future enhancement, not MVP) + +The admin bypasses this and sees the full shop (existing behaviour via ThemeHook). + +## Architecture + +### `/setup` page + +**One LiveView, one page, one URL: `/setup`.** + +Not a stepper wizard — a single scrollable page with independent sections as cards. Each section can be completed in any order. Completed sections collapse to show a checkmark and summary. + +**Access rules:** +- No admin exists → page is public, shows all sections including account creation +- Admin exists + logged in + setup incomplete → page is accessible, account section hidden +- Admin exists + not logged in → redirect to `/users/log-in` +- Setup complete (all three connections made) → redirect to `/admin` +- Site is live → redirect to `/` + +"Setup complete" means: admin exists + provider connected + Stripe connected. Products don't need to be synced yet — that's a launch checklist item, not a setup gate. + +**Why no auth gate for provider/payment setup:** Single-tenant app. During initial setup there's no data to protect. The page is only reachable before setup is complete. + +**Sections:** + +| Section | Shown when | Content | +|---------|-----------|---------| +| Create admin account | No admin exists | Email field, magic link flow | +| Connect a print provider | No provider connected | Provider cards (Printify, Printful, coming soon), API key form | +| Connect payments | No payment provider | Stripe API key form | + +Each completed section collapses to a summary line with a checkmark (e.g. "Connected to Printify"). + +**No theme/style section here.** Theme customisation is a launch readiness concern, not a setup concern. Keep setup lean. + +### Dashboard launch checklist + +Lives on the existing `/admin` dashboard page (`lib/berrypod_web/live/admin/dashboard.ex`). Shown as a card above the stats when `site_live?` is false or when checklist items remain incomplete. + +**Component:** A function component `<.launch_checklist>` that takes `@setup` assigns and renders the checklist card. Each item is a row with: checkbox/checkmark, label, description, link/button. + +**Progress indicator:** "3 of 5 complete" with a simple progress bar. Shopify research shows progress bars motivate completion. + +**Dismissal:** The checklist can be dismissed permanently via a "Dismiss" link. Stores `checklist_dismissed` in Settings. Once dismissed or all items complete, shows the normal stats-only dashboard. + +**"Go live" button:** Inline in the checklist as the final item. Enabled when products are synced + connections valid. Clicking it sets `site_live` and shows a brief celebration state before transitioning to the normal dashboard. + +### Setup status (`Setup.setup_status/0`) + +Make provider-agnostic and add launch checklist fields: + +```elixir +%{ + # Setup phase (connections) + admin_created: boolean, + provider_connected: boolean, + provider_type: string | nil, + stripe_connected: boolean, + setup_complete: boolean, # admin + provider + stripe all connected + + # Launch checklist phase + products_synced: boolean, + product_count: integer, + theme_customised: boolean, + has_orders: boolean, + site_live: boolean, + + # Derived + can_go_live: boolean, # provider_connected and products_synced and stripe_connected + checklist_dismissed: boolean +} +``` + +## Files to create + +### `lib/berrypod/providers/registry.ex` + +Provider metadata. Pure data, no DB. + +```elixir +@providers [ + %{ + type: "printify", + name: "Printify", + tagline: "Largest catalog — 800+ products from 90+ print providers", + features: ["Biggest product selection", "Multi-provider routing", "Competitive pricing"], + setup_hint: "Get your API token from Printify → Account → Connections", + setup_url: "https://printify.com/app/account/connections", + status: :available + }, + %{ + type: "printful", + name: "Printful", + tagline: "Premium quality with in-house fulfilment", + features: ["In-house production", "Warehousing & branding", "Mockup generator"], + setup_hint: "Get your API token from Printful → Settings → API", + setup_url: "https://www.printful.com/dashboard/developer/api", + status: :available + }, + %{type: "gelato", name: "Gelato", tagline: "Local production in 30+ countries", status: :coming_soon}, + %{type: "prodigi", name: "Prodigi", tagline: "Fine art and photo products", status: :coming_soon} +] +``` + +Functions: `all/0`, `available/0`, `get/1` + +### `lib/berrypod/payments/registry.ex` + +Same pattern for payment providers (just Stripe for now). + +### `lib/berrypod_web/live/setup/onboarding.ex` + +Single LiveView at `/setup`. Three sections: account, provider, payments. + +**Mount:** + +```elixir +def mount(_params, _session, socket) do + setup = Setup.setup_status() + + cond do + setup.site_live -> + {:ok, push_navigate(socket, to: ~p"/")} + + setup.setup_complete -> + {:ok, push_navigate(socket, to: ~p"/admin")} + + setup.admin_created and is_nil(get_user(socket)) -> + {:ok, push_navigate(socket, to: ~p"/users/log-in")} + + true -> + {:ok, mount_setup(socket, setup)} + end +end +``` + +**Account section events:** +- `handle_event("register", %{"email" => email}, socket)` — calls `Accounts.register_user/1` + `deliver_login_instructions/2`. Shows "Check your email" note. + +**Provider section events:** +- `handle_event("select_provider", %{"type" => type}, socket)` — expands that provider's API key form. +- `handle_event("test_connection", %{"provider" => params}, socket)` — tests API key validity. +- `handle_event("connect_provider", %{"provider" => params}, socket)` — creates connection, enqueues sync in background. Does NOT wait for sync to finish — moves on. + +**Payments section events:** +- `handle_event("connect_stripe", %{"stripe" => params}, socket)` — reuses existing `StripeSetup.connect/1`. + +**Completion:** When all three sections are done (`setup_complete`), show a brief "You're all set" message with a button to go to the dashboard. Or auto-redirect after a short delay. + +## Files to modify + +### `lib/berrypod/setup.ex` + +- Replace `Products.get_provider_connection_by_type("printify")` with `Products.get_first_provider_connection/0` +- Rename `printify_connected` → `provider_connected` +- Add `provider_type` field (e.g. "printify", "printful") +- Add `setup_complete` field: `admin_created and provider_connected and stripe_connected` +- Add `theme_customised` field: true if theme preset != default +- Add `has_orders` field: `Orders.count_paid_orders() > 0` +- Add `checklist_dismissed` field from Settings +- Update `can_go_live`: `provider_connected and products_synced and stripe_connected` + +### `lib/berrypod/products.ex` + +Add `get_first_provider_connection/0` — first connection with non-nil `api_key_encrypted`, ordered by `inserted_at`. + +### `lib/berrypod_web/live/admin/dashboard.ex` + +- Load `Setup.setup_status()` on mount +- When `!setup.site_live and !setup.checklist_dismissed`, render `<.launch_checklist>` above the stats +- When `setup.site_live or setup.checklist_dismissed`, render the normal stats dashboard +- Handle "go_live" event: `Settings.set_site_live(true)`, show celebration, then reload as normal dashboard +- Handle "dismiss_checklist" event: `Settings.put_setting("shop", "checklist_dismissed", "true")` + +### `lib/berrypod_web/router.ex` + +Add `/setup` to a minimal live_session (no ThemeHook, no CartHook): +```elixir +live "/setup", Setup.Onboarding, :index +``` + +Remove `/admin/setup` from admin live_session (or redirect to `/setup` if not yet complete, `/admin` if complete). + +### `lib/berrypod_web/user_auth.ex` + +Change `signed_in_path/1`: +- If setup not complete → `~p"/setup"` +- If setup complete but not live → `~p"/admin"` +- If live → `~p"/admin"` + +### `lib/berrypod_web/live/auth/registration.ex` + +Redirect to `/setup` (no admin) or `/users/log-in` (admin exists). + +### `lib/berrypod_web/live/auth/login.ex` + +- Add `@registration_open` assign (`!Accounts.has_admin?()`) +- Hide "Sign up" link when admin exists + +### `lib/berrypod_web/theme_hook.ex` + +Change fresh-install redirect from `/users/register` to `/setup`. + +### `lib/berrypod_web/components/layouts/admin.html.heex` + +- Remove "Setup" sidebar link (setup is now `/setup`, not an admin page) +- The dashboard handles launch readiness now + +### `lib/berrypod_web/live/shop/coming_soon.ex` + +Keep as-is but potentially enhance later with email signup. Current implementation already uses theme settings for site name. + +### `assets/css/admin/components.css` + +Add under `/* ── Setup ── */`: + +```css +/* /setup page */ +.admin-setup-done { + /* collapsed summary: checkmark + text */ +} + +/* Dashboard launch checklist */ +.admin-checklist { } +.admin-checklist-item { } +.admin-checklist-item[data-complete] { } +.admin-checklist-progress { } +``` + +~30-40 lines total. Everything else uses existing admin component classes. + +## Auth flow + +``` +Fresh install (no admin): + Visit anything → ThemeHook → redirect /setup + /setup shows ALL sections (account, provider, payments) + User creates account via magic link + User clicks magic link → confirmed → signed_in_path = /setup + /setup: account section done, provider + payments still needed + Complete connections → redirect /admin + Dashboard shows launch checklist + +Returning admin (setup complete, not yet live): + Visit /admin → dashboard with launch checklist + Checklist: sync products, customise theme, test order, go live + Items auto-complete as admin works through them + Admin clicks "Go live" → site opens to public + +Visitor (not yet live): + Visit anything → ThemeHook → redirect /coming-soon + Sees branded holding page with site name + +Already live: + /setup → redirect /admin + Dashboard shows stats (no checklist) + Visitors see the real shop +``` + +## CSS approach + +Reuse existing admin components. Small additions needed: + +| Need | Reuse | New? | +|------|-------|------| +| Section cards | `.admin-card` + `.admin-card-body` + `.admin-card-title` | No | +| Badges | `.admin-badge` | No | +| Buttons | `.admin-btn-*` | No | +| Forms | `.admin-input`, `.admin-label` | No | +| Alerts/notes | `.admin-alert-info` | No | +| Spinner | `.admin-spinner` | No | +| Section complete state | — | **Yes** — collapsed state with checkmark | +| Checklist items | — | **Yes** — row with checkbox, label, link | +| Progress bar | — | **Yes** — simple bar for "3 of 5 complete" | + +All new CSS in `assets/css/admin/components.css`, using `.admin-setup-*` and `.admin-checklist-*` prefixes. + +## Task breakdown + +| # | Task | Files | Est | +|---|------|-------|-----| +| 1 | Provider + payment registries | `providers/registry.ex`, `payments/registry.ex` | 30m | +| 2 | Make Setup provider-agnostic + add checklist fields | `setup.ex`, `products.ex` | 45m | +| 3 | Setup LiveView (`/setup`) — account, provider, payments | `setup/onboarding.ex` | 2.5h | +| 4 | Dashboard launch checklist component + go-live | `dashboard.ex` | 2h | +| 5 | Router, auth flow, redirects | `router.ex`, `user_auth.ex`, `registration.ex`, `login.ex`, `theme_hook.ex`, `admin.html.heex` | 30m | +| 6 | CSS additions (~40 lines) | `admin/components.css` | 20m | +| 7 | Tests | setup, dashboard checklist, auth flow | 2h | +| 8 | Remove old `/admin/setup` | delete or redirect | 15m | + +**Total: ~9 hours across 4-5 sessions** + +## Verification + +1. **Fresh install:** Delete local DB, run migrations, visit `localhost:4000` → redirects to `/setup`. Complete account + provider + payments → lands on `/admin` with launch checklist. +2. **Launch checklist:** Sync products → item auto-completes. Change theme → item auto-completes. Progress bar updates. +3. **Go live:** Click "Go live" on checklist → celebration → visitors see real shop. +4. **Coming soon:** Before go-live, unauthenticated visitors see branded holding page. +5. **Dismiss:** Admin can dismiss the checklist and use the normal dashboard without going live. +6. **Provider agnostic:** Connect Printful instead of Printify — works the same. +7. **Dead ends:** `/users/register` redirects to `/setup`. Login page hides "Sign up" when admin exists. +8. **`mix precommit`** passes. + +## Future enhancements (not in scope) + +- **Email signup on coming-soon page** — collect emails for launch notification +- **Test order detection** — auto-complete "Place a test order" when an order with a test Stripe key exists +- **Content page editing** — "Review your pages" becomes more meaningful with a page editor (Tier 4) +- **Preset picker on setup page** — considered and cut. Theme customisation belongs in the launch checklist phase, not initial setup. The theme editor is the right tool for this. +- **Onboarding tooltips** — contextual hints on first visit to admin pages (e.g. "This is where you manage your products") diff --git a/lib/berrypod/orders.ex b/lib/berrypod/orders.ex index 0ef1acc..22f56c0 100644 --- a/lib/berrypod/orders.ex +++ b/lib/berrypod/orders.ex @@ -50,6 +50,16 @@ defmodule Berrypod.Orders do |> Map.new() end + @doc """ + Returns true if at least one paid order exists. + """ + def has_paid_orders? do + Order + |> where(payment_status: "paid") + |> limit(1) + |> Repo.exists?() + end + @doc """ Returns total revenue (in minor units) from paid orders. """ diff --git a/lib/berrypod/payments/registry.ex b/lib/berrypod/payments/registry.ex new file mode 100644 index 0000000..d9e3d9d --- /dev/null +++ b/lib/berrypod/payments/registry.ex @@ -0,0 +1,28 @@ +defmodule Berrypod.Payments.Registry do + @moduledoc """ + Payment provider metadata registry. + + Lightweight — just Stripe for now. No behaviour abstraction until + a second payment provider justifies one. + """ + + @providers [ + %{ + type: "stripe", + name: "Stripe", + tagline: "Accept cards, Apple Pay, Google Pay and more", + setup_hint: "Find your secret key under Developers → API keys", + setup_url: "https://dashboard.stripe.com/apikeys", + status: :available + } + ] + + @doc "Returns all payment providers." + def all, do: @providers + + @doc "Returns only providers with `:available` status." + def available, do: Enum.filter(@providers, &(&1.status == :available)) + + @doc "Returns a payment provider by type string, or nil." + def get(type), do: Enum.find(@providers, &(&1.type == type)) +end diff --git a/lib/berrypod/products.ex b/lib/berrypod/products.ex index 186023a..dccc741 100644 --- a/lib/berrypod/products.ex +++ b/lib/berrypod/products.ex @@ -42,6 +42,19 @@ defmodule Berrypod.Products do Repo.get_by(ProviderConnection, provider_type: provider_type) end + @doc """ + Returns the first provider connection with an API key, or nil. + + Provider-agnostic — doesn't care which type. Ordered by creation date. + """ + def get_first_provider_connection do + ProviderConnection + |> where([c], not is_nil(c.api_key_encrypted)) + |> order_by(:inserted_at) + |> limit(1) + |> Repo.one() + end + @doc """ Creates a provider connection. """ diff --git a/lib/berrypod/providers/provider.ex b/lib/berrypod/providers/provider.ex index d011b4b..540f070 100644 --- a/lib/berrypod/providers/provider.ex +++ b/lib/berrypod/providers/provider.ex @@ -1,8 +1,8 @@ defmodule Berrypod.Providers.Provider do @moduledoc """ - Behaviour for POD provider integrations. + Behaviour and registry for POD provider integrations. - Each provider (Printify, Gelato, Prodigi, etc.) implements this behaviour + Each provider (Printify, Printful, etc.) implements this behaviour to provide a consistent interface for: - Testing connections @@ -10,6 +10,10 @@ defmodule Berrypod.Providers.Provider do - Submitting orders - Tracking order status + The `@providers` list is the single source of truth for available providers. + Both the module dispatch (`for_type/1`) and UI metadata (`all/0`, `get/1`) + are derived from it. + ## Data Normalization Providers return normalized data structures: @@ -23,47 +27,82 @@ defmodule Berrypod.Providers.Provider do alias Berrypod.Products.ProviderConnection - @doc """ - Returns the provider type identifier (e.g., "printify", "gelato"). - """ + # Single source of truth for all providers — module dispatch and UI metadata. + # Add new providers here. Set module: nil for coming-soon entries. + @providers [ + %{ + type: "printify", + module: Berrypod.Providers.Printify, + name: "Printify", + tagline: "Largest catalog — 800+ products from 90+ print providers", + features: ["Biggest product selection", "Multi-provider routing", "Competitive pricing"], + setup_hint: "Get your API token from Printify → Account → Connections", + setup_url: "https://printify.com/app/account/connections", + login_url: "https://printify.com/app/auth/login", + signup_url: "https://printify.com/app/auth/register", + setup_steps: [ + ~s[Click Account (top right)], + ~s[Select Connections from the dropdown], + ~s[Find API tokens and click Generate], + ~s[Enter a name (e.g. "My Shop"), keep all scopes selected, and click Generate token], + ~s[Click Copy to clipboard and paste it below] + ], + status: :available + }, + %{ + type: "printful", + module: Berrypod.Providers.Printful, + name: "Printful", + tagline: "Premium quality with in-house fulfilment", + features: ["In-house production", "Warehousing & branding", "Mockup generator"], + setup_hint: "Get your API token from Printful → Settings → API", + setup_url: "https://www.printful.com/dashboard/developer/api", + login_url: "https://www.printful.com/auth/login", + signup_url: "https://www.printful.com/auth/signup", + setup_steps: [ + ~s[Go to SettingsAPI access], + ~s[Click Create API key], + ~s[Give it a name and select all scopes], + ~s[Copy the token and paste it below] + ], + status: :available + }, + %{ + type: "gelato", + module: nil, + name: "Gelato", + tagline: "Local production in 30+ countries", + status: :coming_soon + }, + %{ + type: "prodigi", + module: nil, + name: "Prodigi", + tagline: "Fine art and photo products", + status: :coming_soon + } + ] + + # Build a compile-time lookup map for for_type/1 + @type_to_module Map.new(@providers, fn p -> {p.type, p[:module]} end) + + # ── Callbacks ── + @callback provider_type() :: String.t() - @doc """ - Tests the connection to the provider. - - Returns `{:ok, info}` with provider-specific info (e.g., shop name) - or `{:error, reason}` if the connection fails. - """ @callback test_connection(ProviderConnection.t()) :: {:ok, map()} | {:error, term()} - @doc """ - Fetches all products from the provider. - - Returns a list of normalized product maps. - """ @callback fetch_products(ProviderConnection.t()) :: {:ok, [map()]} | {:error, term()} - @doc """ - Submits an order to the provider for fulfillment. - - Returns `{:ok, %{provider_order_id: String.t()}}` on success. - """ @callback submit_order(ProviderConnection.t(), order :: map()) :: {:ok, %{provider_order_id: String.t()}} | {:error, term()} - @doc """ - Gets the current status of an order from the provider. - """ @callback get_order_status(ProviderConnection.t(), provider_order_id :: String.t()) :: {:ok, map()} | {:error, term()} @doc """ Fetches shipping rates from the provider for the given products. - Takes the connection and the already-fetched product list (from fetch_products). - Returns normalized rate maps with keys: blueprint_id, print_provider_id, - country_code, first_item_cost, additional_item_cost, currency, handling_time_days. - Optional — providers that don't support shipping rate lookup can skip this. The sync worker checks `function_exported?/3` before calling. """ @@ -72,11 +111,24 @@ defmodule Berrypod.Providers.Provider do @optional_callbacks [fetch_shipping_rates: 2] + # ── Registry ── + + @doc "Returns all providers (available and coming soon)." + def all, do: @providers + + @doc "Returns only providers with `:available` status." + def available, do: Enum.filter(@providers, &(&1.status == :available)) + + @doc "Returns a provider metadata map by type string, or nil." + def get(type), do: Enum.find(@providers, &(&1.type == type)) + + # ── Module dispatch ── + @doc """ Returns the provider module for a given provider type. Checks `:provider_modules` application config first, allowing test - overrides via Mox. Falls back to hardcoded dispatch. + overrides via Mox. Falls back to the `@providers` list. """ def for_type(type) do case Application.get_env(:berrypod, :provider_modules, %{}) do @@ -91,11 +143,13 @@ defmodule Berrypod.Providers.Provider do end end - defp default_for_type("printify"), do: {:ok, Berrypod.Providers.Printify} - defp default_for_type("gelato"), do: {:error, :not_implemented} - defp default_for_type("prodigi"), do: {:error, :not_implemented} - defp default_for_type("printful"), do: {:ok, Berrypod.Providers.Printful} - defp default_for_type(type), do: {:error, {:unknown_provider, type}} + defp default_for_type(type) do + case Map.fetch(@type_to_module, type) do + {:ok, nil} -> {:error, :not_implemented} + {:ok, module} -> {:ok, module} + :error -> {:error, {:unknown_provider, type}} + end + end @doc """ Returns the provider module for a provider connection. diff --git a/lib/berrypod/settings.ex b/lib/berrypod/settings.ex index f2b6118..23db60e 100644 --- a/lib/berrypod/settings.ex +++ b/lib/berrypod/settings.ex @@ -84,6 +84,7 @@ defmodule Berrypod.Settings do settings = Ecto.Changeset.apply_changes(changeset) json = Jason.encode!(settings) put_setting("theme_settings", json, "json") + put_setting("theme_customised", true, "boolean") # Invalidate and rewarm CSS cache alias Berrypod.Theme.{CSSCache, CSSGenerator} diff --git a/lib/berrypod/setup.ex b/lib/berrypod/setup.ex index 943a361..4b91790 100644 --- a/lib/berrypod/setup.ex +++ b/lib/berrypod/setup.ex @@ -1,33 +1,58 @@ defmodule Berrypod.Setup do @moduledoc """ - Aggregates setup status checks for the admin setup flow. + Aggregates setup status checks for the setup flow and launch checklist. """ - alias Berrypod.{Accounts, Products, Settings} + alias Berrypod.{Accounts, Orders, Products, Settings} @doc """ Returns a map describing the current setup status. - Used by the admin setup checklist and ThemeHook gate to determine - what's been completed and whether the shop can go live. + Used by the setup page, dashboard launch checklist, and ThemeHook gate. + + ## Setup phase (connections) + + * `admin_created` — at least one user exists + * `provider_connected` — a provider connection with an API key exists + * `provider_type` — the connected provider's type (e.g. "printify"), or nil + * `stripe_connected` — Stripe API key is stored + * `setup_complete` — all three connections made + + ## Launch checklist phase + + * `products_synced` / `product_count` — products imported + * `theme_customised` — theme settings have been saved at least once + * `has_orders` — at least one paid order exists + * `site_live` — shop is open to the public + * `can_go_live` — minimum requirements met to go live + * `checklist_dismissed` — admin has dismissed the launch checklist """ def setup_status do - conn = Products.get_provider_connection_by_type("printify") - product_count = Products.count_products_for_connection(conn && conn.id) + conn = Products.get_first_provider_connection() + product_count = Products.count_products() - printify_connected = conn != nil and conn.api_key_encrypted != nil + provider_connected = conn != nil products_synced = product_count > 0 stripe_connected = Settings.has_secret?("stripe_api_key") + admin_created = Accounts.has_admin?() site_live = Settings.site_live?() %{ - admin_created: Accounts.has_admin?(), - printify_connected: printify_connected, + # Setup phase + admin_created: admin_created, + provider_connected: provider_connected, + provider_type: conn && conn.provider_type, + stripe_connected: stripe_connected, + setup_complete: admin_created and provider_connected and stripe_connected, + + # Launch checklist products_synced: products_synced, product_count: product_count, - stripe_connected: stripe_connected, + theme_customised: Settings.get_setting("theme_customised", false) == true, + has_orders: Orders.has_paid_orders?(), site_live: site_live, - can_go_live: printify_connected and products_synced and stripe_connected + can_go_live: provider_connected and products_synced and stripe_connected, + checklist_dismissed: Settings.get_setting("checklist_dismissed", false) == true } end end diff --git a/lib/berrypod_web/components/layouts/admin.html.heex b/lib/berrypod_web/components/layouts/admin.html.heex index 96702db..66a3d7f 100644 --- a/lib/berrypod_web/components/layouts/admin.html.heex +++ b/lib/berrypod_web/components/layouts/admin.html.heex @@ -43,14 +43,6 @@ <%!-- nav links --%>