add setup onboarding page, dashboard launch checklist, provider registry

- new /setup page with three-section onboarding (account, provider, payments)
- dashboard launch checklist with progress bar, go-live, dismiss
- provider registry on Provider module (single source of truth for metadata)
- payments registry for Stripe
- setup context made provider-agnostic (provider_connected, theme_customised, etc.)
- admin provider pages now fully registry-driven (no hardcoded provider names)
- auth flow: fresh installs redirect to /setup, signed_in_path respects setup state
- removed old /admin/setup wizard
- 840 tests, 0 failures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-20 00:34:06 +00:00
parent 989c5cd4df
commit c2caeed64d
33 changed files with 1927 additions and 1053 deletions

View File

@ -21,13 +21,13 @@
- Transactional emails (order confirmation, shipping notification) - Transactional emails (order confirmation, shipping notification)
- Demo content polished and ready for production - 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 ## Task list
Ordered by dependency level — admin shell chain first (unblocks most downstream work). 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 | | # | 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) | | ~~28~~ | ~~Printful mockup generation worker~~ | 25 | — | done (existing pipeline) |
| ~~29~~ | ~~Printful webhooks~~ | 25 | 1.5h | done | | ~~29~~ | ~~Printful webhooks~~ | 25 | 1.5h | done |
| | **Next up** | | | | | | **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 | | | 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** | | | | | | **CSS migration — Tailwind + DaisyUI to modern CSS** | | | |
| ~~32~~ | ~~Phase 0: Foundation + screenshot tooling~~ | 30-31 | 1.5h | done | | ~~32~~ | ~~Phase 0: Foundation + screenshot tooling~~ | 30-31 | 1.5h | done |
| ~~33~~ | ~~Phase 1: Layout primitives + reset~~ | 32 | 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 | | ~~39~~ | ~~Phase 7: Remove Tailwind entirely~~ | 38 | 1.5h | done |
| 40 | Phase 8: Optimisation + modern enhancements | 39 | 2.5h | | | 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. 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 ### 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. 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 €`). 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 €`).

View File

@ -639,3 +639,330 @@
[data-theme="light"] .theme-toggle-indicator { left: 33.333333%; } [data-theme="light"] .theme-toggle-indicator { left: 33.333333%; }
[data-theme="dark"] .theme-toggle-indicator { left: 66.666667%; } [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);
}

View File

@ -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")

View File

@ -50,6 +50,16 @@ defmodule Berrypod.Orders do
|> Map.new() |> Map.new()
end 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 """ @doc """
Returns total revenue (in minor units) from paid orders. Returns total revenue (in minor units) from paid orders.
""" """

View File

@ -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

View File

@ -42,6 +42,19 @@ defmodule Berrypod.Products do
Repo.get_by(ProviderConnection, provider_type: provider_type) Repo.get_by(ProviderConnection, provider_type: provider_type)
end 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 """ @doc """
Creates a provider connection. Creates a provider connection.
""" """

View File

@ -1,8 +1,8 @@
defmodule Berrypod.Providers.Provider do defmodule Berrypod.Providers.Provider do
@moduledoc """ @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: to provide a consistent interface for:
- Testing connections - Testing connections
@ -10,6 +10,10 @@ defmodule Berrypod.Providers.Provider do
- Submitting orders - Submitting orders
- Tracking order status - 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 ## Data Normalization
Providers return normalized data structures: Providers return normalized data structures:
@ -23,47 +27,82 @@ defmodule Berrypod.Providers.Provider do
alias Berrypod.Products.ProviderConnection alias Berrypod.Products.ProviderConnection
@doc """ # Single source of truth for all providers — module dispatch and UI metadata.
Returns the provider type identifier (e.g., "printify", "gelato"). # 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 <strong>Account</strong> (top right)],
~s[Select <strong>Connections</strong> from the dropdown],
~s[Find <strong>API tokens</strong> and click <strong>Generate</strong>],
~s[Enter a name (e.g. "My Shop"), keep <strong>all scopes</strong> selected, and click <strong>Generate token</strong>],
~s[Click <strong>Copy to clipboard</strong> 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 <strong>Settings</strong> &rarr; <strong>API access</strong>],
~s[Click <strong>Create API key</strong>],
~s[Give it a name and select <strong>all scopes</strong>],
~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() @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()} @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()} @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()) :: @callback submit_order(ProviderConnection.t(), order :: map()) ::
{:ok, %{provider_order_id: String.t()}} | {:error, term()} {: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()) :: @callback get_order_status(ProviderConnection.t(), provider_order_id :: String.t()) ::
{:ok, map()} | {:error, term()} {:ok, map()} | {:error, term()}
@doc """ @doc """
Fetches shipping rates from the provider for the given products. 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. Optional providers that don't support shipping rate lookup can skip this.
The sync worker checks `function_exported?/3` before calling. The sync worker checks `function_exported?/3` before calling.
""" """
@ -72,11 +111,24 @@ defmodule Berrypod.Providers.Provider do
@optional_callbacks [fetch_shipping_rates: 2] @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 """ @doc """
Returns the provider module for a given provider type. Returns the provider module for a given provider type.
Checks `:provider_modules` application config first, allowing test 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 def for_type(type) do
case Application.get_env(:berrypod, :provider_modules, %{}) do case Application.get_env(:berrypod, :provider_modules, %{}) do
@ -91,11 +143,13 @@ defmodule Berrypod.Providers.Provider do
end end
end end
defp default_for_type("printify"), do: {:ok, Berrypod.Providers.Printify} defp default_for_type(type) do
defp default_for_type("gelato"), do: {:error, :not_implemented} case Map.fetch(@type_to_module, type) do
defp default_for_type("prodigi"), do: {:error, :not_implemented} {:ok, nil} -> {:error, :not_implemented}
defp default_for_type("printful"), do: {:ok, Berrypod.Providers.Printful} {:ok, module} -> {:ok, module}
defp default_for_type(type), do: {:error, {:unknown_provider, type}} :error -> {:error, {:unknown_provider, type}}
end
end
@doc """ @doc """
Returns the provider module for a provider connection. Returns the provider module for a provider connection.

View File

@ -84,6 +84,7 @@ defmodule Berrypod.Settings do
settings = Ecto.Changeset.apply_changes(changeset) settings = Ecto.Changeset.apply_changes(changeset)
json = Jason.encode!(settings) json = Jason.encode!(settings)
put_setting("theme_settings", json, "json") put_setting("theme_settings", json, "json")
put_setting("theme_customised", true, "boolean")
# Invalidate and rewarm CSS cache # Invalidate and rewarm CSS cache
alias Berrypod.Theme.{CSSCache, CSSGenerator} alias Berrypod.Theme.{CSSCache, CSSGenerator}

View File

