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:
parent
989c5cd4df
commit
c2caeed64d
19
PROGRESS.md
19
PROGRESS.md
@ -21,13 +21,13 @@
|
||||
- Transactional emails (order confirmation, shipping notification)
|
||||
- Demo content polished and ready for production
|
||||
|
||||
**Tier 1 MVP complete.** CI pipeline done. Hosting & deployment done (including observability). PageSpeed CI done (99-100 mobile, 97+ desktop). Usability fixes done. Shipping costs at checkout done. Per-colour product images with gallery filtering done (both providers). Printful integration complete (sync, orders, shipping, webhooks, mockup enrichment, catalog colours). CSS migration Phases 0-7 complete — project is fully Tailwind-free (hand-written CSS, 9.8 KB gzipped shop, 17.8 KB gzipped admin).
|
||||
**Tier 1 MVP complete.** CI pipeline done. Hosting & deployment done (including observability). PageSpeed CI done (99-100 mobile, 97+ desktop). Usability fixes done. Shipping costs at checkout done. Per-colour product images with gallery filtering done (both providers). Printful integration complete (sync, orders, shipping, webhooks, mockup enrichment, catalog colours). CSS migration Phases 0-7 complete — project is fully Tailwind-free (hand-written CSS, 9.8 KB gzipped shop, 17.8 KB gzipped admin). Setup and launch readiness complete — `/setup` onboarding page, dashboard launch checklist, provider registry, provider-agnostic setup status.
|
||||
|
||||
## Task list
|
||||
|
||||
Ordered by dependency level — admin shell chain first (unblocks most downstream work).
|
||||
|
||||
Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [setup-wizard.md](docs/plans/setup-wizard.md) | [search.md](docs/plans/search.md) | [products-refactor.md](/home/jamey/.claude/plans/snug-roaming-zebra.md) | [shipping-sync.md](docs/plans/shipping-sync.md) | [printful-integration.md](docs/plans/printful-integration.md) | [provider-strategy.md](docs/plans/provider-strategy.md) | [css-migration.md](docs/plans/css-migration.md)
|
||||
Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [setup-wizard.md](docs/plans/setup-wizard.md) | [setup-and-launch.md](docs/plans/setup-and-launch.md) | [search.md](docs/plans/search.md) | [products-refactor.md](/home/jamey/.claude/plans/snug-roaming-zebra.md) | [shipping-sync.md](docs/plans/shipping-sync.md) | [printful-integration.md](docs/plans/printful-integration.md) | [provider-strategy.md](docs/plans/provider-strategy.md) | [css-migration.md](docs/plans/css-migration.md)
|
||||
|
||||
| # | Task | Depends on | Est | Status |
|
||||
|---|------|------------|-----|--------|
|
||||
@ -58,8 +58,17 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [setup-wizard.md](doc
|
||||
| ~~28~~ | ~~Printful mockup generation worker~~ | 25 | — | done (existing pipeline) |
|
||||
| ~~29~~ | ~~Printful webhooks~~ | 25 | 1.5h | done |
|
||||
| | **Next up** | | | |
|
||||
| 30 | Admin UI tweaks for Printful | 25 | 1h | |
|
||||
| ~~30~~ | ~~Admin UI tweaks for Printful~~ | 25 | 30m | done |
|
||||
| 31 | Printful tests + integration testing | 24-30 | 4.5h | |
|
||||
| | **Setup and launch readiness** ([plan](docs/plans/setup-and-launch.md)) | | | |
|
||||
| ~~41~~ | ~~Provider + payment registries~~ | — | 30m | done |
|
||||
| ~~42~~ | ~~Make Setup provider-agnostic + add checklist fields~~ | 41 | 45m | done |
|
||||
| ~~43~~ | ~~Setup LiveView (`/setup`) — account, provider, payments~~ | 41, 42 | 2.5h | done |
|
||||
| ~~44~~ | ~~Dashboard launch checklist component + go-live~~ | 42 | 2h | done |
|
||||
| ~~45~~ | ~~Router, auth flow, redirects~~ | 43, 44 | 30m | done |
|
||||
| ~~46~~ | ~~CSS additions (~200 lines)~~ | 43, 44 | 20m | done |
|
||||
| ~~47~~ | ~~Tests (setup, dashboard checklist, auth flow)~~ | 43-46 | 2h | done |
|
||||
| ~~48~~ | ~~Remove old `/admin/setup`~~ | 43-47 | 15m | done |
|
||||
| | **CSS migration — Tailwind + DaisyUI to modern CSS** | | | |
|
||||
| ~~32~~ | ~~Phase 0: Foundation + screenshot tooling~~ | 30-31 | 1.5h | done |
|
||||
| ~~33~~ | ~~Phase 1: Layout primitives + reset~~ | 32 | 1.5h | done |
|
||||
@ -71,7 +80,7 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [setup-wizard.md](doc
|
||||
| ~~39~~ | ~~Phase 7: Remove Tailwind entirely~~ | 38 | 1.5h | done |
|
||||
| 40 | Phase 8: Optimisation + modern enhancements | 39 | 2.5h | |
|
||||
|
||||
**Total remaining: ~8-9 hours across ~5 sessions** (admin Printful tweaks, Printful tests, CSS optimisation)
|
||||
**Total remaining: ~7 hours across ~3-4 sessions** (Printful tests, CSS optimisation)
|
||||
|
||||
See [css-migration.md](docs/plans/css-migration.md) for full plan with architecture, visual regression testing strategy, and acceptance criteria per phase.
|
||||
|
||||
@ -110,7 +119,7 @@ Issues from hands-on testing of the deployed prod site (Feb 2025). 16 of 18 comp
|
||||
|
||||
### Tier 5 — Platform vision
|
||||
|
||||
16. **Hosted platform** — Marketing/brochure site for Berrypod as a service. Subscribe/sign-up flow. Multi-tenancy with per-tenant databases. Stripe Connect for customer shops (each merchant connects their own Stripe account via OAuth).
|
||||
16. **Hosted platform** — Marketing/brochure site for Berrypod as a service. Subscribe/sign-up flow. Multi-tenancy with per-tenant databases. **OAuth connect flows** for providers and payments: register as a Printify/Printful OAuth app so hosted users get a one-click "Connect with Printify" button on the setup page; use Stripe Connect so merchants authorise via OAuth redirect instead of pasting API keys. The setup UI already supports this — check for OAuth credentials and show the connect button when available, fall back to the API key form on self-hosted installs where no OAuth app is configured. Provider metadata (`connect_mode: :oauth | :api_key`) drives which form renders.
|
||||
17. **Migration & export** — Let shop owners export their data (products, orders, customers, theme settings). Import from other platforms (Shopify, WooCommerce). Portable data as a selling point for the self-hosted story.
|
||||
18. **Internationalisation (i18n)** — Multi-language support via Gettext (already in Phoenix). Currency formatting. RTL layout support. Per-shop locale configuration. **Note:** `ex_money`/`ex_cldr` are currently used *only* for `Cart.format_price/1` (a single GBP formatting call) but add ~13 MB to the release (ex_cldr 9.5 MB, digital_token 3.7 MB, ex_cldr_numbers, ex_cldr_currencies). Consider replacing with a simple `format_price/2` function that handles GBP/EUR/USD directly — all three use 2 decimal places and are trivial to format. Re-add `ex_money` later if proper locale-aware number formatting is needed (e.g., German `12.345,67 €`).
|
||||
|
||||
|
||||
@ -639,3 +639,330 @@
|
||||
|
||||
[data-theme="light"] .theme-toggle-indicator { left: 33.333333%; }
|
||||
[data-theme="dark"] .theme-toggle-indicator { left: 66.666667%; }
|
||||
|
||||
/* ── Dashboard stats grid ── */
|
||||
|
||||
.admin-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(1, 1fr);
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.admin-stats-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Setup page ── */
|
||||
|
||||
.setup-page {
|
||||
max-width: 36rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.setup-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.setup-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.setup-subtitle {
|
||||
color: var(--color-base-content-60, rgba(0 0 0 / 0.6));
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.setup-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.setup-card {
|
||||
border: 1px solid var(--color-base-300, #d4d4d4);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.25rem;
|
||||
transition: border-color 150ms;
|
||||
}
|
||||
|
||||
.setup-card-done {
|
||||
border-color: var(--color-success, #22c55e);
|
||||
background: color-mix(in oklab, var(--color-success, #22c55e) 5%, transparent);
|
||||
}
|
||||
|
||||
.setup-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.setup-card-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 50%;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
background: var(--color-base-200, #e5e5e5);
|
||||
color: var(--color-base-content-60, rgba(0 0 0 / 0.6));
|
||||
}
|
||||
|
||||
.setup-card-number-done {
|
||||
background: var(--color-success, #22c55e);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.setup-card-title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.setup-card-summary {
|
||||
margin-top: 0.25rem;
|
||||
padding-left: 2.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-base-content-60, rgba(0 0 0 / 0.6));
|
||||
}
|
||||
|
||||
.setup-card-body {
|
||||
margin-top: 1rem;
|
||||
padding-left: 2.5rem;
|
||||
}
|
||||
|
||||
.setup-hint {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-base-content-60, rgba(0 0 0 / 0.6));
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.setup-link {
|
||||
color: var(--color-base-content, #171717);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.setup-key-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-base-content-60, rgba(0 0 0 / 0.6));
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.setup-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.setup-test-result {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.setup-test-ok {
|
||||
color: var(--color-success, #22c55e);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.setup-test-error {
|
||||
color: var(--color-error, #dc2626);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Provider picker grid */
|
||||
.setup-provider-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.setup-provider-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.125rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--color-base-300, #d4d4d4);
|
||||
border-radius: 0.375rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 150ms, background 150ms;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--color-base-content, #171717);
|
||||
}
|
||||
}
|
||||
|
||||
.setup-provider-card-selected {
|
||||
border-color: var(--color-base-content, #171717);
|
||||
background: var(--color-base-200, #e5e5e5);
|
||||
}
|
||||
|
||||
.setup-provider-card-disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.setup-provider-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.setup-provider-tagline {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-base-content-60, rgba(0 0 0 / 0.6));
|
||||
}
|
||||
|
||||
.setup-provider-badge {
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-base-200, #e5e5e5);
|
||||
color: var(--color-base-content-60, rgba(0 0 0 / 0.6));
|
||||
}
|
||||
|
||||
.setup-provider-form {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
/* Setup complete card */
|
||||
.setup-complete {
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
padding: 2rem;
|
||||
border: 1px solid var(--color-success, #22c55e);
|
||||
border-radius: 0.5rem;
|
||||
background: color-mix(in oklab, var(--color-success, #22c55e) 5%, transparent);
|
||||
}
|
||||
|
||||
.setup-complete-icon {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
color: var(--color-success, #22c55e);
|
||||
margin: 0 auto 0.75rem;
|
||||
}
|
||||
|
||||
.setup-complete h2 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.setup-complete p {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-base-content-60, rgba(0 0 0 / 0.6));
|
||||
margin: 0.25rem 0 1rem;
|
||||
}
|
||||
|
||||
/* ── Dashboard launch checklist ── */
|
||||
|
||||
.admin-checklist {
|
||||
border: 1px solid var(--color-base-300, #d4d4d4);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.admin-checklist-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.admin-checklist-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-checklist-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-base-content-60, rgba(0 0 0 / 0.6));
|
||||
}
|
||||
|
||||
.admin-checklist-bar {
|
||||
width: 6rem;
|
||||
height: 0.375rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-base-200, #e5e5e5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-checklist-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-success, #22c55e);
|
||||
transition: width 300ms;
|
||||
}
|
||||
|
||||
.admin-checklist-items {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.admin-checklist-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.admin-checklist-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
border: 1.5px solid var(--color-base-300, #d4d4d4);
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.admin-checklist-check-done {
|
||||
background: var(--color-success, #22c55e);
|
||||
border-color: var(--color-success, #22c55e);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.admin-checklist-label {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.admin-checklist-label-done {
|
||||
color: var(--color-base-content-60, rgba(0 0 0 / 0.6));
|
||||
}
|
||||
|
||||
.admin-checklist-action {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.admin-checklist-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--color-base-200, #e5e5e5);
|
||||
}
|
||||
|
||||
353
docs/plans/setup-and-launch.md
Normal file
353
docs/plans/setup-and-launch.md
Normal 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")
|
||||
@ -50,6 +50,16 @@ defmodule Berrypod.Orders do
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true if at least one paid order exists.
|
||||
"""
|
||||
def has_paid_orders? do
|
||||
Order
|
||||
|> where(payment_status: "paid")
|
||||
|> limit(1)
|
||||
|> Repo.exists?()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns total revenue (in minor units) from paid orders.
|
||||
"""
|
||||
|
||||
28
lib/berrypod/payments/registry.ex
Normal file
28
lib/berrypod/payments/registry.ex
Normal 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
|
||||
@ -42,6 +42,19 @@ defmodule Berrypod.Products do
|
||||
Repo.get_by(ProviderConnection, provider_type: provider_type)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the first provider connection with an API key, or nil.
|
||||
|
||||
Provider-agnostic — doesn't care which type. Ordered by creation date.
|
||||
"""
|
||||
def get_first_provider_connection do
|
||||
ProviderConnection
|
||||
|> where([c], not is_nil(c.api_key_encrypted))
|
||||
|> order_by(:inserted_at)
|
||||
|> limit(1)
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a provider connection.
|
||||
"""
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
defmodule Berrypod.Providers.Provider do
|
||||
@moduledoc """
|
||||
Behaviour for POD provider integrations.
|
||||
Behaviour and registry for POD provider integrations.
|
||||
|
||||
Each provider (Printify, Gelato, Prodigi, etc.) implements this behaviour
|
||||
Each provider (Printify, Printful, etc.) implements this behaviour
|
||||
to provide a consistent interface for:
|
||||
|
||||
- Testing connections
|
||||
@ -10,6 +10,10 @@ defmodule Berrypod.Providers.Provider do
|
||||
- Submitting orders
|
||||
- Tracking order status
|
||||
|
||||
The `@providers` list is the single source of truth for available providers.
|
||||
Both the module dispatch (`for_type/1`) and UI metadata (`all/0`, `get/1`)
|
||||
are derived from it.
|
||||
|
||||
## Data Normalization
|
||||
|
||||
Providers return normalized data structures:
|
||||
@ -23,47 +27,82 @@ defmodule Berrypod.Providers.Provider do
|
||||
|
||||
alias Berrypod.Products.ProviderConnection
|
||||
|
||||
@doc """
|
||||
Returns the provider type identifier (e.g., "printify", "gelato").
|
||||
"""
|
||||
# Single source of truth for all providers — module dispatch and UI metadata.
|
||||
# Add new providers here. Set module: nil for coming-soon entries.
|
||||
@providers [
|
||||
%{
|
||||
type: "printify",
|
||||
module: Berrypod.Providers.Printify,
|
||||
name: "Printify",
|
||||
tagline: "Largest catalog — 800+ products from 90+ print providers",
|
||||
features: ["Biggest product selection", "Multi-provider routing", "Competitive pricing"],
|
||||
setup_hint: "Get your API token from Printify → Account → Connections",
|
||||
setup_url: "https://printify.com/app/account/connections",
|
||||
login_url: "https://printify.com/app/auth/login",
|
||||
signup_url: "https://printify.com/app/auth/register",
|
||||
setup_steps: [
|
||||
~s[Click <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> → <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()
|
||||
|
||||
@doc """
|
||||
Tests the connection to the provider.
|
||||
|
||||
Returns `{:ok, info}` with provider-specific info (e.g., shop name)
|
||||
or `{:error, reason}` if the connection fails.
|
||||
"""
|
||||
@callback test_connection(ProviderConnection.t()) :: {:ok, map()} | {:error, term()}
|
||||
|
||||
@doc """
|
||||
Fetches all products from the provider.
|
||||
|
||||
Returns a list of normalized product maps.
|
||||
"""
|
||||
@callback fetch_products(ProviderConnection.t()) :: {:ok, [map()]} | {:error, term()}
|
||||
|
||||
@doc """
|
||||
Submits an order to the provider for fulfillment.
|
||||
|
||||
Returns `{:ok, %{provider_order_id: String.t()}}` on success.
|
||||
"""
|
||||
@callback submit_order(ProviderConnection.t(), order :: map()) ::
|
||||
{:ok, %{provider_order_id: String.t()}} | {:error, term()}
|
||||
|
||||
@doc """
|
||||
Gets the current status of an order from the provider.
|
||||
"""
|
||||
@callback get_order_status(ProviderConnection.t(), provider_order_id :: String.t()) ::
|
||||
{:ok, map()} | {:error, term()}
|
||||
|
||||
@doc """
|
||||
Fetches shipping rates from the provider for the given products.
|
||||
|
||||
Takes the connection and the already-fetched product list (from fetch_products).
|
||||
Returns normalized rate maps with keys: blueprint_id, print_provider_id,
|
||||
country_code, first_item_cost, additional_item_cost, currency, handling_time_days.
|
||||
|
||||
Optional — providers that don't support shipping rate lookup can skip this.
|
||||
The sync worker checks `function_exported?/3` before calling.
|
||||
"""
|
||||
@ -72,11 +111,24 @@ defmodule Berrypod.Providers.Provider do
|
||||
|
||||
@optional_callbacks [fetch_shipping_rates: 2]
|
||||
|
||||
# ── Registry ──
|
||||
|
||||
@doc "Returns all providers (available and coming soon)."
|
||||
def all, do: @providers
|
||||
|
||||
@doc "Returns only providers with `:available` status."
|
||||
def available, do: Enum.filter(@providers, &(&1.status == :available))
|
||||
|
||||
@doc "Returns a provider metadata map by type string, or nil."
|
||||
def get(type), do: Enum.find(@providers, &(&1.type == type))
|
||||
|
||||
# ── Module dispatch ──
|
||||
|
||||
@doc """
|
||||
Returns the provider module for a given provider type.
|
||||
|
||||
Checks `:provider_modules` application config first, allowing test
|
||||
overrides via Mox. Falls back to hardcoded dispatch.
|
||||
overrides via Mox. Falls back to the `@providers` list.
|
||||
"""
|
||||
def for_type(type) do
|
||||
case Application.get_env(:berrypod, :provider_modules, %{}) do
|
||||
@ -91,11 +143,13 @@ defmodule Berrypod.Providers.Provider do
|
||||
end
|
||||
end
|
||||
|
||||
defp default_for_type("printify"), do: {:ok, Berrypod.Providers.Printify}
|
||||
defp default_for_type("gelato"), do: {:error, :not_implemented}
|
||||
defp default_for_type("prodigi"), do: {:error, :not_implemented}
|
||||
defp default_for_type("printful"), do: {:ok, Berrypod.Providers.Printful}
|
||||
defp default_for_type(type), do: {:error, {:unknown_provider, type}}
|
||||
defp default_for_type(type) do
|
||||
case Map.fetch(@type_to_module, type) do
|
||||
{:ok, nil} -> {:error, :not_implemented}
|
||||
{:ok, module} -> {:ok, module}
|
||||
:error -> {:error, {:unknown_provider, type}}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the provider module for a provider connection.
|
||||
|
||||
@ -84,6 +84,7 @@ defmodule Berrypod.Settings do
|
||||
settings = Ecto.Changeset.apply_changes(changeset)
|
||||
json = Jason.encode!(settings)
|
||||
put_setting("theme_settings", json, "json")
|
||||
put_setting("theme_customised", true, "boolean")
|
||||
|
||||
# Invalidate and rewarm CSS cache
|
||||
alias Berrypod.Theme.{CSSCache, CSSGenerator}
|
||||
|
||||
@ -1,33 +1,58 @@
|
||||
defmodule Berrypod.Setup do
|
||||
@moduledoc """
|
||||
Aggregates setup status checks for the admin setup flow.
|
||||
Aggregates setup status checks for the setup flow and launch checklist.
|
||||
"""
|
||||
|
||||
alias Berrypod.{Accounts, Products, Settings}
|
||||
alias Berrypod.{Accounts, Orders, Products, Settings}
|
||||
|
||||
@doc """
|
||||
Returns a map describing the current setup status.
|
||||
|
||||
Used by the admin setup checklist and ThemeHook gate to determine
|
||||
what's been completed and whether the shop can go live.
|
||||
Used by the setup page, dashboard launch checklist, and ThemeHook gate.
|
||||
|
||||
## Setup phase (connections)
|
||||
|
||||
* `admin_created` — at least one user exists
|
||||
* `provider_connected` — a provider connection with an API key exists
|
||||
* `provider_type` — the connected provider's type (e.g. "printify"), or nil
|
||||
* `stripe_connected` — Stripe API key is stored
|
||||
* `setup_complete` — all three connections made
|
||||
|
||||
## Launch checklist phase
|
||||
|
||||
* `products_synced` / `product_count` — products imported
|
||||
* `theme_customised` — theme settings have been saved at least once
|
||||
* `has_orders` — at least one paid order exists
|
||||
* `site_live` — shop is open to the public
|
||||
* `can_go_live` — minimum requirements met to go live
|
||||
* `checklist_dismissed` — admin has dismissed the launch checklist
|
||||
"""
|
||||
def setup_status do
|
||||
conn = Products.get_provider_connection_by_type("printify")
|
||||
product_count = Products.count_products_for_connection(conn && conn.id)
|
||||
conn = Products.get_first_provider_connection()
|
||||
product_count = Products.count_products()
|
||||
|
||||
printify_connected = conn != nil and conn.api_key_encrypted != nil
|
||||
provider_connected = conn != nil
|
||||
products_synced = product_count > 0
|
||||
stripe_connected = Settings.has_secret?("stripe_api_key")
|
||||
admin_created = Accounts.has_admin?()
|
||||
site_live = Settings.site_live?()
|
||||
|
||||
%{
|
||||
admin_created: Accounts.has_admin?(),
|
||||
printify_connected: printify_connected,
|
||||
# Setup phase
|
||||
admin_created: admin_created,
|
||||
provider_connected: provider_connected,
|
||||
provider_type: conn && conn.provider_type,
|
||||
stripe_connected: stripe_connected,
|
||||
setup_complete: admin_created and provider_connected and stripe_connected,
|
||||
|
||||
# Launch checklist
|
||||
products_synced: products_synced,
|
||||
product_count: product_count,
|
||||
stripe_connected: stripe_connected,
|
||||
theme_customised: Settings.get_setting("theme_customised", false) == true,
|
||||
has_orders: Orders.has_paid_orders?(),
|
||||
site_live: site_live,
|
||||
can_go_live: printify_connected and products_synced and stripe_connected
|
||||
can_go_live: provider_connected and products_synced and stripe_connected,
|
||||
checklist_dismissed: Settings.get_setting("checklist_dismissed", false) == true
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@ -43,14 +43,6 @@
|
||||
<%!-- nav links --%>
|
||||
<nav class="flex-1 p-2" aria-label="Admin navigation">
|
||||
<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>
|
||||
<.link
|
||||
navigate={~p"/admin"}
|
||||
|
||||
@ -3,9 +3,16 @@ defmodule BerrypodWeb.Admin.Dashboard do
|
||||
|
||||
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
|
||||
def mount(_params, _session, socket) do
|
||||
if Settings.site_live?() do
|
||||
setup = Berrypod.Setup.setup_status()
|
||||
status_counts = Orders.count_orders_by_status()
|
||||
paid_count = Map.get(status_counts, "paid", 0)
|
||||
recent_orders = Orders.list_orders(status: "paid") |> Enum.take(5)
|
||||
@ -13,15 +20,40 @@ defmodule BerrypodWeb.Admin.Dashboard do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Dashboard")
|
||||
|> assign(:setup, setup)
|
||||
|> assign(:show_checklist, show_checklist?(setup))
|
||||
|> assign(:just_went_live, false)
|
||||
|> assign(:paid_count, paid_count)
|
||||
|> assign(:revenue, Orders.total_revenue())
|
||||
|> assign(:product_count, Products.count_products())
|
||||
|> assign(:recent_orders, recent_orders)}
|
||||
else
|
||||
{:ok, push_navigate(socket, to: ~p"/admin/setup")}
|
||||
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
|
||||
|
||||
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
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
@ -29,8 +61,26 @@ defmodule BerrypodWeb.Admin.Dashboard do
|
||||
Dashboard
|
||||
</.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 --%>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-6">
|
||||
<div class="admin-stats-grid">
|
||||
<.stat_card
|
||||
label="Orders"
|
||||
value={@paid_count}
|
||||
@ -52,46 +102,50 @@ defmodule BerrypodWeb.Admin.Dashboard do
|
||||
</div>
|
||||
|
||||
<%!-- Recent orders --%>
|
||||
<section class="mt-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold">Recent orders</h2>
|
||||
<section style="margin-top: 2rem;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem;">
|
||||
<h2 style="font-size: 1.125rem; font-weight: 600;">Recent orders</h2>
|
||||
<.link
|
||||
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 →
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<%= if @recent_orders == [] do %>
|
||||
<div class="rounded-lg border border-base-200 p-8 text-center text-base-content/60">
|
||||
<.icon name="hero-inbox" class="size-10 mx-auto mb-3 text-base-content/30" />
|
||||
<p class="font-medium">No orders yet</p>
|
||||
<p class="text-sm mt-1">Orders will appear here once customers check out.</p>
|
||||
<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));">
|
||||
<div style="margin: 0 auto 0.75rem; width: 2.5rem; opacity: 0.3;">
|
||||
<.icon name="hero-inbox" class="size-10" />
|
||||
</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>
|
||||
<% else %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<div style="overflow-x: auto;">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr class="border-b border-base-200 text-left text-base-content/60">
|
||||
<th class="pb-2 font-medium">Order</th>
|
||||
<th class="pb-2 font-medium">Date</th>
|
||||
<th class="pb-2 font-medium">Customer</th>
|
||||
<th class="pb-2 font-medium text-right">Total</th>
|
||||
<th class="pb-2 font-medium">Fulfilment</th>
|
||||
<tr>
|
||||
<th>Order</th>
|
||||
<th>Date</th>
|
||||
<th>Customer</th>
|
||||
<th style="text-align: right;">Total</th>
|
||||
<th>Fulfilment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
: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}")}
|
||||
style="cursor: pointer;"
|
||||
>
|
||||
<td class="py-2.5 font-medium">{order.order_number}</td>
|
||||
<td class="py-2.5 text-base-content/60">{format_date(order.inserted_at)}</td>
|
||||
<td class="py-2.5 text-base-content/60">{order.customer_email || "—"}</td>
|
||||
<td class="py-2.5 text-right">{Cart.format_price(order.total)}</td>
|
||||
<td class="py-2.5"><.fulfilment_pill status={order.fulfilment_status} /></td>
|
||||
<td style="font-weight: 500;">{order.order_number}</td>
|
||||
<td>{format_date(order.inserted_at)}</td>
|
||||
<td>{order.customer_email || "—"}</td>
|
||||
<td style="text-align: right;">{Cart.format_price(order.total)}</td>
|
||||
<td><.fulfilment_pill status={order.fulfilment_status} /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@ -101,6 +155,91 @@ defmodule BerrypodWeb.Admin.Dashboard do
|
||||
"""
|
||||
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"} →
|
||||
</.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
|
||||
# ==========================================================================
|
||||
@ -114,15 +253,18 @@ defmodule BerrypodWeb.Admin.Dashboard do
|
||||
~H"""
|
||||
<.link
|
||||
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 class="rounded-lg bg-base-200 p-2">
|
||||
<.icon name={@icon} class="size-5 text-base-content/60" />
|
||||
<div style="display: flex; align-items: center; gap: 0.75rem; padding: 1rem;">
|
||||
<div style="background: var(--color-base-200, #e5e5e5); border-radius: 0.5rem; padding: 0.5rem;">
|
||||
<.icon name={@icon} class="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold">{@value}</p>
|
||||
<p class="text-sm text-base-content/60">{@label}</p>
|
||||
<p style="font-size: 1.5rem; font-weight: 700;">{@value}</p>
|
||||
<p style="font-size: 0.875rem; color: var(--color-base-content-60, rgba(0 0 0 / 0.6));">
|
||||
{@label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</.link>
|
||||
@ -132,19 +274,19 @@ defmodule BerrypodWeb.Admin.Dashboard do
|
||||
defp fulfilment_pill(assigns) do
|
||||
{color, label} =
|
||||
case assigns.status do
|
||||
"unfulfilled" -> {"bg-base-200 text-base-content/60", "unfulfilled"}
|
||||
"submitted" -> {"bg-blue-50 text-blue-700", "submitted"}
|
||||
"processing" -> {"bg-amber-50 text-amber-700", "processing"}
|
||||
"shipped" -> {"bg-purple-50 text-purple-700", "shipped"}
|
||||
"delivered" -> {"bg-green-50 text-green-700", "delivered"}
|
||||
"failed" -> {"bg-red-50 text-red-700", "failed"}
|
||||
_ -> {"bg-base-200 text-base-content/60", assigns.status || "—"}
|
||||
"unfulfilled" -> {"var(--color-base-200, #e5e5e5)", "unfulfilled"}
|
||||
"submitted" -> {"#dbeafe", "submitted"}
|
||||
"processing" -> {"#fef3c7", "processing"}
|
||||
"shipped" -> {"#f3e8ff", "shipped"}
|
||||
"delivered" -> {"#dcfce7", "delivered"}
|
||||
"failed" -> {"#fee2e2", "failed"}
|
||||
_ -> {"var(--color-base-200, #e5e5e5)", assigns.status || "—"}
|
||||
end
|
||||
|
||||
assigns = assign(assigns, color: color, label: label)
|
||||
|
||||
~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}
|
||||
</span>
|
||||
"""
|
||||
@ -154,6 +296,10 @@ defmodule BerrypodWeb.Admin.Dashboard do
|
||||
# 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
|
||||
Cart.format_price(amount_pence)
|
||||
end
|
||||
|
||||
@ -4,8 +4,9 @@ defmodule BerrypodWeb.Admin.Providers.Form do
|
||||
alias Berrypod.Products
|
||||
alias Berrypod.Products.ProviderConnection
|
||||
alias Berrypod.Providers
|
||||
alias Berrypod.Providers.Provider
|
||||
|
||||
@supported_types ~w(printify printful)
|
||||
@supported_types Enum.map(Provider.available(), & &1.type)
|
||||
|
||||
@impl true
|
||||
def mount(params, _session, socket) do
|
||||
@ -14,10 +15,12 @@ defmodule BerrypodWeb.Admin.Providers.Form do
|
||||
|
||||
defp apply_action(socket, :new, params) do
|
||||
provider_type = validated_type(params["type"])
|
||||
provider = Provider.get(provider_type)
|
||||
|
||||
socket
|
||||
|> assign(:page_title, "Connect to #{provider_label(provider_type)}")
|
||||
|> assign(:page_title, "Connect to #{provider.name}")
|
||||
|> assign(:provider_type, provider_type)
|
||||
|> assign(:provider, provider)
|
||||
|> assign(:connection, %ProviderConnection{provider_type: provider_type})
|
||||
|> assign(:form, to_form(ProviderConnection.changeset(%ProviderConnection{}, %{})))
|
||||
|> assign(:testing, false)
|
||||
@ -27,10 +30,12 @@ defmodule BerrypodWeb.Admin.Providers.Form do
|
||||
|
||||
defp apply_action(socket, :edit, %{"id" => id}) do
|
||||
connection = Products.get_provider_connection!(id)
|
||||
provider = Provider.get(connection.provider_type)
|
||||
|
||||
socket
|
||||
|> assign(:page_title, "#{provider_label(connection.provider_type)} settings")
|
||||
|> assign(:page_title, "#{provider.name} settings")
|
||||
|> assign(:provider_type, connection.provider_type)
|
||||
|> assign(:provider, provider)
|
||||
|> assign(:connection, connection)
|
||||
|> assign(:form, to_form(ProviderConnection.changeset(connection, %{})))
|
||||
|> assign(:testing, false)
|
||||
@ -89,7 +94,7 @@ defmodule BerrypodWeb.Admin.Providers.Form do
|
||||
{:ok, _connection} ->
|
||||
{:noreply,
|
||||
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")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
@ -132,7 +137,8 @@ defmodule BerrypodWeb.Admin.Providers.Form do
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
defp encrypt_api_key(api_key) do
|
||||
@ -147,9 +153,6 @@ defmodule BerrypodWeb.Admin.Providers.Form do
|
||||
|
||||
# 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, %{store_name: name}}), do: name
|
||||
defp connection_name(_), do: nil
|
||||
|
||||
@ -1,75 +1,32 @@
|
||||
<.header>
|
||||
{if @live_action == :new,
|
||||
do: "Connect to #{provider_label(@provider_type)}",
|
||||
else: "#{provider_label(@provider_type)} settings"}
|
||||
do: "Connect to #{@provider.name}",
|
||||
else: "#{@provider.name} settings"}
|
||||
</.header>
|
||||
|
||||
<div class="max-w-xl mt-6">
|
||||
<%= if @live_action == :new do %>
|
||||
<div class="prose prose-sm mb-6">
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%= if @provider_type == "printify" do %>
|
||||
<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">
|
||||
<li>
|
||||
<a
|
||||
href="https://printify.com/app/auth/login"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="admin-link"
|
||||
>
|
||||
Log in to Printify
|
||||
<a href={@provider.login_url} target="_blank" rel="noopener" class="admin-link">
|
||||
Log in to {@provider.name}
|
||||
</a>
|
||||
(or <a
|
||||
href="https://printify.com/app/auth/register"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="admin-link"
|
||||
>create a free account</a>)
|
||||
(or <a href={@provider.signup_url} target="_blank" rel="noopener" class="admin-link">create a free account</a>)
|
||||
</li>
|
||||
<li>Click <strong>Account</strong> (top right)</li>
|
||||
<li>Select <strong>Connections</strong> from the dropdown</li>
|
||||
<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 :for={step <- @provider.setup_steps}>
|
||||
{raw(step)}
|
||||
</li>
|
||||
<li>Click <strong>Copy to clipboard</strong> and paste it below</li>
|
||||
</ol>
|
||||
</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> → <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 %>
|
||||
|
||||
<.form for={@form} id="provider-form" phx-change="validate" phx-submit="save">
|
||||
@ -78,7 +35,7 @@
|
||||
<.input
|
||||
field={@form[:api_key]}
|
||||
type="password"
|
||||
label={"#{provider_label(@provider_type)} API key"}
|
||||
label={"#{@provider.name} API key"}
|
||||
placeholder={
|
||||
if @live_action == :edit,
|
||||
do: "Leave blank to keep current key",
|
||||
@ -106,7 +63,7 @@
|
||||
<% {:ok, _info} -> %>
|
||||
<span class="text-success flex items-center gap-1">
|
||||
<.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>
|
||||
<% {:error, reason} -> %>
|
||||
<span class="text-error flex items-center gap-1">
|
||||
@ -124,7 +81,7 @@
|
||||
<div class="flex gap-2 mt-6">
|
||||
<.button type="submit" disabled={@testing}>
|
||||
{if @live_action == :new,
|
||||
do: "Connect to #{provider_label(@provider_type)}",
|
||||
do: "Connect to #{@provider.name}",
|
||||
else: "Save changes"}
|
||||
</.button>
|
||||
<.link navigate={~p"/admin/providers"} class="admin-btn admin-btn-ghost">
|
||||
|
||||
@ -3,6 +3,7 @@ defmodule BerrypodWeb.Admin.Providers.Index do
|
||||
|
||||
alias Berrypod.Products
|
||||
alias Berrypod.Products.ProviderConnection
|
||||
alias Berrypod.Providers.Provider
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
@ -11,6 +12,7 @@ defmodule BerrypodWeb.Admin.Providers.Index do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Provider connections")
|
||||
|> assign(:available_providers, Provider.available())
|
||||
|> stream(:connections, connections)}
|
||||
end
|
||||
|
||||
@ -85,6 +87,13 @@ defmodule BerrypodWeb.Admin.Providers.Index do
|
||||
"""
|
||||
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
|
||||
diff = DateTime.diff(DateTime.utc_now(), datetime, :second)
|
||||
|
||||
|
||||
@ -6,11 +6,8 @@
|
||||
<.icon name="hero-plus" class="size-4 mr-1" /> Connect provider
|
||||
</div>
|
||||
<ul tabindex="0" class="admin-dropdown-content">
|
||||
<li>
|
||||
<.link navigate={~p"/admin/providers/new?type=printify"}>Printify</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link navigate={~p"/admin/providers/new?type=printful"}>Printful</.link>
|
||||
<li :for={provider <- @available_providers}>
|
||||
<.link navigate={~p"/admin/providers/new?type=#{provider.type}"}>{provider.name}</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@ -22,15 +19,14 @@
|
||||
<.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>
|
||||
<p class="mt-2 text-base-content/60 max-w-md mx-auto">
|
||||
Connect your Printify or Printful account to import products
|
||||
and start selling.
|
||||
Connect your account to import products and start selling.
|
||||
</p>
|
||||
<div class="flex justify-center gap-3 mt-6">
|
||||
<.button navigate={~p"/admin/providers/new?type=printify"}>
|
||||
Connect Printify
|
||||
</.button>
|
||||
<.button navigate={~p"/admin/providers/new?type=printful"} variant="outline">
|
||||
Connect Printful
|
||||
<.button
|
||||
:for={provider <- @available_providers}
|
||||
navigate={~p"/admin/providers/new?type=#{provider.type}"}
|
||||
>
|
||||
Connect {provider.name}
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
@ -46,7 +42,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<.status_indicator status={connection.sync_status} enabled={connection.enabled} />
|
||||
<h3 class="font-semibold text-lg">
|
||||
{String.capitalize(connection.provider_type)}
|
||||
{provider_name(connection.provider_type)}
|
||||
</h3>
|
||||
</div>
|
||||
<p class="text-base-content/70 mt-1">{connection.name}</p>
|
||||
@ -65,7 +61,7 @@
|
||||
<button
|
||||
phx-click="delete"
|
||||
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"
|
||||
>
|
||||
Disconnect
|
||||
|
||||
@ -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 · {@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 · {@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 → Account → 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 → 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 —
|
||||
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
|
||||
@ -12,14 +12,17 @@ defmodule BerrypodWeb.Auth.Login do
|
||||
<.header>
|
||||
<p>Log in</p>
|
||||
<:subtitle>
|
||||
<%= if @current_scope do %>
|
||||
<%= cond do %>
|
||||
<% @current_scope -> %>
|
||||
You need to reauthenticate to perform sensitive actions on your account.
|
||||
<% else %>
|
||||
<% @registration_open -> %>
|
||||
Don't have an account? <.link
|
||||
navigate={~p"/users/register"}
|
||||
navigate={~p"/setup"}
|
||||
class="font-semibold text-brand hover:underline"
|
||||
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 %>
|
||||
</:subtitle>
|
||||
</.header>
|
||||
@ -100,7 +103,8 @@ defmodule BerrypodWeb.Auth.Login do
|
||||
|
||||
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
|
||||
|
||||
@impl true
|
||||
|
||||
@ -54,8 +54,8 @@ defmodule BerrypodWeb.Auth.Registration do
|
||||
|> put_flash(:error, "Registration is closed")
|
||||
|> redirect(to: ~p"/users/log-in")}
|
||||
else
|
||||
changeset = Accounts.change_user_email(%User{}, %{}, validate_unique: false)
|
||||
{:ok, assign_form(socket, changeset), temporary_assigns: [form: nil]}
|
||||
# Fresh install — account creation happens on the setup page
|
||||
{:ok, redirect(socket, to: ~p"/setup")}
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
559
lib/berrypod_web/live/setup/onboarding.ex
Normal file
559
lib/berrypod_web/live/setup/onboarding.ex
Normal 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">→</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} →
|
||||
</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 →
|
||||
</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
|
||||
@ -140,6 +140,16 @@ defmodule BerrypodWeb.Router do
|
||||
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
|
||||
|
||||
# Admin pages with sidebar layout
|
||||
@ -153,7 +163,6 @@ defmodule BerrypodWeb.Router do
|
||||
{BerrypodWeb.AdminLayoutHook, :assign_current_path}
|
||||
] do
|
||||
live "/", Admin.Dashboard, :index
|
||||
live "/setup", Admin.Setup, :index
|
||||
live "/orders", Admin.Orders, :index
|
||||
live "/orders/:id", Admin.OrderShow, :show
|
||||
live "/products", Admin.Products, :index
|
||||
|
||||
@ -57,8 +57,8 @@ defmodule BerrypodWeb.ThemeHook do
|
||||
{:cont, socket}
|
||||
|
||||
not Berrypod.Accounts.has_admin?() ->
|
||||
# Fresh install — send to registration
|
||||
{:halt, Phoenix.LiveView.redirect(socket, to: "/users/register")}
|
||||
# Fresh install — send to setup
|
||||
{:halt, Phoenix.LiveView.redirect(socket, to: "/setup")}
|
||||
|
||||
true ->
|
||||
{:halt, Phoenix.LiveView.redirect(socket, to: "/coming-soon")}
|
||||
|
||||
@ -258,7 +258,7 @@ defmodule BerrypodWeb.UserAuth do
|
||||
|
||||
@doc "Returns the path to redirect to after log in."
|
||||
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
|
||||
|
||||
@doc """
|
||||
|
||||
@ -10,12 +10,17 @@ defmodule Berrypod.SetupTest do
|
||||
status = Setup.setup_status()
|
||||
|
||||
refute status.admin_created
|
||||
refute status.printify_connected
|
||||
refute status.provider_connected
|
||||
assert is_nil(status.provider_type)
|
||||
refute status.products_synced
|
||||
assert status.product_count == 0
|
||||
refute status.stripe_connected
|
||||
refute status.setup_complete
|
||||
refute status.site_live
|
||||
refute status.can_go_live
|
||||
refute status.theme_customised
|
||||
refute status.has_orders
|
||||
refute status.checklist_dismissed
|
||||
end
|
||||
|
||||
test "detects admin created" do
|
||||
@ -39,7 +44,7 @@ defmodule Berrypod.SetupTest do
|
||||
assert status.site_live
|
||||
end
|
||||
|
||||
test "detects printify connected with products" do
|
||||
test "detects provider connected with products" do
|
||||
{:ok, conn} =
|
||||
Products.create_provider_connection(%{
|
||||
name: "Test",
|
||||
@ -48,7 +53,8 @@ defmodule Berrypod.SetupTest do
|
||||
})
|
||||
|
||||
status = Setup.setup_status()
|
||||
assert status.printify_connected
|
||||
assert status.provider_connected
|
||||
assert status.provider_type == "printify"
|
||||
refute status.products_synced
|
||||
assert status.product_count == 0
|
||||
|
||||
@ -66,7 +72,24 @@ defmodule Berrypod.SetupTest do
|
||||
assert status.product_count == 1
|
||||
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} =
|
||||
Products.create_provider_connection(%{
|
||||
name: "Test",
|
||||
@ -90,5 +113,13 @@ defmodule Berrypod.SetupTest do
|
||||
|
||||
assert Setup.setup_status().can_go_live
|
||||
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
|
||||
|
||||
@ -18,7 +18,7 @@ defmodule BerrypodWeb.UserSessionControllerTest do
|
||||
})
|
||||
|
||||
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
|
||||
conn = get(conn, ~p"/admin/settings")
|
||||
@ -39,7 +39,7 @@ defmodule BerrypodWeb.UserSessionControllerTest do
|
||||
})
|
||||
|
||||
assert conn.resp_cookies["_berrypod_web_user_remember_me"]
|
||||
assert redirected_to(conn) == ~p"/admin/setup"
|
||||
assert redirected_to(conn) == ~p"/setup"
|
||||
end
|
||||
|
||||
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 redirected_to(conn) == ~p"/admin/setup"
|
||||
assert redirected_to(conn) == ~p"/setup"
|
||||
|
||||
# Now do a logged in request and assert on the page content
|
||||
conn = get(conn, ~p"/admin/settings")
|
||||
@ -99,7 +99,7 @@ defmodule BerrypodWeb.UserSessionControllerTest do
|
||||
})
|
||||
|
||||
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 Accounts.get_user!(user.id).confirmed_at
|
||||
|
||||
@ -18,14 +18,61 @@ defmodule BerrypodWeb.Admin.DashboardTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "redirects to setup when not live" do
|
||||
describe "launch checklist" do
|
||||
setup %{conn: conn, user: user} do
|
||||
%{conn: log_in_user(conn, user)}
|
||||
end
|
||||
|
||||
test "redirects to /admin/setup when site not live", %{conn: conn} do
|
||||
{:error, redirect} = live(conn, ~p"/admin")
|
||||
assert {:live_redirect, %{to: "/admin/setup"}} = redirect
|
||||
test "shows checklist when site not live", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/admin")
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -30,13 +30,6 @@ defmodule BerrypodWeb.Admin.LayoutTest do
|
||||
refute has_element?(view, ~s(a.active[href="/admin/settings"]))
|
||||
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
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/settings")
|
||||
|
||||
|
||||
@ -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
|
||||
@ -64,7 +64,7 @@ defmodule BerrypodWeb.Auth.ConfirmationTest do
|
||||
assert Accounts.get_user!(user.id).confirmed_at
|
||||
# we are logged in now
|
||||
assert get_session(conn, :user_token)
|
||||
assert redirected_to(conn) == ~p"/admin/setup"
|
||||
assert redirected_to(conn) == ~p"/setup"
|
||||
|
||||
# log out, new conn
|
||||
conn = build_conn()
|
||||
|
||||
@ -9,7 +9,7 @@ defmodule BerrypodWeb.Auth.LoginTest do
|
||||
{:ok, _lv, html} = live(conn, ~p"/users/log-in")
|
||||
|
||||
assert html =~ "Log in"
|
||||
assert html =~ "Sign up"
|
||||
assert html =~ "Set up your shop"
|
||||
assert html =~ "Log in with email"
|
||||
end
|
||||
end
|
||||
@ -56,7 +56,7 @@ defmodule BerrypodWeb.Auth.LoginTest do
|
||||
|
||||
conn = submit_form(form, conn)
|
||||
|
||||
assert redirected_to(conn) == ~p"/admin/setup"
|
||||
assert redirected_to(conn) == ~p"/setup"
|
||||
end
|
||||
|
||||
test "redirects to login page with a flash error if credentials are invalid", %{
|
||||
@ -76,16 +76,16 @@ defmodule BerrypodWeb.Auth.LoginTest do
|
||||
end
|
||||
|
||||
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, _login_live, login_html} =
|
||||
{:ok, _setup_live, setup_html} =
|
||||
lv
|
||||
|> element("main a", "Sign up")
|
||||
|> element("main a", "Set up your shop")
|
||||
|> 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
|
||||
|
||||
|
||||
@ -5,11 +5,8 @@ defmodule BerrypodWeb.Auth.RegistrationTest do
|
||||
import Berrypod.AccountsFixtures
|
||||
|
||||
describe "Registration page" do
|
||||
test "renders registration page when no admin exists", %{conn: conn} do
|
||||
{:ok, _lv, html} = live(conn, ~p"/users/register")
|
||||
|
||||
assert html =~ "Register"
|
||||
assert html =~ "Log in"
|
||||
test "redirects to setup when no admin exists (fresh install)", %{conn: conn} do
|
||||
assert {:error, {:redirect, %{to: "/setup"}}} = live(conn, ~p"/users/register")
|
||||
end
|
||||
|
||||
test "redirects to login when admin already exists", %{conn: conn} do
|
||||
@ -25,66 +22,9 @@ defmodule BerrypodWeb.Auth.RegistrationTest do
|
||||
conn
|
||||
|> log_in_user(user_fixture())
|
||||
|> live(~p"/users/register")
|
||||
|> follow_redirect(conn, ~p"/admin/setup")
|
||||
|> follow_redirect(conn, ~p"/setup")
|
||||
|
||||
assert {:ok, _conn} = result
|
||||
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
|
||||
|
||||
116
test/berrypod_web/live/setup/onboarding_test.exs
Normal file
116
test/berrypod_web/live/setup/onboarding_test.exs
Normal 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
|
||||
@ -51,9 +51,9 @@ defmodule BerrypodWeb.Shop.ComingSoonTest do
|
||||
assert html =~ "Shop the collection"
|
||||
end
|
||||
|
||||
test "redirects to registration on fresh install (no admin)", %{conn: conn} do
|
||||
# No admin created — redirect to registration
|
||||
assert {:error, {:redirect, %{to: "/users/register"}}} = live(conn, ~p"/")
|
||||
test "redirects to setup on fresh install (no admin)", %{conn: conn} do
|
||||
# No admin created — redirect to setup
|
||||
assert {:error, {:redirect, %{to: "/setup"}}} = live(conn, ~p"/")
|
||||
end
|
||||
|
||||
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
|
||||
Berrypod.Repo.delete!(user)
|
||||
|
||||
assert {:error, {:redirect, %{to: "/users/register"}}} = live(conn, ~p"/")
|
||||
assert {:error, {:redirect, %{to: "/setup"}}} = live(conn, ~p"/")
|
||||
end
|
||||
|
||||
test "gates all public shop routes", %{conn: conn} do
|
||||
|
||||
@ -25,7 +25,7 @@ defmodule BerrypodWeb.UserAuthTest do
|
||||
conn = UserAuth.log_in_user(conn, user)
|
||||
assert token = get_session(conn, :user_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)
|
||||
end
|
||||
|
||||
@ -80,7 +80,7 @@ defmodule BerrypodWeb.UserAuthTest do
|
||||
|> assign(:current_scope, Scope.for_user(user))
|
||||
|> UserAuth.log_in_user(user)
|
||||
|
||||
assert redirected_to(conn) == ~p"/admin/setup"
|
||||
assert redirected_to(conn) == ~p"/setup"
|
||||
end
|
||||
|
||||
test "writes a cookie if remember_me was set in previous session", %{conn: conn, user: user} do
|
||||
|
||||
Loading…
Reference in New Issue
Block a user