@ -1,33 +1,58 @@
defmodule Berrypod.Setup do defmodule Berrypod.Setup do
@moduledoc """ @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 """ @doc """
Returns a map describing the current setup status. Returns a map describing the current setup status.
Used by the admin setup checklist and ThemeHook gate to determine Used by the setup page, dashboard launch checklist, and ThemeHook gate.
what's been completed and whether the shop can go live.
## 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 def setup_status do
conn = Products.get_provider_connection_by_type("printify") conn = Products.get_first_provider_connection()
product_count = Products.count_products_for_connection(conn && conn.id) product_count = Products.count_products()
printify_connected = conn != nil and conn.api_key_encrypted != nil provider_connected = conn != nil
products_synced = product_count > 0 products_synced = product_count > 0
stripe_connected = Settings.has_secret?("stripe_api_key") stripe_connected = Settings.has_secret?("stripe_api_key")
admin_created = Accounts.has_admin?()
site_live = Settings.site_live?() site_live = Settings.site_live?()
%{ %{
admin_created: Accounts.has_admin?(), # Setup phase
printify_connected: printify_connected, 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, products_synced: products_synced,
product_count: product_count, 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, 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
end end

View File

@ -43,14 +43,6 @@
<%!-- nav links --%> <%!-- nav links --%>
<nav class="flex-1 p-2" aria-label="Admin navigation"> <nav class="flex-1 p-2" aria-label="Admin navigation">
<ul class="admin-nav"> <ul class="admin-nav">
<li :if={!@site_live}>
<.link
navigate={~p"/admin/setup"}
class={admin_nav_active?(@current_path, "/admin/setup")}
>
<.icon name="hero-rocket-launch" class="size-5" /> Setup
</.link>
</li>
<li> <li>
<.link <.link
navigate={~p"/admin"} navigate={~p"/admin"}

View File

@ -3,9 +3,16 @@ defmodule BerrypodWeb.Admin.Dashboard do
alias Berrypod.{Cart, Orders, Products, Settings} alias Berrypod.{Cart, Orders, Products, Settings}
@checklist_items [
%{key: :products_synced, label: "Sync your products", href: "/admin/providers"},
%{key: :theme_customised, label: "Customise your theme", href: "/admin/theme"},
%{key: :has_orders, label: "Place a test order", href: "/"},
%{key: :site_live, label: "Go live", href: nil}
]
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
if Settings.site_live?() do setup = Berrypod.Setup.setup_status()
status_counts = Orders.count_orders_by_status() status_counts = Orders.count_orders_by_status()
paid_count = Map.get(status_counts, "paid", 0) paid_count = Map.get(status_counts, "paid", 0)
recent_orders = Orders.list_orders(status: "paid") |> Enum.take(5) recent_orders = Orders.list_orders(status: "paid") |> Enum.take(5)
@ -13,15 +20,40 @@ defmodule BerrypodWeb.Admin.Dashboard do
{:ok, {:ok,
socket socket
|> assign(:page_title, "Dashboard") |> assign(:page_title, "Dashboard")
|> assign(:setup, setup)
|> assign(:show_checklist, show_checklist?(setup))
|> assign(:just_went_live, false)
|> assign(:paid_count, paid_count) |> assign(:paid_count, paid_count)
|> assign(:revenue, Orders.total_revenue()) |> assign(:revenue, Orders.total_revenue())
|> assign(:product_count, Products.count_products()) |> assign(:product_count, Products.count_products())
|> assign(:recent_orders, recent_orders)} |> assign(:recent_orders, recent_orders)}
else
{:ok, push_navigate(socket, to: ~p"/admin/setup")}
end end
# ── Events ──
@impl true
def handle_event("go_live", _params, socket) do
{:ok, _} = Settings.set_site_live(true)
setup = %{socket.assigns.setup | site_live: true}
{:noreply,
socket
|> assign(:setup, setup)
|> assign(:just_went_live, true)}
end end
def handle_event("dismiss_checklist", _params, socket) do
{:ok, _} = Settings.put_setting("checklist_dismissed", true, "boolean")
setup = %{socket.assigns.setup | checklist_dismissed: true}
{:noreply,
socket
|> assign(:setup, setup)
|> assign(:show_checklist, false)}
end
# ── Render ──
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -29,8 +61,26 @@ defmodule BerrypodWeb.Admin.Dashboard do
Dashboard Dashboard
</.header> </.header>
<%!-- Celebration after go-live --%>
<div :if={@just_went_live} class="setup-complete" style="margin-top: 1.5rem;">
<.icon name="hero-check-badge" class="setup-complete-icon" />
<h2>Your shop is live!</h2>
<p>Customers can now browse and buy from your shop.</p>
<div style="display: flex; gap: 0.5rem; justify-content: center; flex-wrap: wrap;">
<.link href={~p"/"} class="admin-btn admin-btn-primary">
<.icon name="hero-arrow-top-right-on-square-mini" class="size-4" /> View your shop
</.link>
<.link navigate={~p"/admin/theme"} class="admin-btn admin-btn-secondary">
<.icon name="hero-paint-brush-mini" class="size-4" /> Customise theme
</.link>
</div>
</div>
<%!-- Launch checklist --%>
<.launch_checklist :if={@show_checklist and !@just_went_live} setup={@setup} />
<%!-- Stats --%> <%!-- Stats --%>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-6"> <div class="admin-stats-grid">
<.stat_card <.stat_card
label="Orders" label="Orders"
value={@paid_count} value={@paid_count}
@ -52,46 +102,50 @@ defmodule BerrypodWeb.Admin.Dashboard do
</div> </div>
<%!-- Recent orders --%> <%!-- Recent orders --%>
<section class="mt-8"> <section style="margin-top: 2rem;">
<div class="flex items-center justify-between mb-4"> <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem;">
<h2 class="text-lg font-semibold">Recent orders</h2> <h2 style="font-size: 1.125rem; font-weight: 600;">Recent orders</h2>
<.link <.link
navigate={~p"/admin/orders"} navigate={~p"/admin/orders"}
class="text-sm text-base-content/60 hover:text-base-content" style="font-size: 0.875rem; color: var(--color-base-content-60, rgba(0 0 0 / 0.6));"
> >
View all &rarr; View all &rarr;
</.link> </.link>
</div> </div>
<%= if @recent_orders == [] do %> <%= if @recent_orders == [] do %>
<div class="rounded-lg border border-base-200 p-8 text-center text-base-content/60"> <div style="border: 1px solid var(--color-base-200, #e5e5e5); border-radius: 0.5rem; padding: 2rem; text-align: center; color: var(--color-base-content-60, rgba(0 0 0 / 0.6));">
<.icon name="hero-inbox" class="size-10 mx-auto mb-3 text-base-content/30" /> <div style="margin: 0 auto 0.75rem; width: 2.5rem; opacity: 0.3;">
<p class="font-medium">No orders yet</p> <.icon name="hero-inbox" class="size-10" />
<p class="text-sm mt-1">Orders will appear here once customers check out.</p> </div>
<p style="font-weight: 500;">No orders yet</p>
<p style="font-size: 0.875rem; margin-top: 0.25rem;">
Orders will appear here once customers check out.
</p>
</div> </div>
<% else %> <% else %>
<div class="overflow-x-auto"> <div style="overflow-x: auto;">
<table class="w-full text-sm"> <table class="admin-table">
<thead> <thead>
<tr class="border-b border-base-200 text-left text-base-content/60"> <tr>
<th class="pb-2 font-medium">Order</th> <th>Order</th>
<th class="pb-2 font-medium">Date</th> <th>Date</th>
<th class="pb-2 font-medium">Customer</th> <th>Customer</th>
<th class="pb-2 font-medium text-right">Total</th> <th style="text-align: right;">Total</th>
<th class="pb-2 font-medium">Fulfilment</th> <th>Fulfilment</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr <tr
:for={order <- @recent_orders} :for={order <- @recent_orders}
class="border-b border-base-200 hover:bg-base-200/50 cursor-pointer"
phx-click={JS.navigate(~p"/admin/orders/#{order}")} phx-click={JS.navigate(~p"/admin/orders/#{order}")}
style="cursor: pointer;"
> >
<td class="py-2.5 font-medium">{order.order_number}</td> <td style="font-weight: 500;">{order.order_number}</td>
<td class="py-2.5 text-base-content/60">{format_date(order.inserted_at)}</td> <td>{format_date(order.inserted_at)}</td>
<td class="py-2.5 text-base-content/60">{order.customer_email || ""}</td> <td>{order.customer_email || ""}</td>
<td class="py-2.5 text-right">{Cart.format_price(order.total)}</td> <td style="text-align: right;">{Cart.format_price(order.total)}</td>
<td class="py-2.5"><.fulfilment_pill status={order.fulfilment_status} /></td> <td><.fulfilment_pill status={order.fulfilment_status} /></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -101,6 +155,91 @@ defmodule BerrypodWeb.Admin.Dashboard do
""" """
end end
# ==========================================================================
# Launch checklist component
# ==========================================================================
attr :setup, :map, required: true
defp launch_checklist(assigns) do
items =
Enum.map(@checklist_items, fn item ->
Map.put(item, :done, Map.get(assigns.setup, item.key, false))
end)
done_count = Enum.count(items, & &1.done)
total = length(items)
progress_pct = round(done_count / total * 100)
can_go_live =
assigns.setup.provider_connected and assigns.setup.products_synced and
assigns.setup.stripe_connected
assigns =
assigns
|> assign(:items, items)
|> assign(:done_count, done_count)
|> assign(:total, total)
|> assign(:progress_pct, progress_pct)
|> assign(:can_go_live, can_go_live)
~H"""
<div class="admin-checklist" style="margin-top: 1.5rem;">
<div class="admin-checklist-header">
<h2 class="admin-checklist-title">Launch checklist</h2>
<div class="admin-checklist-progress">
<span>{@done_count} of {@total}</span>
<div class="admin-checklist-bar">
<div class="admin-checklist-bar-fill" style={"width: #{@progress_pct}%"} />
</div>
</div>
</div>
<ul class="admin-checklist-items">
<li :for={item <- @items} class="admin-checklist-item">
<span class={["admin-checklist-check", item.done && "admin-checklist-check-done"]}>
<.icon :if={item.done} name="hero-check-mini" class="size-3" />
</span>
<span class={["admin-checklist-label", item.done && "admin-checklist-label-done"]}>
{item.label}
</span>
<span class="admin-checklist-action">
<%= if item.key == :site_live do %>
<button
phx-click="go_live"
disabled={!@can_go_live}
class="admin-btn admin-btn-primary admin-btn-sm"
>
<.icon name="hero-rocket-launch-mini" class="size-4" /> Go live
</button>
<% else %>
<.link
:if={!item.done}
navigate={item.href}
class="admin-btn admin-btn-secondary admin-btn-sm"
>
{if item.done, do: "View", else: "Start"} &rarr;
</.link>
<% end %>
</span>
</li>
</ul>
<div class="admin-checklist-footer">
<button
type="button"
phx-click="dismiss_checklist"
class="admin-btn admin-btn-ghost admin-btn-sm"
>
Dismiss
</button>
</div>
</div>
"""
end
# ========================================================================== # ==========================================================================
# Components # Components
# ========================================================================== # ==========================================================================
@ -114,15 +253,18 @@ defmodule BerrypodWeb.Admin.Dashboard do
~H""" ~H"""
<.link <.link
navigate={@href} navigate={@href}
class="rounded-lg border border-base-200 p-4 hover:border-base-300 transition-colors" class="admin-card"
style="display: block; text-decoration: none;"
> >
<div class="flex items-center gap-3"> <div style="display: flex; align-items: center; gap: 0.75rem; padding: 1rem;">
<div class="rounded-lg bg-base-200 p-2"> <div style="background: var(--color-base-200, #e5e5e5); border-radius: 0.5rem; padding: 0.5rem;">
<.icon name={@icon} class="size-5 text-base-content/60" /> <.icon name={@icon} class="size-5" />
</div> </div>
<div> <div>
<p class="text-2xl font-bold">{@value}</p> <p style="font-size: 1.5rem; font-weight: 700;">{@value}</p>
<p class="text-sm text-base-content/60">{@label}</p> <p style="font-size: 0.875rem; color: var(--color-base-content-60, rgba(0 0 0 / 0.6));">
{@label}
</p>
</div> </div>
</div> </div>
</.link> </.link>
@ -132,19 +274,19 @@ defmodule BerrypodWeb.Admin.Dashboard do
defp fulfilment_pill(assigns) do defp fulfilment_pill(assigns) do
{color, label} = {color, label} =
case assigns.status do case assigns.status do
"unfulfilled" -> {"bg-base-200 text-base-content/60", "unfulfilled"} "unfulfilled" -> {"var(--color-base-200, #e5e5e5)", "unfulfilled"}
"submitted" -> {"bg-blue-50 text-blue-700", "submitted"} "submitted" -> {"#dbeafe", "submitted"}
"processing" -> {"bg-amber-50 text-amber-700", "processing"} "processing" -> {"#fef3c7", "processing"}
"shipped" -> {"bg-purple-50 text-purple-700", "shipped"} "shipped" -> {"#f3e8ff", "shipped"}
"delivered" -> {"bg-green-50 text-green-700", "delivered"} "delivered" -> {"#dcfce7", "delivered"}
"failed" -> {"bg-red-50 text-red-700", "failed"} "failed" -> {"#fee2e2", "failed"}
_ -> {"bg-base-200 text-base-content/60", assigns.status || ""} _ -> {"var(--color-base-200, #e5e5e5)", assigns.status || ""}
end end
assigns = assign(assigns, color: color, label: label) assigns = assign(assigns, color: color, label: label)
~H""" ~H"""
<span class={["inline-flex rounded-full px-2 py-0.5 text-xs font-medium", @color]}> <span style={"display: inline-flex; border-radius: 9999px; padding: 0.125rem 0.5rem; font-size: 0.75rem; font-weight: 500; background: #{@color};"}>
{@label} {@label}
</span> </span>
""" """
@ -154,6 +296,10 @@ defmodule BerrypodWeb.Admin.Dashboard do
# Helpers # Helpers
# ========================================================================== # ==========================================================================
defp show_checklist?(setup) do
not setup.site_live and not setup.checklist_dismissed
end
defp format_revenue(amount_pence) when is_integer(amount_pence) do defp format_revenue(amount_pence) when is_integer(amount_pence) do
Cart.format_price(amount_pence) Cart.format_price(amount_pence)
end end

View File

@ -4,8 +4,9 @@ defmodule BerrypodWeb.Admin.Providers.Form do
alias Berrypod.Products alias Berrypod.Products
alias Berrypod.Products.ProviderConnection alias Berrypod.Products.ProviderConnection
alias Berrypod.Providers alias Berrypod.Providers
alias Berrypod.Providers.Provider
@supported_types ~w(printify printful) @supported_types Enum.map(Provider.available(), & &1.type)
@impl true @impl true
def mount(params, _session, socket) do def mount(params, _session, socket) do
@ -14,10 +15,12 @@ defmodule BerrypodWeb.Admin.Providers.Form do
defp apply_action(socket, :new, params) do defp apply_action(socket, :new, params) do
provider_type = validated_type(params["type"]) provider_type = validated_type(params["type"])
provider = Provider.get(provider_type)
socket socket
|> assign(:page_title, "Connect to #{provider_label(provider_type)}") |> assign(:page_title, "Connect to #{provider.name}")
|> assign(:provider_type, provider_type) |> assign(:provider_type, provider_type)
|> assign(:provider, provider)
|> assign(:connection, %ProviderConnection{provider_type: provider_type}) |> assign(:connection, %ProviderConnection{provider_type: provider_type})
|> assign(:form, to_form(ProviderConnection.changeset(%ProviderConnection{}, %{}))) |> assign(:form, to_form(ProviderConnection.changeset(%ProviderConnection{}, %{})))
|> assign(:testing, false) |> assign(:testing, false)
@ -27,10 +30,12 @@ defmodule BerrypodWeb.Admin.Providers.Form do
defp apply_action(socket, :edit, %{"id" => id}) do defp apply_action(socket, :edit, %{"id" => id}) do
connection = Products.get_provider_connection!(id) connection = Products.get_provider_connection!(id)
provider = Provider.get(connection.provider_type)
socket socket
|> assign(:page_title, "#{provider_label(connection.provider_type)} settings") |> assign(:page_title, "#{provider.name} settings")
|> assign(:provider_type, connection.provider_type) |> assign(:provider_type, connection.provider_type)
|> assign(:provider, provider)
|> assign(:connection, connection) |> assign(:connection, connection)
|> assign(:form, to_form(ProviderConnection.changeset(connection, %{}))) |> assign(:form, to_form(ProviderConnection.changeset(connection, %{})))
|> assign(:testing, false) |> assign(:testing, false)
@ -89,7 +94,7 @@ defmodule BerrypodWeb.Admin.Providers.Form do
{:ok, _connection} -> {:ok, _connection} ->
{:noreply, {:noreply,
socket socket
|> put_flash(:info, "Connected to #{provider_label(provider_type)}!") |> put_flash(:info, "Connected to #{socket.assigns.provider.name}!")
|> push_navigate(to: ~p"/admin/settings")} |> push_navigate(to: ~p"/admin/settings")}
{:error, %Ecto.Changeset{} = changeset} -> {:error, %Ecto.Changeset{} = changeset} ->
@ -132,7 +137,8 @@ defmodule BerrypodWeb.Admin.Providers.Form do
end end
defp maybe_add_name(params, type, _result) do defp maybe_add_name(params, type, _result) do
Map.put_new(params, "name", provider_label(type)) provider = Provider.get(type)
Map.put_new(params, "name", provider && provider.name || type)
end end
defp encrypt_api_key(api_key) do defp encrypt_api_key(api_key) do
@ -147,9 +153,6 @@ defmodule BerrypodWeb.Admin.Providers.Form do
# Shared helpers used by the template # Shared helpers used by the template
defp provider_label("printful"), do: "Printful"
defp provider_label(_), do: "Printify"
defp connection_name({:ok, %{shop_name: name}}), do: name defp connection_name({:ok, %{shop_name: name}}), do: name
defp connection_name({:ok, %{store_name: name}}), do: name defp connection_name({:ok, %{store_name: name}}), do: name
defp connection_name(_), do: nil defp connection_name(_), do: nil

View File

@ -1,75 +1,32 @@
<.header> <.header>
{if @live_action == :new, {if @live_action == :new,
do: "Connect to #{provider_label(@provider_type)}", do: "Connect to #{@provider.name}",
else: "#{provider_label(@provider_type)} settings"} else: "#{@provider.name} settings"}
</.header> </.header>
<div class="max-w-xl mt-6"> <div class="max-w-xl mt-6">
<%= if @live_action == :new do %> <%= if @live_action == :new do %>
<div class="prose prose-sm mb-6"> <div class="prose prose-sm mb-6">
<p> <p>
{provider_label(@provider_type)} is a print-on-demand service that prints and ships products for you. {@provider.name} is a print-on-demand service that prints and ships products for you.
Connect your account to automatically import your products into your shop. Connect your account to automatically import your products into your shop.
</p> </p>
</div> </div>
<%= if @provider_type == "printify" do %>
<div class="rounded-lg bg-base-200 p-4 mb-6 text-sm"> <div class="rounded-lg bg-base-200 p-4 mb-6 text-sm">
<p class="font-medium mb-2">Get your API key from Printify:</p> <p class="font-medium mb-2">Get your API key from {@provider.name}:</p>
<ol class="list-decimal list-inside space-y-1 text-base-content/80"> <ol class="list-decimal list-inside space-y-1 text-base-content/80">
<li> <li>
<a <a href={@provider.login_url} target="_blank" rel="noopener" class="admin-link">
href="https://printify.com/app/auth/login" Log in to {@provider.name}
target="_blank"
rel="noopener"
class="admin-link"
>
Log in to Printify
</a> </a>
(or <a (or <a href={@provider.signup_url} target="_blank" rel="noopener" class="admin-link">create a free account</a>)
href="https://printify.com/app/auth/register"
target="_blank"
rel="noopener"
class="admin-link"
>create a free account</a>)
</li> </li>
<li>Click <strong>Account</strong> (top right)</li> <li :for={step <- @provider.setup_steps}>
<li>Select <strong>Connections</strong> from the dropdown</li> {raw(step)}
<li>Find <strong>API tokens</strong> and click <strong>Generate</strong></li>
<li>
Enter a name (e.g. "My Shop"), keep <strong>all scopes</strong>
selected, and click <strong>Generate token</strong>
</li> </li>
<li>Click <strong>Copy to clipboard</strong> and paste it below</li>
</ol> </ol>
</div> </div>
<% else %>
<div class="rounded-lg bg-base-200 p-4 mb-6 text-sm">
<p class="font-medium mb-2">Get your API key from Printful:</p>
<ol class="list-decimal list-inside space-y-1 text-base-content/80">
<li>
<a
href="https://www.printful.com/auth/login"
target="_blank"
rel="noopener"
class="admin-link"
>
Log in to Printful
</a>
(or <a
href="https://www.printful.com/auth/signup"
target="_blank"
rel="noopener"
class="admin-link"
>create a free account</a>)
</li>
<li>Go to <strong>Settings</strong> &rarr; <strong>API access</strong></li>
<li>Click <strong>Create API key</strong></li>
<li>Give it a name and select <strong>all scopes</strong></li>
<li>Copy the token and paste it below</li>
</ol>
</div>
<% end %>
<% end %> <% end %>
<.form for={@form} id="provider-form" phx-change="validate" phx-submit="save"> <.form for={@form} id="provider-form" phx-change="validate" phx-submit="save">
@ -78,7 +35,7 @@
<.input <.input
field={@form[:api_key]} field={@form[:api_key]}
type="password" type="password"
label={"#{provider_label(@provider_type)} API key"} label={"#{@provider.name} API key"}
placeholder={ placeholder={
if @live_action == :edit, if @live_action == :edit,
do: "Leave blank to keep current key", do: "Leave blank to keep current key",
@ -106,7 +63,7 @@
<% {:ok, _info} -> %> <% {:ok, _info} -> %>
<span class="text-success flex items-center gap-1"> <span class="text-success flex items-center gap-1">
<.icon name="hero-check-circle" class="size-4" /> <.icon name="hero-check-circle" class="size-4" />
Connected to {connection_name(@test_result) || provider_label(@provider_type)} Connected to {connection_name(@test_result) || @provider.name}
</span> </span>
<% {:error, reason} -> %> <% {:error, reason} -> %>
<span class="text-error flex items-center gap-1"> <span class="text-error flex items-center gap-1">
@ -124,7 +81,7 @@
<div class="flex gap-2 mt-6"> <div class="flex gap-2 mt-6">
<.button type="submit" disabled={@testing}> <.button type="submit" disabled={@testing}>
{if @live_action == :new, {if @live_action == :new,
do: "Connect to #{provider_label(@provider_type)}", do: "Connect to #{@provider.name}",
else: "Save changes"} else: "Save changes"}
</.button> </.button>
<.link navigate={~p"/admin/providers"} class="admin-btn admin-btn-ghost"> <.link navigate={~p"/admin/providers"} class="admin-btn admin-btn-ghost">

View File

@ -3,6 +3,7 @@ defmodule BerrypodWeb.Admin.Providers.Index do
alias Berrypod.Products alias Berrypod.Products
alias Berrypod.Products.ProviderConnection alias Berrypod.Products.ProviderConnection
alias Berrypod.Providers.Provider
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
@ -11,6 +12,7 @@ defmodule BerrypodWeb.Admin.Providers.Index do
{:ok, {:ok,
socket socket
|> assign(:page_title, "Provider connections") |> assign(:page_title, "Provider connections")
|> assign(:available_providers, Provider.available())
|> stream(:connections, connections)} |> stream(:connections, connections)}
end end
@ -85,6 +87,13 @@ defmodule BerrypodWeb.Admin.Providers.Index do
""" """
end end
defp provider_name(type) do
case Provider.get(type) do
%{name: name} -> name
nil -> String.capitalize(type)
end
end
defp format_relative_time(datetime) do defp format_relative_time(datetime) do
diff = DateTime.diff(DateTime.utc_now(), datetime, :second) diff = DateTime.diff(DateTime.utc_now(), datetime, :second)

View File

@ -6,11 +6,8 @@
<.icon name="hero-plus" class="size-4 mr-1" /> Connect provider <.icon name="hero-plus" class="size-4 mr-1" /> Connect provider
</div> </div>
<ul tabindex="0" class="admin-dropdown-content"> <ul tabindex="0" class="admin-dropdown-content">
<li> <li :for={provider <- @available_providers}>
<.link navigate={~p"/admin/providers/new?type=printify"}>Printify</.link> <.link navigate={~p"/admin/providers/new?type=#{provider.type}"}>{provider.name}</.link>
</li>
<li>
<.link navigate={~p"/admin/providers/new?type=printful"}>Printful</.link>
</li> </li>
</ul> </ul>
</div> </div>
@ -22,15 +19,14 @@
<.icon name="hero-cube" class="size-16 mx-auto mb-4 text-base-content/30" /> <.icon name="hero-cube" class="size-16 mx-auto mb-4 text-base-content/30" />
<h2 class="text-xl font-medium">Connect a print-on-demand provider</h2> <h2 class="text-xl font-medium">Connect a print-on-demand provider</h2>
<p class="mt-2 text-base-content/60 max-w-md mx-auto"> <p class="mt-2 text-base-content/60 max-w-md mx-auto">
Connect your Printify or Printful account to import products Connect your account to import products and start selling.
and start selling.
</p> </p>
<div class="flex justify-center gap-3 mt-6"> <div class="flex justify-center gap-3 mt-6">
<.button navigate={~p"/admin/providers/new?type=printify"}> <.button
Connect Printify :for={provider <- @available_providers}
</.button> navigate={~p"/admin/providers/new?type=#{provider.type}"}
<.button navigate={~p"/admin/providers/new?type=printful"} variant="outline"> >
Connect Printful Connect {provider.name}
</.button> </.button>
</div> </div>
</div> </div>
@ -46,7 +42,7 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<.status_indicator status={connection.sync_status} enabled={connection.enabled} /> <.status_indicator status={connection.sync_status} enabled={connection.enabled} />
<h3 class="font-semibold text-lg"> <h3 class="font-semibold text-lg">
{String.capitalize(connection.provider_type)} {provider_name(connection.provider_type)}
</h3> </h3>
</div> </div>
<p class="text-base-content/70 mt-1">{connection.name}</p> <p class="text-base-content/70 mt-1">{connection.name}</p>
@ -65,7 +61,7 @@
<button <button
phx-click="delete" phx-click="delete"
phx-value-id={connection.id} phx-value-id={connection.id}
data-confirm={"Disconnect from #{String.capitalize(connection.provider_type)}? Your synced products will remain in your shop."} data-confirm={"Disconnect from #{provider_name(connection.provider_type)}? Your synced products will remain in your shop."}
class="admin-btn admin-btn-ghost admin-btn-sm text-error" class="admin-btn admin-btn-ghost admin-btn-sm text-error"
> >
Disconnect Disconnect

View File

@ -1,660 +0,0 @@
defmodule BerrypodWeb.Admin.Setup do
use BerrypodWeb, :live_view
alias Berrypod.{Products, Settings, Setup}
alias Berrypod.Products.ProviderConnection
alias Berrypod.Providers
alias Berrypod.Stripe.Setup, as: StripeSetup
@impl true
def mount(_params, _session, socket) do
if Settings.site_live?() do
{:ok, push_navigate(socket, to: ~p"/admin")}
else
status = Setup.setup_status()
conn = Products.get_provider_connection_by_type("printify")
if conn && connected?(socket) do
Phoenix.PubSub.subscribe(Berrypod.PubSub, "sync:#{conn.id}")
end
active_step = determine_active_step(status)
{:ok,
socket
|> assign(:page_title, "Get started")
|> assign(:setup, status)
|> assign(:active_step, active_step)
# Printify state
|> assign(:printify_conn, conn)
|> assign(:printify_form, to_form(%{"api_key" => ""}, as: :printify))
|> assign(:printify_testing, false)
|> assign(:printify_test_result, nil)
|> assign(:printify_saving, false)
|> assign(:pending_api_key, nil)
|> assign(:sync_status, conn && conn.sync_status)
# Stripe state
|> assign(:stripe_form, to_form(%{"api_key" => ""}, as: :stripe))
|> assign(:stripe_connecting, false)
|> assign(:stripe_api_key_hint, Settings.secret_hint("stripe_api_key"))
# Celebration
|> assign(:just_went_live, false)}
end
end
# -- Step determination --
defp determine_active_step(status) do
cond do
!status.printify_connected -> :printify
!status.products_synced -> :printify
!status.stripe_connected -> :stripe
!status.site_live -> :go_live
true -> :complete
end
end
# -- Events: Printify --
@impl true
def handle_event("validate_printify", %{"printify" => params}, socket) do
{:noreply, assign(socket, pending_api_key: params["api_key"])}
end
def handle_event("test_printify", _params, socket) do
api_key = socket.assigns.pending_api_key
if api_key in [nil, ""] do
{:noreply, assign(socket, printify_test_result: {:error, :no_api_key})}
else
socket = assign(socket, printify_testing: true, printify_test_result: nil)
temp_conn = %ProviderConnection{
provider_type: "printify",
api_key_encrypted: encrypt_api_key(api_key)
}
result = Providers.test_connection(temp_conn)
{:noreply, assign(socket, printify_testing: false, printify_test_result: result)}
end
end
def handle_event("connect_printify", %{"printify" => %{"api_key" => api_key}}, socket) do
if api_key == "" do
{:noreply, put_flash(socket, :error, "Please enter your Printify API token")}
else
socket = assign(socket, printify_saving: true)
params =
%{"api_key" => api_key, "provider_type" => "printify"}
|> maybe_add_shop_config(socket.assigns.printify_test_result)
|> maybe_add_name(socket.assigns.printify_test_result)
case Products.create_provider_connection(params) do
{:ok, connection} ->
Products.enqueue_sync(connection)
if connected?(socket) do
Phoenix.PubSub.subscribe(Berrypod.PubSub, "sync:#{connection.id}")
end
status = %{socket.assigns.setup | printify_connected: true}
{:noreply,
socket
|> assign(:printify_saving, false)
|> assign(:printify_conn, connection)
|> assign(:sync_status, "syncing")
|> assign(:setup, status)
|> put_flash(:info, "Connected to Printify! Syncing products...")}
{:error, _changeset} ->
{:noreply,
socket
|> assign(:printify_saving, false)
|> put_flash(:error, "Failed to save connection")}
end
end
end
def handle_event("retry_sync", _params, socket) do
conn = socket.assigns.printify_conn
if conn do
Products.enqueue_sync(conn)
{:noreply, assign(socket, sync_status: "syncing")}
else
{:noreply, socket}
end
end
# -- Events: Stripe --
def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do
if api_key == "" do
{:noreply, put_flash(socket, :error, "Please enter your Stripe secret key")}
else
socket = assign(socket, stripe_connecting: true)
case StripeSetup.connect(api_key) do
{:ok, _result} ->
status = %{socket.assigns.setup | stripe_connected: true, can_go_live: true}
{:noreply,
socket
|> assign(:stripe_connecting, false)
|> assign(:setup, status)
|> assign(:stripe_api_key_hint, Settings.secret_hint("stripe_api_key"))
|> assign(:active_step, :go_live)
|> put_flash(:info, "Stripe connected")}
{:error, message} ->
{:noreply,
socket
|> assign(:stripe_connecting, false)
|> put_flash(:error, "Stripe connection failed: #{message}")}
end
end
end
# -- Events: Go live --
def handle_event("go_live", _params, socket) do
{:ok, _} = Settings.set_site_live(true)
status = %{socket.assigns.setup | site_live: true}
{:noreply,
socket
|> assign(:setup, status)
|> assign(:just_went_live, true)}
end
# -- Events: Step navigation --
def handle_event("toggle_step", %{"step" => step}, socket) do
step = String.to_existing_atom(step)
new_active =
if socket.assigns.active_step == step do
determine_active_step(socket.assigns.setup)
else
step
end
{:noreply, assign(socket, active_step: new_active)}
end
# -- PubSub: Sync progress --
@impl true
def handle_info({:sync_status, "completed", product_count}, socket) do
status = %{
socket.assigns.setup
| products_synced: true,
product_count: product_count
}
active_step = if status.stripe_connected, do: :go_live, else: :stripe
{:noreply,
socket
|> assign(:setup, status)
|> assign(:sync_status, "completed")
|> assign(:active_step, active_step)
|> put_flash(:info, "#{product_count} products synced")}
end
def handle_info({:sync_status, "failed"}, socket) do
{:noreply,
socket
|> assign(:sync_status, "failed")
|> put_flash(:error, "Product sync failed — try again")}
end
def handle_info({:sync_status, status}, socket) do
{:noreply, assign(socket, sync_status: status)}
end
# -- Render --
@impl true
def render(assigns) do
~H"""
<.header>
Get started
</.header>
<%!-- Celebration state --%>
<.celebration :if={@just_went_live} />
<%!-- Setup stepper --%>
<.setup_stepper
:if={!@just_went_live}
setup={@setup}
active_step={@active_step}
printify_conn={@printify_conn}
printify_form={@printify_form}
printify_testing={@printify_testing}
printify_test_result={@printify_test_result}
printify_saving={@printify_saving}
sync_status={@sync_status}
stripe_form={@stripe_form}
stripe_connecting={@stripe_connecting}
stripe_api_key_hint={@stripe_api_key_hint}
/>
"""
end
# ==========================================================================
# Setup stepper components
# ==========================================================================
attr :setup, :map, required: true
attr :active_step, :atom, required: true
attr :printify_conn, :any, required: true
attr :printify_form, :any, required: true
attr :printify_testing, :boolean, required: true
attr :printify_test_result, :any, required: true
attr :printify_saving, :boolean, required: true
attr :sync_status, :string, required: true
attr :stripe_form, :any, required: true
attr :stripe_connecting, :boolean, required: true
attr :stripe_api_key_hint, :string, required: true
defp setup_stepper(assigns) do
~H"""
<div class="mt-6">
<ol class="relative" aria-label="Setup steps">
<%!-- Step 1: Printify --%>
<.setup_step
step={:printify}
number={1}
title="Connect to Printify"
active_step={@active_step}
done={@setup.printify_connected and @setup.products_synced}
last={false}
next_done={@setup.stripe_connected}
>
<:summary :if={@setup.printify_connected and @setup.products_synced}>
Connected &middot; {@setup.product_count} products synced
</:summary>
<:content>
<.printify_step_content
setup={@setup}
printify_conn={@printify_conn}
printify_form={@printify_form}
printify_testing={@printify_testing}
printify_test_result={@printify_test_result}
printify_saving={@printify_saving}
sync_status={@sync_status}
/>
</:content>
</.setup_step>
<%!-- Step 2: Stripe --%>
<.setup_step
step={:stripe}
number={2}
title="Connect Stripe"
active_step={@active_step}
done={@setup.stripe_connected}
last={false}
next_done={@setup.site_live}
>
<:summary :if={@setup.stripe_connected}>
Connected &middot; {@stripe_api_key_hint}
</:summary>
<:content>
<.stripe_step_content
stripe_form={@stripe_form}
stripe_connecting={@stripe_connecting}
/>
</:content>
</.setup_step>
<%!-- Step 3: Go live --%>
<.setup_step
step={:go_live}
number={3}
title="Go live"
active_step={@active_step}
done={@setup.site_live}
last={true}
next_done={false}
>
<:content>
<.go_live_step_content setup={@setup} />
</:content>
</.setup_step>
</ol>
</div>
"""
end
attr :step, :atom, required: true
attr :number, :integer, required: true
attr :title, :string, required: true
attr :active_step, :atom, required: true
attr :done, :boolean, required: true
attr :last, :boolean, required: true
attr :next_done, :boolean, required: true
slot :summary
slot :content, required: true
defp setup_step(assigns) do
is_active = assigns.active_step == assigns.step
is_clickable = assigns.done
assigns =
assigns
|> assign(:is_active, is_active)
|> assign(:is_clickable, is_clickable)
~H"""
<li class="relative pl-10 pb-8 last:pb-0" aria-current={@is_active && "step"}>
<%!-- Connector line --%>
<div
:if={!@last}
class={[
"absolute left-[0.9375rem] top-8 -bottom-0 w-0.5",
if(@done, do: "bg-green-500", else: "bg-base-300")
]}
aria-hidden="true"
/>
<%!-- Step circle --%>
<div class={[
"absolute left-0 top-0 flex size-8 items-center justify-center rounded-full text-sm font-semibold ring-4 ring-base-100",
cond do
@done -> "bg-green-500 text-white"
@is_active -> "bg-base-content text-white"
true -> "bg-base-200 text-base-content/40"
end
]}>
<%= if @done do %>
<.icon name="hero-check-mini" class="size-5" />
<% else %>
{@number}
<% end %>
</div>
<%!-- Step header --%>
<%= if @is_clickable do %>
<button
type="button"
class="flex w-full items-center gap-2 text-left"
phx-click="toggle_step"
phx-value-step={@step}
aria-expanded={to_string(@is_active)}
>
<h3 class="text-sm font-semibold text-base-content">{@title}</h3>
<.icon
name={if @is_active, do: "hero-chevron-up-mini", else: "hero-chevron-down-mini"}
class="size-4 text-base-content/40"
/>
</button>
<% else %>
<h3 class={[
"text-sm font-semibold",
if(@is_active, do: "text-base-content", else: "text-base-content/40")
]}>
{@title}
</h3>
<% end %>
<%!-- Collapsed summary for completed steps --%>
<p :if={@done and !@is_active and @summary != []} class="text-sm text-base-content/60 mt-0.5">
{render_slot(@summary)}
</p>
<%!-- Expanded content --%>
<div :if={@is_active} class="mt-3">
{render_slot(@content)}
</div>
</li>
"""
end
# -- Printify step content --
attr :setup, :map, required: true
attr :printify_conn, :any, required: true
attr :printify_form, :any, required: true
attr :printify_testing, :boolean, required: true
attr :printify_test_result, :any, required: true
attr :printify_saving, :boolean, required: true
attr :sync_status, :string, required: true
defp printify_step_content(assigns) do
~H"""
<%!-- Not yet connected: show form --%>
<div :if={!@setup.printify_connected}>
<p class="text-sm text-base-content/60 mb-4">
Connect your Printify account to import products.
Get an API token from <a
href="https://printify.com/app/account/connections"
target="_blank"
rel="noopener"
class="text-base-content underline"
>
Printify &rarr; Account &rarr; Connections
</a>.
</p>
<.form
for={@printify_form}
phx-change="validate_printify"
phx-submit="connect_printify"
>
<.input
field={@printify_form[:api_key]}
type="password"
label="Printify API token"
placeholder="Paste your token here"
autocomplete="off"
/>
<div class="flex flex-col sm:flex-row gap-2 mt-3">
<button
type="button"
phx-click="test_printify"
disabled={@printify_testing}
class="inline-flex items-center justify-center gap-1.5 rounded-md bg-base-200 px-3 py-2 text-sm font-medium text-base-content hover:bg-base-300 ring-1 ring-base-300 ring-inset disabled:opacity-50"
>
<.icon
name={if @printify_testing, do: "hero-arrow-path", else: "hero-signal"}
class={if @printify_testing, do: "size-4 animate-spin", else: "size-4"}
/>
{if @printify_testing, do: "Checking...", else: "Check connection"}
</button>
<.button type="submit" disabled={@printify_saving or @printify_testing}>
{if @printify_saving, do: "Connecting...", else: "Connect to Printify"}
</.button>
</div>
<.printify_test_feedback :if={@printify_test_result} result={@printify_test_result} />
</.form>
</div>
<%!-- Connected, syncing --%>
<div
:if={@setup.printify_connected and @sync_status == "syncing"}
class="flex items-center gap-2 text-sm"
>
<.icon name="hero-arrow-path" class="size-4 animate-spin text-base-content/40" />
<span class="text-base-content/60">Syncing products from Printify...</span>
</div>
<%!-- Connected, sync failed --%>
<div :if={@setup.printify_connected and @sync_status == "failed"}>
<p class="text-sm text-red-600 mb-2">Product sync failed.</p>
<button
type="button"
phx-click="retry_sync"
class="inline-flex items-center gap-1.5 rounded-md bg-base-200 px-3 py-2 text-sm font-medium text-base-content hover:bg-base-300 ring-1 ring-base-300 ring-inset"
>
<.icon name="hero-arrow-path" class="size-4" /> Try again
</button>
</div>
<%!-- Connected, synced (shown when user expands a completed step) --%>
<div :if={@setup.printify_connected and @setup.products_synced}>
<p class="text-sm text-base-content/60">
{@setup.product_count} products synced from Printify.
</p>
</div>
"""
end
attr :result, :any, required: true
defp printify_test_feedback(assigns) do
~H"""
<div class="mt-2 text-sm">
<%= case @result do %>
<% {:ok, info} -> %>
<span class="text-green-600 flex items-center gap-1">
<.icon name="hero-check-circle" class="size-4" /> Connected to {info.shop_name}
</span>
<% {:error, reason} -> %>
<span class="text-red-600 flex items-center gap-1">
<.icon name="hero-x-circle" class="size-4" />
{format_printify_error(reason)}
</span>
<% end %>
</div>
"""
end
# -- Stripe step content --
attr :stripe_form, :any, required: true
attr :stripe_connecting, :boolean, required: true
defp stripe_step_content(assigns) do
~H"""
<div>
<p class="text-sm text-base-content/60 mb-4">
Enter your Stripe secret key to accept payments.
Find it in your
<a
href="https://dashboard.stripe.com/apikeys"
target="_blank"
rel="noopener"
class="text-base-content underline"
>
Stripe dashboard
</a>
under Developers &rarr; API keys.
</p>
<.form for={@stripe_form} phx-submit="connect_stripe">
<.input
field={@stripe_form[:api_key]}
type="password"
label="Secret key"
autocomplete="off"
placeholder="sk_test_... or sk_live_..."
/>
<p class="text-xs text-base-content/60 mt-1">
Starts with <code>sk_test_</code> or <code>sk_live_</code>. Encrypted at rest.
</p>
<div class="mt-3">
<.button phx-disable-with="Connecting...">
{if @stripe_connecting, do: "Connecting...", else: "Connect Stripe"}
</.button>
</div>
</.form>
</div>
"""
end
# -- Go live step content --
attr :setup, :map, required: true
defp go_live_step_content(assigns) do
~H"""
<div>
<p class="text-sm text-base-content/60 mb-4">
Your shop is ready. Visitors currently see a "coming soon" page &mdash;
hit the button to make it live.
</p>
<button
phx-click="go_live"
disabled={!@setup.can_go_live}
class="inline-flex items-center gap-2 rounded-md bg-green-600 px-4 py-2.5 text-sm font-semibold text-white shadow-xs hover:bg-green-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<.icon name="hero-rocket-launch" class="size-5" /> Go live
</button>
</div>
"""
end
# -- Celebration --
defp celebration(assigns) do
~H"""
<div class="mt-6 rounded-lg border border-green-200 bg-green-50 p-6 text-center">
<.icon name="hero-check-badge" class="size-12 mx-auto text-green-600 mb-3" />
<h2 class="text-lg font-semibold text-green-900">Your shop is live!</h2>
<p class="text-sm text-green-700 mt-1 mb-4">
Customers can now browse and buy from your shop.
</p>
<div class="flex flex-col sm:flex-row gap-2 justify-center">
<.link
navigate={~p"/admin"}
class="inline-flex items-center justify-center gap-1.5 rounded-md bg-green-600 px-3 py-2 text-sm font-semibold text-white hover:bg-green-500"
>
<.icon name="hero-home-mini" class="size-4" /> Go to dashboard
</.link>
<.link
navigate={~p"/"}
class="inline-flex items-center justify-center gap-1.5 rounded-md bg-base-100 px-3 py-2 text-sm font-medium text-base-content ring-1 ring-base-300 ring-inset hover:bg-base-200/50"
>
<.icon name="hero-arrow-top-right-on-square-mini" class="size-4" /> View your shop
</.link>
<.link
navigate={~p"/admin/theme"}
class="inline-flex items-center justify-center gap-1.5 rounded-md bg-base-100 px-3 py-2 text-sm font-medium text-base-content ring-1 ring-base-300 ring-inset hover:bg-base-200/50"
>
<.icon name="hero-paint-brush-mini" class="size-4" /> Customise theme
</.link>
</div>
</div>
"""
end
# ==========================================================================
# Helpers
# ==========================================================================
defp encrypt_api_key(api_key) do
case Berrypod.Vault.encrypt(api_key) do
{:ok, encrypted} -> encrypted
_ -> nil
end
end
defp maybe_add_shop_config(params, {:ok, %{shop_id: shop_id}}) do
config = Map.get(params, "config", %{}) |> Map.put("shop_id", to_string(shop_id))
Map.put(params, "config", config)
end
defp maybe_add_shop_config(params, _), do: params
defp maybe_add_name(params, {:ok, %{shop_name: shop_name}}) when is_binary(shop_name) do
Map.put_new(params, "name", shop_name)
end
defp maybe_add_name(params, _), do: Map.put_new(params, "name", "Printify")
defp format_printify_error(:no_api_key), do: "Please enter your API token"
defp format_printify_error(:unauthorized), do: "That token doesn't seem to be valid"
defp format_printify_error(:timeout), do: "Couldn't reach Printify — try again"
defp format_printify_error({:http_error, _code}), do: "Something went wrong — try again"
defp format_printify_error(error) when is_binary(error), do: error
defp format_printify_error(_), do: "Connection failed — check your token and try again"
end

View File

@ -12,14 +12,17 @@ defmodule BerrypodWeb.Auth.Login do
<.header> <.header>
<p>Log in</p> <p>Log in</p>
<:subtitle> <:subtitle>
<%= if @current_scope do %> <%= cond do %>
<% @current_scope -> %>
You need to reauthenticate to perform sensitive actions on your account. You need to reauthenticate to perform sensitive actions on your account.
<% else %> <% @registration_open -> %>
Don't have an account? <.link Don't have an account? <.link
navigate={~p"/users/register"} navigate={~p"/setup"}
class="font-semibold text-brand hover:underline" class="font-semibold text-brand hover:underline"
phx-no-format phx-no-format
>Sign up</.link> for an account now. >Set up your shop</.link> to get started.
<% true -> %>
Log in with your admin credentials.
<% end %> <% end %>
</:subtitle> </:subtitle>
</.header> </.header>
@ -100,7 +103,8 @@ defmodule BerrypodWeb.Auth.Login do
form = to_form(%{"email" => email}, as: "user") form = to_form(%{"email" => email}, as: "user")
{:ok, assign(socket, form: form, trigger_submit: false)} {:ok,
assign(socket, form: form, trigger_submit: false, registration_open: !Accounts.has_admin?())}
end end
@impl true @impl true

View File

@ -54,8 +54,8 @@ defmodule BerrypodWeb.Auth.Registration do
|> put_flash(:error, "Registration is closed") |> put_flash(:error, "Registration is closed")
|> redirect(to: ~p"/users/log-in")} |> redirect(to: ~p"/users/log-in")}
else else
changeset = Accounts.change_user_email(%User{}, %{}, validate_unique: false) # Fresh install — account creation happens on the setup page
{:ok, assign_form(socket, changeset), temporary_assigns: [form: nil]} {:ok, redirect(socket, to: ~p"/setup")}
end end
end end

View File

@ -0,0 +1,559 @@
defmodule BerrypodWeb.Setup.Onboarding do
use BerrypodWeb, :live_view
alias Berrypod.{Accounts, Products, Settings, Setup}
alias Berrypod.Products.ProviderConnection
alias Berrypod.Providers.Provider
alias Berrypod.Stripe.Setup, as: StripeSetup
# ── Mount ──
@impl true
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
defp get_user(socket) do
case socket.assigns do
%{current_scope: %{user: user}} when not is_nil(user) -> user
_ -> nil
end
end
defp mount_setup(socket, setup) do
provider_conn = Products.get_first_provider_connection()
socket
|> assign(:page_title, "Set up your shop")
|> assign(:setup, setup)
# Account
|> assign(:account_form, to_form(%{"email" => ""}, as: :account))
|> assign(:account_submitted, false)
# Provider
|> assign(:providers, Provider.all())
|> assign(:selected_provider, nil)
|> assign(:provider_form, to_form(%{"api_key" => ""}, as: :provider))
|> assign(:provider_testing, false)
|> assign(:provider_test_result, nil)
|> assign(:provider_connecting, false)
|> assign(:provider_conn, provider_conn)
|> assign(:pending_provider_key, nil)
# Stripe
|> assign(:stripe_form, to_form(%{"api_key" => ""}, as: :stripe))
|> assign(:stripe_connecting, false)
end
# ── Events: Account ──
@impl true
def handle_event("create_account", %{"account" => %{"email" => email}}, socket) do
if email == "" do
{:noreply, put_flash(socket, :error, "Please enter your email address")}
else
case Accounts.register_user(%{email: email}) do
{:ok, user} ->
{:ok, _} =
Accounts.deliver_login_instructions(
user,
&url(~p"/users/log-in/#{&1}")
)
setup = %{socket.assigns.setup | admin_created: true}
{:noreply,
socket
|> assign(:setup, setup)
|> assign(:account_submitted, true)
|> put_flash(:info, "Check your email for a login link")}
{:error, changeset} ->
{:noreply,
socket
|> assign(:account_form, to_form(changeset, as: :account))
|> put_flash(:error, "Could not create account")}
end
end
end
# ── Events: Provider ──
def handle_event("select_provider", %{"type" => type}, socket) do
{:noreply,
socket
|> assign(:selected_provider, type)
|> assign(:provider_form, to_form(%{"api_key" => ""}, as: :provider))
|> assign(:provider_test_result, nil)
|> assign(:pending_provider_key, nil)}
end
def handle_event("validate_provider", %{"provider" => params}, socket) do
{:noreply, assign(socket, pending_provider_key: params["api_key"])}
end
def handle_event("test_provider", _params, socket) do
type = socket.assigns.selected_provider
api_key = socket.assigns.pending_provider_key
if api_key in [nil, ""] do
{:noreply, assign(socket, provider_test_result: {:error, :no_api_key})}
else
socket = assign(socket, provider_testing: true, provider_test_result: nil)
temp_conn = %ProviderConnection{
provider_type: type,
api_key_encrypted: encrypt_api_key(api_key)
}
result = Berrypod.Providers.test_connection(temp_conn)
{:noreply, assign(socket, provider_testing: false, provider_test_result: result)}
end
end
def handle_event("connect_provider", %{"provider" => %{"api_key" => api_key}}, socket) do
type = socket.assigns.selected_provider
if api_key == "" do
{:noreply, put_flash(socket, :error, "Please enter your API token")}
else
socket = assign(socket, provider_connecting: true)
params =
%{"api_key" => api_key, "provider_type" => type}
|> maybe_add_shop_config(socket.assigns.provider_test_result)
|> maybe_add_name(socket.assigns.provider_test_result, type)
case Products.create_provider_connection(params) do
{:ok, connection} ->
Products.enqueue_sync(connection)
setup = %{
socket.assigns.setup
| provider_connected: true,
provider_type: type
}
{:noreply,
socket
|> assign(:provider_connecting, false)
|> assign(:provider_conn, connection)
|> assign(:setup, setup)
|> put_flash(:info, "Connected! Product sync started in the background.")}
{:error, _changeset} ->
{:noreply,
socket
|> assign(:provider_connecting, false)
|> put_flash(:error, "Failed to save connection")}
end
end
end
# ── Events: Stripe ──
def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do
if api_key == "" do
{:noreply, put_flash(socket, :error, "Please enter your Stripe secret key")}
else
socket = assign(socket, stripe_connecting: true)
case StripeSetup.connect(api_key) do
{:ok, _result} ->
setup = %{socket.assigns.setup | stripe_connected: true}
setup =
if setup.admin_created and setup.provider_connected do
%{setup | setup_complete: true}
else
setup
end
{:noreply,
socket
|> assign(:stripe_connecting, false)
|> assign(:setup, setup)
|> put_flash(:info, "Stripe connected")}
{:error, message} ->
{:noreply,
socket
|> assign(:stripe_connecting, false)
|> put_flash(:error, "Stripe connection failed: #{message}")}
end
end
end
# ── Render ──
@impl true
def render(assigns) do
~H"""
<div class="setup-page">
<div class="setup-header">
<h1 class="setup-title">Set up your shop</h1>
<p class="setup-subtitle">Three quick steps to get everything connected.</p>
</div>
<div class="setup-sections">
<%!-- Section 1: Account --%>
<.section_card
title="Create admin account"
number={1}
done={@setup.admin_created}
summary={account_summary(assigns)}
hidden={false}
>
<.account_section
form={@account_form}
submitted={@account_submitted}
local_mail?={local_mail_adapter?()}
/>
</.section_card>
<%!-- Section 2: Provider --%>
<.section_card
title="Connect a print provider"
number={2}
done={@setup.provider_connected}
summary={provider_summary(assigns)}
hidden={false}
>
<.provider_section
providers={@providers}
selected={@selected_provider}
form={@provider_form}
testing={@provider_testing}
test_result={@provider_test_result}
connecting={@provider_connecting}
/>
</.section_card>
<%!-- Section 3: Payments --%>
<.section_card
title="Connect payments"
number={3}
done={@setup.stripe_connected}
summary={stripe_summary(assigns)}
hidden={false}
>
<.stripe_section
form={@stripe_form}
connecting={@stripe_connecting}
/>
</.section_card>
</div>
<%!-- All done --%>
<div :if={@setup.setup_complete} class="setup-complete">
<.icon name="hero-check-badge" class="setup-complete-icon" />
<h2>You're all set</h2>
<p>Head to the dashboard to sync products, customise your theme, and go live.</p>
<.link navigate={~p"/admin"} class="admin-btn admin-btn-primary">
Go to dashboard <span aria-hidden="true">&rarr;</span>
</.link>
</div>
</div>
"""
end
# ── Section card component ──
attr :title, :string, required: true
attr :number, :integer, required: true
attr :done, :boolean, required: true
attr :summary, :string, default: nil
attr :hidden, :boolean, default: false
slot :inner_block, required: true
defp section_card(assigns) do
~H"""
<div :if={!@hidden} class={["setup-card", @done && "setup-card-done"]}>
<div class="setup-card-header">
<span class={["setup-card-number", @done && "setup-card-number-done"]}>
<%= if @done do %>
<.icon name="hero-check-mini" class="size-4" />
<% else %>
{@number}
<% end %>
</span>
<h2 class="setup-card-title">{@title}</h2>
</div>
<%= if @done and @summary do %>
<p class="setup-card-summary">{@summary}</p>
<% else %>
<div :if={!@done} class="setup-card-body">
{render_slot(@inner_block)}
</div>
<% end %>
</div>
"""
end
# ── Account section ──
attr :form, :any, required: true
attr :submitted, :boolean, required: true
attr :local_mail?, :boolean, required: true
defp account_section(assigns) do
~H"""
<div :if={@submitted} class="admin-alert admin-alert-info">
<.icon name="hero-envelope" class="size-5 shrink-0" />
<div>
<p><strong>Check your email</strong></p>
<p>Click the link we sent to finish creating your account.</p>
</div>
</div>
<div :if={@local_mail? and @submitted} class="admin-alert admin-alert-info">
<.icon name="hero-information-circle" class="size-5 shrink-0" />
<div>
<p>
Using local mail adapter.
See sent emails at <a href="/dev/mailbox" class="underline">/dev/mailbox</a>.
</p>
</div>
</div>
<div :if={!@submitted}>
<p class="setup-hint">Enter your email to create the admin account. We'll send a login link.</p>
<.form for={@form} phx-submit="create_account">
<.input
field={@form[:email]}
type="email"
label="Email address"
autocomplete="email"
required
phx-mounted={JS.focus()}
/>
<div class="setup-actions">
<.button phx-disable-with="Creating account...">Create account</.button>
</div>
</.form>
</div>
"""
end
# ── Provider section ──
attr :providers, :list, required: true
attr :selected, :string, default: nil
attr :form, :any, required: true
attr :testing, :boolean, required: true
attr :test_result, :any, default: nil
attr :connecting, :boolean, required: true
defp provider_section(assigns) do
~H"""
<div>
<p class="setup-hint">Choose a print-on-demand provider and connect your API key.</p>
<div class="setup-provider-grid">
<button
:for={provider <- @providers}
type="button"
phx-click={provider.status == :available && "select_provider"}
phx-value-type={provider.type}
disabled={provider.status == :coming_soon}
class={[
"setup-provider-card",
@selected == provider.type && "setup-provider-card-selected",
provider.status == :coming_soon && "setup-provider-card-disabled"
]}
>
<span class="setup-provider-name">{provider.name}</span>
<span class="setup-provider-tagline">{provider.tagline}</span>
<span :if={provider.status == :coming_soon} class="setup-provider-badge">
Coming soon
</span>
</button>
</div>
<%!-- API key form for selected provider --%>
<div :if={@selected} class="setup-provider-form">
<% provider_info = Enum.find(@providers, &(&1.type == @selected)) %>
<p class="setup-hint">
{provider_info.setup_hint}.
<a href={provider_info.setup_url} target="_blank" rel="noopener" class="setup-link">
Open {provider_info.name} &rarr;
</a>
</p>
<.form for={@form} phx-change="validate_provider" phx-submit="connect_provider">
<.input
field={@form[:api_key]}
type="password"
label="API token"
placeholder="Paste your token here"
autocomplete="off"
/>
<div class="setup-actions">
<button
type="button"
phx-click="test_provider"
disabled={@testing}
class="admin-btn admin-btn-secondary"
>
<%= if @testing do %>
<.icon name="hero-arrow-path" class="size-4 animate-spin" /> Checking...
<% else %>
<.icon name="hero-signal" class="size-4" /> Check connection
<% end %>
</button>
<.button type="submit" disabled={@connecting or @testing}>
{if @connecting, do: "Connecting...", else: "Connect"}
</.button>
</div>
<.provider_test_feedback :if={@test_result} result={@test_result} />
</.form>
</div>
</div>
"""
end
attr :result, :any, required: true
defp provider_test_feedback(assigns) do
~H"""
<div class="setup-test-result">
<%= case @result do %>
<% {:ok, info} -> %>
<span class="setup-test-ok">
<.icon name="hero-check-circle" class="size-4" />
Connected{if info[:shop_name], do: " to #{info.shop_name}", else: ""}
</span>
<% {:error, :no_api_key} -> %>
<span class="setup-test-error">
<.icon name="hero-x-circle" class="size-4" /> Please enter your API token
</span>
<% {:error, reason} -> %>
<span class="setup-test-error">
<.icon name="hero-x-circle" class="size-4" /> {format_error(reason)}
</span>
<% end %>
</div>
"""
end
# ── Stripe section ──
attr :form, :any, required: true
attr :connecting, :boolean, required: true
defp stripe_section(assigns) do
~H"""
<div>
<p class="setup-hint">
Enter your Stripe secret key to accept payments.
<a
href="https://dashboard.stripe.com/apikeys"
target="_blank"
rel="noopener"
class="setup-link"
>
Open Stripe dashboard &rarr;
</a>
</p>
<.form for={@form} phx-submit="connect_stripe">
<.input
field={@form[:api_key]}
type="password"
label="Secret key"
autocomplete="off"
placeholder="sk_test_... or sk_live_..."
/>
<p class="setup-key-hint">
Starts with <code>sk_test_</code> or <code>sk_live_</code>. Encrypted at rest.
</p>
<div class="setup-actions">
<.button phx-disable-with="Connecting...">
{if @connecting, do: "Connecting...", else: "Connect Stripe"}
</.button>
</div>
</.form>
</div>
"""
end
# ── Helpers ──
defp account_summary(%{current_scope: %{user: user}}) when not is_nil(user) do
user.email
end
defp account_summary(_), do: "Account created"
defp provider_summary(%{setup: %{provider_type: type}}) when is_binary(type) do
case Provider.get(type) do
nil -> "Connected"
info -> "Connected to #{info.name}"
end
end
defp provider_summary(_), do: nil
defp stripe_summary(%{setup: %{stripe_connected: true}}) do
case Settings.secret_hint("stripe_api_key") do
nil -> "Connected"
hint -> "Connected · #{hint}"
end
end
defp stripe_summary(_), do: nil
defp encrypt_api_key(api_key) do
case Berrypod.Vault.encrypt(api_key) do
{:ok, encrypted} -> encrypted
_ -> nil
end
end
defp maybe_add_shop_config(params, {:ok, %{shop_id: shop_id}}) do
config = Map.get(params, "config", %{}) |> Map.put("shop_id", to_string(shop_id))
Map.put(params, "config", config)
end
defp maybe_add_shop_config(params, _), do: params
defp maybe_add_name(params, {:ok, %{shop_name: name}}, _type) when is_binary(name) do
Map.put_new(params, "name", name)
end
defp maybe_add_name(params, _, type) do
case Provider.get(type) do
nil -> Map.put_new(params, "name", type)
info -> Map.put_new(params, "name", info.name)
end
end
defp format_error(:unauthorized), do: "That token doesn't seem to be valid"
defp format_error(:timeout), do: "Couldn't reach the provider — try again"
defp format_error(:provider_not_implemented), do: "This provider isn't supported yet"
defp format_error({:http_error, _code}), do: "Something went wrong — try again"
defp format_error(error) when is_binary(error), do: error
defp format_error(_), do: "Connection failed — check your token and try again"
defp local_mail_adapter? do
Application.get_env(:berrypod, Berrypod.Mailer)[:adapter] == Swoosh.Adapters.Local
end
end

View File

@ -140,6 +140,16 @@ defmodule BerrypodWeb.Router do
end end
end end
# Setup page — minimal live_session, no theme/cart/search hooks
scope "/", BerrypodWeb do
pipe_through [:browser]
live_session :setup,
on_mount: [{BerrypodWeb.UserAuth, :mount_current_scope}] do
live "/setup", Setup.Onboarding, :index
end
end
## Authentication routes ## Authentication routes
# Admin pages with sidebar layout # Admin pages with sidebar layout
@ -153,7 +163,6 @@ defmodule BerrypodWeb.Router do
{BerrypodWeb.AdminLayoutHook, :assign_current_path} {BerrypodWeb.AdminLayoutHook, :assign_current_path}
] do ] do
live "/", Admin.Dashboard, :index live "/", Admin.Dashboard, :index
live "/setup", Admin.Setup, :index
live "/orders", Admin.Orders, :index live "/orders", Admin.Orders, :index
live "/orders/:id", Admin.OrderShow, :show live "/orders/:id", Admin.OrderShow, :show
live "/products", Admin.Products, :index live "/products", Admin.Products, :index

View File

@ -57,8 +57,8 @@ defmodule BerrypodWeb.ThemeHook do
{:cont, socket} {:cont, socket}
not Berrypod.Accounts.has_admin?() -> not Berrypod.Accounts.has_admin?() ->
# Fresh install — send to registration # Fresh install — send to setup
{:halt, Phoenix.LiveView.redirect(socket, to: "/users/register")} {:halt, Phoenix.LiveView.redirect(socket, to: "/setup")}
true -> true ->
{:halt, Phoenix.LiveView.redirect(socket, to: "/coming-soon")} {:halt, Phoenix.LiveView.redirect(socket, to: "/coming-soon")}

View File

@ -258,7 +258,7 @@ defmodule BerrypodWeb.UserAuth do
@doc "Returns the path to redirect to after log in." @doc "Returns the path to redirect to after log in."
def signed_in_path(_) do def signed_in_path(_) do
if Berrypod.Settings.site_live?(), do: ~p"/admin", else: ~p"/admin/setup" if Berrypod.Setup.setup_status().setup_complete, do: ~p"/admin", else: ~p"/setup"
end end
@doc """ @doc """

View File

@ -10,12 +10,17 @@ defmodule Berrypod.SetupTest do
status = Setup.setup_status() status = Setup.setup_status()
refute status.admin_created refute status.admin_created
refute status.printify_connected refute status.provider_connected
assert is_nil(status.provider_type)
refute status.products_synced refute status.products_synced
assert status.product_count == 0 assert status.product_count == 0
refute status.stripe_connected refute status.stripe_connected
refute status.setup_complete
refute status.site_live refute status.site_live
refute status.can_go_live refute status.can_go_live
refute status.theme_customised
refute status.has_orders
refute status.checklist_dismissed
end end
test "detects admin created" do test "detects admin created" do
@ -39,7 +44,7 @@ defmodule Berrypod.SetupTest do
assert status.site_live assert status.site_live
end end
test "detects printify connected with products" do test "detects provider connected with products" do
{:ok, conn} = {:ok, conn} =
Products.create_provider_connection(%{ Products.create_provider_connection(%{
name: "Test", name: "Test",
@ -48,7 +53,8 @@ defmodule Berrypod.SetupTest do
}) })
status = Setup.setup_status() status = Setup.setup_status()
assert status.printify_connected assert status.provider_connected
assert status.provider_type == "printify"
refute status.products_synced refute status.products_synced
assert status.product_count == 0 assert status.product_count == 0
@ -66,7 +72,24 @@ defmodule Berrypod.SetupTest do
assert status.product_count == 1 assert status.product_count == 1
end end
test "can_go_live requires printify, products, and stripe" do test "setup_complete requires admin, provider, and stripe" do
user_fixture()
{:ok, _conn} =
Products.create_provider_connection(%{
name: "Test",
provider_type: "printful",
api_key: "test_api_key"
})
refute Setup.setup_status().setup_complete
{:ok, _} = Settings.put_secret("stripe_api_key", "sk_test_abc123")
assert Setup.setup_status().setup_complete
end
test "can_go_live requires provider, products, and stripe" do
{:ok, conn} = {:ok, conn} =
Products.create_provider_connection(%{ Products.create_provider_connection(%{
name: "Test", name: "Test",
@ -90,5 +113,13 @@ defmodule Berrypod.SetupTest do
assert Setup.setup_status().can_go_live assert Setup.setup_status().can_go_live
end end
test "detects theme customised" do
refute Setup.setup_status().theme_customised
{:ok, _} = Settings.update_theme_settings(%{mood: "warm"})
assert Setup.setup_status().theme_customised
end
end end
end end

View File

@ -18,7 +18,7 @@ defmodule BerrypodWeb.UserSessionControllerTest do
}) })
assert get_session(conn, :user_token) assert get_session(conn, :user_token)
assert redirected_to(conn) == ~p"/admin/setup" assert redirected_to(conn) == ~p"/setup"
# Now do a logged in request and assert on the page content # Now do a logged in request and assert on the page content
conn = get(conn, ~p"/admin/settings") conn = get(conn, ~p"/admin/settings")
@ -39,7 +39,7 @@ defmodule BerrypodWeb.UserSessionControllerTest do
}) })
assert conn.resp_cookies["_berrypod_web_user_remember_me"] assert conn.resp_cookies["_berrypod_web_user_remember_me"]
assert redirected_to(conn) == ~p"/admin/setup" assert redirected_to(conn) == ~p"/setup"
end end
test "logs the user in with return to", %{conn: conn, user: user} do test "logs the user in with return to", %{conn: conn, user: user} do
@ -80,7 +80,7 @@ defmodule BerrypodWeb.UserSessionControllerTest do
}) })
assert get_session(conn, :user_token) assert get_session(conn, :user_token)
assert redirected_to(conn) == ~p"/admin/setup" assert redirected_to(conn) == ~p"/setup"
# Now do a logged in request and assert on the page content # Now do a logged in request and assert on the page content
conn = get(conn, ~p"/admin/settings") conn = get(conn, ~p"/admin/settings")
@ -99,7 +99,7 @@ defmodule BerrypodWeb.UserSessionControllerTest do
}) })
assert get_session(conn, :user_token) assert get_session(conn, :user_token)
assert redirected_to(conn) == ~p"/admin/setup" assert redirected_to(conn) == ~p"/setup"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "User confirmed successfully." assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "User confirmed successfully."
assert Accounts.get_user!(user.id).confirmed_at assert Accounts.get_user!(user.id).confirmed_at

View File

@ -18,14 +18,61 @@ defmodule BerrypodWeb.Admin.DashboardTest do
end end
end end
describe "redirects to setup when not live" do describe "launch checklist" do
setup %{conn: conn, user: user} do setup %{conn: conn, user: user} do
%{conn: log_in_user(conn, user)} %{conn: log_in_user(conn, user)}
end end
test "redirects to /admin/setup when site not live", %{conn: conn} do test "shows checklist when site not live", %{conn: conn} do
{:error, redirect} = live(conn, ~p"/admin") {:ok, _view, html} = live(conn, ~p"/admin")
assert {:live_redirect, %{to: "/admin/setup"}} = redirect
assert html =~ "Launch checklist"
assert html =~ "Sync your products"
assert html =~ "Customise your theme"
assert html =~ "Go live"
end
test "hides checklist when site is live", %{conn: conn} do
{:ok, _} = Berrypod.Settings.set_site_live(true)
{:ok, _view, html} = live(conn, ~p"/admin")
refute html =~ "Launch checklist"
end
test "dismiss checklist hides it", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin")
assert has_element?(view, "button", "Dismiss")
html = render_click(view, "dismiss_checklist")
refute html =~ "Launch checklist"
end
test "go live button works", %{conn: conn} do
# Need provider + products + stripe for go live to be enabled
{:ok, conn_record} =
Berrypod.Products.create_provider_connection(%{
name: "Test",
provider_type: "printify",
api_key: "test_key"
})
{:ok, _} =
Berrypod.Products.create_product(%{
title: "Test product",
provider_product_id: "ext-1",
provider_connection_id: conn_record.id,
status: "active"
})
{:ok, _} = Berrypod.Settings.put_secret("stripe_api_key", "sk_test_123")
{:ok, view, _html} = live(conn, ~p"/admin")
html = render_click(view, "go_live")
assert html =~ "Your shop is live"
assert Berrypod.Settings.site_live?()
end end
end end

View File

@ -30,13 +30,6 @@ defmodule BerrypodWeb.Admin.LayoutTest do
refute has_element?(view, ~s(a.active[href="/admin/settings"])) refute has_element?(view, ~s(a.active[href="/admin/settings"]))
end end
test "highlights setup on setup page", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/setup")
assert has_element?(view, ~s(a.active[href="/admin/setup"]))
refute has_element?(view, ~s(a.active[href="/admin/orders"]))
end
test "highlights correct link on different pages", %{conn: conn} do test "highlights correct link on different pages", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/settings") {:ok, view, _html} = live(conn, ~p"/admin/settings")

View File

@ -1,88 +0,0 @@
defmodule BerrypodWeb.Admin.SetupTest do
use BerrypodWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import Berrypod.AccountsFixtures
import Berrypod.ProductsFixtures
setup do
user = user_fixture()
%{user: user}
end
describe "unauthenticated" do
test "redirects to login", %{conn: conn} do
{:error, redirect} = live(conn, ~p"/admin/setup")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
end
describe "setup stepper" do
setup %{conn: conn, user: user} do
%{conn: log_in_user(conn, user)}
end
test "shows stepper with printify form when nothing connected", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/setup")
assert html =~ "Setup steps"
assert html =~ "Connect to Printify"
assert html =~ "Printify API token"
assert html =~ "Connect Stripe"
assert html =~ "Go live"
end
test "shows stripe form when printify is done", %{conn: conn} do
conn_fixture = provider_connection_fixture(%{provider_type: "printify"})
_product = product_fixture(%{provider_connection: conn_fixture})
{:ok, view, _html} = live(conn, ~p"/admin/setup")
# Printify step should be completed
assert has_element?(view, "li:first-child [class*='bg-green-500']")
# Stripe step should be active with form
assert has_element?(view, "label", "Secret key")
end
test "shows go live button when all services connected", %{conn: conn} do
conn_fixture = provider_connection_fixture(%{provider_type: "printify"})
_product = product_fixture(%{provider_connection: conn_fixture})
{:ok, _} = Berrypod.Settings.put_secret("stripe_api_key", "sk_test_123")
{:ok, view, _html} = live(conn, ~p"/admin/setup")
assert has_element?(view, "button", "Go live")
end
test "go live shows celebration", %{conn: conn} do
conn_fixture = provider_connection_fixture(%{provider_type: "printify"})
_product = product_fixture(%{provider_connection: conn_fixture})
{:ok, _} = Berrypod.Settings.put_secret("stripe_api_key", "sk_test_123")
{:ok, view, _html} = live(conn, ~p"/admin/setup")
html = view |> element("button", "Go live") |> render_click()
assert html =~ "Your shop is live!"
assert html =~ "Go to dashboard"
assert html =~ "View your shop"
assert html =~ "Customise theme"
end
test "redirects to /admin when site is live", %{conn: conn} do
{:ok, _} = Berrypod.Settings.set_site_live(true)
{:error, redirect} = live(conn, ~p"/admin/setup")
assert {:live_redirect, %{to: "/admin"}} = redirect
end
test "completed steps show summary and are collapsible", %{conn: conn} do
conn_fixture = provider_connection_fixture(%{provider_type: "printify"})
_product = product_fixture(%{provider_connection: conn_fixture})
{:ok, _view, html} = live(conn, ~p"/admin/setup")
assert html =~ "products synced"
end
end
end

View File

@ -64,7 +64,7 @@ defmodule BerrypodWeb.Auth.ConfirmationTest do
assert Accounts.get_user!(user.id).confirmed_at assert Accounts.get_user!(user.id).confirmed_at
# we are logged in now # we are logged in now
assert get_session(conn, :user_token) assert get_session(conn, :user_token)
assert redirected_to(conn) == ~p"/admin/setup" assert redirected_to(conn) == ~p"/setup"
# log out, new conn # log out, new conn
conn = build_conn() conn = build_conn()

View File

@ -9,7 +9,7 @@ defmodule BerrypodWeb.Auth.LoginTest do
{:ok, _lv, html} = live(conn, ~p"/users/log-in") {:ok, _lv, html} = live(conn, ~p"/users/log-in")
assert html =~ "Log in" assert html =~ "Log in"
assert html =~ "Sign up" assert html =~ "Set up your shop"
assert html =~ "Log in with email" assert html =~ "Log in with email"
end end
end end
@ -56,7 +56,7 @@ defmodule BerrypodWeb.Auth.LoginTest do
conn = submit_form(form, conn) conn = submit_form(form, conn)
assert redirected_to(conn) == ~p"/admin/setup" assert redirected_to(conn) == ~p"/setup"
end end
test "redirects to login page with a flash error if credentials are invalid", %{ test "redirects to login page with a flash error if credentials are invalid", %{
@ -76,16 +76,16 @@ defmodule BerrypodWeb.Auth.LoginTest do
end end
describe "login navigation" do describe "login navigation" do
test "redirects to registration page when the Register button is clicked", %{conn: conn} do test "redirects to setup page when the setup link is clicked", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/log-in") {:ok, lv, _html} = live(conn, ~p"/users/log-in")
{:ok, _login_live, login_html} = {:ok, _setup_live, setup_html} =
lv lv
|> element("main a", "Sign up") |> element("main a", "Set up your shop")
|> render_click() |> render_click()
|> follow_redirect(conn, ~p"/users/register") |> follow_redirect(conn, ~p"/setup")
assert login_html =~ "Register" assert setup_html =~ "Set up your shop"
end end
end end

View File

@ -5,11 +5,8 @@ defmodule BerrypodWeb.Auth.RegistrationTest do
import Berrypod.AccountsFixtures import Berrypod.AccountsFixtures
describe "Registration page" do describe "Registration page" do
test "renders registration page when no admin exists", %{conn: conn} do test "redirects to setup when no admin exists (fresh install)", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/users/register") assert {:error, {:redirect, %{to: "/setup"}}} = live(conn, ~p"/users/register")
assert html =~ "Register"
assert html =~ "Log in"
end end
test "redirects to login when admin already exists", %{conn: conn} do test "redirects to login when admin already exists", %{conn: conn} do
@ -25,66 +22,9 @@ defmodule BerrypodWeb.Auth.RegistrationTest do
conn conn
|> log_in_user(user_fixture()) |> log_in_user(user_fixture())
|> live(~p"/users/register") |> live(~p"/users/register")
|> follow_redirect(conn, ~p"/admin/setup") |> follow_redirect(conn, ~p"/setup")
assert {:ok, _conn} = result assert {:ok, _conn} = result
end end
test "renders errors for invalid data", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/register")
result =
lv
|> element("#registration_form")
|> render_change(user: %{"email" => "with spaces"})
assert result =~ "Register"
assert result =~ "must have the @ sign and no spaces"
end
end
describe "register user" do
test "creates account but does not log in", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/register")
email = unique_user_email()
form = form(lv, "#registration_form", user: valid_user_attributes(email: email))
{:ok, _lv, html} =
render_submit(form)
|> follow_redirect(conn, ~p"/users/log-in")
assert html =~
~r/An email was sent to .*, please access it to confirm your account/
end
test "renders errors for duplicated email", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/register")
user = user_fixture(%{email: "test@email.com"})
result =
lv
|> form("#registration_form",
user: %{"email" => user.email}
)
|> render_submit()
assert result =~ "has already been taken"
end
end
describe "registration navigation" do
test "redirects to login page when the Log in button is clicked", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/register")
{:ok, _login_live, login_html} =
lv
|> element("main a", "Log in")
|> render_click()
|> follow_redirect(conn, ~p"/users/log-in")
assert login_html =~ "Log in"
end
end end
end end

View File

@ -0,0 +1,116 @@
defmodule BerrypodWeb.Setup.OnboardingTest do
use BerrypodWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import Berrypod.AccountsFixtures
alias Berrypod.{Products, Settings}
describe "access rules" do
test "accessible on fresh install (no admin)", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/setup")
assert html =~ "Set up your shop"
assert html =~ "Create admin account"
end
test "redirects to /admin when setup is complete", %{conn: conn} do
user = user_fixture()
conn = log_in_user(conn, user)
{:ok, _} =
Products.create_provider_connection(%{
name: "Test",
provider_type: "printify",
api_key: "test_key"
})
{:ok, _} = Settings.put_secret("stripe_api_key", "sk_test_123")
{:error, redirect} = live(conn, ~p"/setup")
assert {:live_redirect, %{to: "/admin"}} = redirect
end
test "redirects to login when admin exists but not logged in", %{conn: conn} do
_user = user_fixture()
{:error, redirect} = live(conn, ~p"/setup")
assert {:live_redirect, %{to: "/users/log-in"}} = redirect
end
test "redirects to / when site is already live", %{conn: conn} do
user = user_fixture()
conn = log_in_user(conn, user)
{:ok, _} =
Products.create_provider_connection(%{
name: "Test",
provider_type: "printify",
api_key: "test_key"
})
{:ok, _} = Settings.put_secret("stripe_api_key", "sk_test_123")
{:ok, _} = Settings.set_site_live(true)
{:error, redirect} = live(conn, ~p"/setup")
assert {:live_redirect, %{to: "/"}} = redirect
end
end
describe "sections" do
test "shows all three sections on fresh install", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/setup")
assert html =~ "Create admin account"
assert html =~ "Connect a print provider"
assert html =~ "Connect payments"
end
test "shows provider cards", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/setup")
assert html =~ "Printify"
assert html =~ "Printful"
assert html =~ "Gelato"
assert html =~ "Coming soon"
end
test "selecting a provider shows the API key form", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/setup")
html =
view
|> element(~s(button[phx-value-type="printify"]))
|> render_click()
assert html =~ "API token"
assert html =~ "Printify"
end
end
describe "stripe section" do
test "shows stripe form", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/setup")
assert html =~ "Secret key"
assert html =~ "Connect Stripe"
end
end
describe "completion" do
setup :register_and_log_in_user
test "redirects to dashboard when all three steps done", %{conn: conn} do
{:ok, _} =
Products.create_provider_connection(%{
name: "Test",
provider_type: "printify",
api_key: "test_key"
})
{:ok, _} = Settings.put_secret("stripe_api_key", "sk_test_123")
{:error, redirect} = live(conn, ~p"/setup")
assert {:live_redirect, %{to: "/admin"}} = redirect
end
end
end

View File

@ -51,9 +51,9 @@ defmodule BerrypodWeb.Shop.ComingSoonTest do
assert html =~ "Shop the collection" assert html =~ "Shop the collection"
end end
test "redirects to registration on fresh install (no admin)", %{conn: conn} do test "redirects to setup on fresh install (no admin)", %{conn: conn} do
# No admin created — redirect to registration # No admin created — redirect to setup
assert {:error, {:redirect, %{to: "/users/register"}}} = live(conn, ~p"/") assert {:error, {:redirect, %{to: "/setup"}}} = live(conn, ~p"/")
end end
test "redirects when session token is stale (user deleted)", %{conn: conn} do test "redirects when session token is stale (user deleted)", %{conn: conn} do
@ -63,7 +63,7 @@ defmodule BerrypodWeb.Shop.ComingSoonTest do
# Delete the user — session cookie is now stale # Delete the user — session cookie is now stale
Berrypod.Repo.delete!(user) Berrypod.Repo.delete!(user)
assert {:error, {:redirect, %{to: "/users/register"}}} = live(conn, ~p"/") assert {:error, {:redirect, %{to: "/setup"}}} = live(conn, ~p"/")
end end
test "gates all public shop routes", %{conn: conn} do test "gates all public shop routes", %{conn: conn} do

View File

@ -25,7 +25,7 @@ defmodule BerrypodWeb.UserAuthTest do
conn = UserAuth.log_in_user(conn, user) conn = UserAuth.log_in_user(conn, user)
assert token = get_session(conn, :user_token) assert token = get_session(conn, :user_token)
assert get_session(conn, :live_socket_id) == "users_sessions:#{Base.url_encode64(token)}" assert get_session(conn, :live_socket_id) == "users_sessions:#{Base.url_encode64(token)}"
assert redirected_to(conn) == ~p"/admin/setup" assert redirected_to(conn) == ~p"/setup"
assert Accounts.get_user_by_session_token(token) assert Accounts.get_user_by_session_token(token)
end end
@ -80,7 +80,7 @@ defmodule BerrypodWeb.UserAuthTest do
|> assign(:current_scope, Scope.for_user(user)) |> assign(:current_scope, Scope.for_user(user))
|> UserAuth.log_in_user(user) |> UserAuth.log_in_user(user)
assert redirected_to(conn) == ~p"/admin/setup" assert redirected_to(conn) == ~p"/setup"
end end
test "writes a cookie if remember_me was set in previous session", %{conn: conn, user: user} do test "writes a cookie if remember_me was set in previous session", %{conn: conn, user: user} do