diff --git a/CLAUDE.md b/CLAUDE.md index b0d86ba..96536f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -170,6 +170,52 @@ socket |> assign(form: to_form(changeset)) - Reference DOM IDs from templates in tests - Debug with LazyHTML: `LazyHTML.filter(document, "selector")` +## Writing Style + +- Casual, British English tone throughout (code, comments, commits, docs) +- Sentence case always, never title case ("Admin providers" not "Admin Providers") +- Brief and tight in prose. Cut fluff. No waffle. +- Avoid em dashes, semicolons in prose, and overly formal language +- Don't sound like an LLM wrote it (no "straightforward", "robust", "leverage", "comprehensive") +- Comments explain why, not what. Skip obvious ones. + +**Code style:** +- Explicit and obvious over clever and terse +- Readable code that anyone can follow beats "smart" one-liners +- Stick to idiomatic Phoenix/Elixir/LiveView/Oban patterns +- Follow conventions from the existing codebase +- When in doubt, check how Phoenix generators do it + +## Commits + +- Suggest a commit at logical checkpoints (feature complete, tests passing) +- Run `mix precommit` before committing +- Atomic commits with one logical change each +- Commit messages: imperative mood, lowercase, no full stop + - Good: `add provider connection form validation` + - Bad: `Added provider connection form validation.` + +## Testing + +Write tests for new features, but be pragmatic about coverage. + +**Do test:** +- Public context functions (the API boundary) +- Critical user flows (auth, checkout, sync) +- Edge cases and error handling +- Complex business logic + +**Skip tests for:** +- Trivial getters/setters +- Framework-generated code +- Pure UI tweaks with no logic +- Implementation details that may change + +**Approach:** +- Prefer integration tests over isolated unit tests for LiveViews +- One test file per module, colocated in `test/` mirror of `lib/` +- Use fixtures from `test/support/fixtures/` for test data + ## Documentation Workflow **Single source of truth:** [PROGRESS.md](PROGRESS.md) diff --git a/PROGRESS.md b/PROGRESS.md index 22fe12c..a75afa7 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -16,9 +16,12 @@ ## Next Up -1. Wire Products context to shop LiveViews (replace PreviewData) -2. Add Printify product sync worker -3. Session-based cart with real variants +1. **Admin Provider Setup UI** (`/admin/providers`) - Add/edit/test provider connections +2. **Product Sync Strategy:** + - ProductSyncWorker (Oban) for manual/scheduled sync + - Webhook endpoint for real-time updates from Printify +3. Wire Products context to shop LiveViews (replace PreviewData) +4. Session-based cart with real variants --- @@ -56,11 +59,11 @@ See: [docs/plans/image-optimization.md](docs/plans/image-optimization.md) for im - [x] Product/variant/image schemas #### Remaining Tasks -- [ ] Add `list_products/1` to Printify client (~1hr) -- [ ] Create ProductSyncWorker (Oban) (~1hr) +- [ ] **Admin Provider Setup UI** - `/admin/providers` for managing connections (~2hr) ← **NEXT** +- [ ] Create ProductSyncWorker (Oban) for manual/scheduled sync (~1hr) +- [ ] Printify webhook endpoint for real-time product updates (~1.5hr) - [ ] Wire shop LiveViews to Products context (~2hr) - [ ] Add variant selector component (~2hr) -- [ ] Implement product sync mix task (~1hr) See: [docs/plans/products-context.md](docs/plans/products-context.md) for implementation details diff --git a/docs/plans/products-context.md b/docs/plans/products-context.md index 93dc3f3..0df2eea 100644 --- a/docs/plans/products-context.md +++ b/docs/plans/products-context.md @@ -1165,6 +1165,735 @@ end --- +--- + +## Task: Admin Provider Setup UI + +> **Status:** Next up +> **Estimate:** ~2 hours +> **Prerequisite:** Phase 1 complete (schemas, context, Printify provider) + +### Goal + +Add an admin UI at `/admin/providers` for managing POD provider connections. This enables shop owners to connect their Printify account and trigger product syncs without using IEx. + +### User Stories + +1. **Connect provider:** Enter Printify API key, test connection, save +2. **View status:** See connected providers, last sync time, product count +3. **Trigger sync:** Manually sync products from provider +4. **Disconnect:** Remove a provider connection + +### Routes + +```elixir +# In router.ex, within :require_authenticated_user live_session +live "/admin/providers", ProviderLive.Index, :index +live "/admin/providers/new", ProviderLive.Index, :new +live "/admin/providers/:id/edit", ProviderLive.Index, :edit +``` + +### Files to Create/Modify + +| File | Purpose | +|------|---------| +| `lib/simpleshop_theme_web/live/provider_live/index.ex` | LiveView for provider list + modal forms | +| `lib/simpleshop_theme_web/live/provider_live/index.html.heex` | Template | +| `lib/simpleshop_theme_web/live/provider_live/form_component.ex` | Form component for new/edit | +| `lib/simpleshop_theme/providers/printify.ex` | Add `register_webhooks/1`, `unregister_webhooks/1` | +| `lib/simpleshop_theme/workers/product_sync_worker.ex` | Stub for "Sync Now" (full impl in next task) | +| `test/simpleshop_theme_web/live/provider_live_test.exs` | LiveView tests | + +### UI Design + +Single-page admin with modal for add/edit (follows Phoenix generator pattern): + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Admin › Provider Connections [+ Add] │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ 🟢 Printify [Edit] [×] │ │ +│ │ "My Printify Shop" │ │ +│ │ Shop: Acme Store • 24 products │ │ +│ │ Last synced: 5 minutes ago │ │ +│ │ [Sync Now] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ ⚪ Gelato [Edit] [×] │ │ +│ │ "Gelato Account" │ │ +│ │ Not connected (invalid API key) │ │ +│ │ [Sync Now] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Add/Edit Modal:** + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Connect Printify [×] │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ Name │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ My Printify Shop │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ API Key │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ •••••••••••••••••••• │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ Get your API key from Printify Settings → Connections │ +│ │ +│ ┌──────────────────┐ │ +│ │ Test Connection │ ✓ Connected to "Acme Store" │ +│ └──────────────────┘ │ +│ │ +│ □ Enable automatic sync │ +│ │ +│ [Cancel] [Save Connection] │ +└─────────────────────────────────────────────────────────────┘ +``` + +### LiveView Implementation + +**Index LiveView (`provider_live/index.ex`):** + +```elixir +defmodule SimpleshopThemeWeb.ProviderLive.Index do + use SimpleshopThemeWeb, :live_view + + alias SimpleshopTheme.Products + alias SimpleshopTheme.Products.ProviderConnection + + @impl true + def mount(_params, _session, socket) do + connections = Products.list_provider_connections() + {:ok, stream(socket, :connections, connections)} + end + + @impl true + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + defp apply_action(socket, :edit, %{"id" => id}) do + socket + |> assign(:page_title, "Edit Provider") + |> assign(:connection, Products.get_provider_connection!(id)) + end + + defp apply_action(socket, :new, _params) do + socket + |> assign(:page_title, "Add Provider") + |> assign(:connection, %ProviderConnection{}) + end + + defp apply_action(socket, :index, _params) do + socket + |> assign(:page_title, "Provider Connections") + |> assign(:connection, nil) + end + + @impl true + def handle_info({SimpleshopThemeWeb.ProviderLive.FormComponent, {:saved, connection}}, socket) do + {:noreply, stream_insert(socket, :connections, connection)} + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + connection = Products.get_provider_connection!(id) + {:ok, _} = Products.delete_provider_connection(connection) + {:noreply, stream_delete(socket, :connections, connection)} + end + + @impl true + def handle_event("sync", %{"id" => id}, socket) do + connection = Products.get_provider_connection!(id) + # Enqueue sync job (Oban worker) + {:ok, _job} = Products.enqueue_sync(connection) + + {:noreply, + socket + |> put_flash(:info, "Sync started for #{connection.name}") + |> stream_insert(:connections, %{connection | sync_status: "syncing"})} + end +end +``` + +**Form Component (`provider_live/form_component.ex`):** + +Key features: +- Provider type dropdown (Printify only initially, extensible) +- API key field (password type, shows masked on edit) +- "Test Connection" button with async feedback +- Validation before save +- Auto-registers webhooks on successful save + +```elixir +def handle_event("test_connection", _params, socket) do + form_data = socket.assigns.form.source.changes + api_key = form_data[:api_key] || socket.assigns.connection.api_key + + case test_provider_connection(socket.assigns.provider_type, api_key) do + {:ok, %{shop_name: name}} -> + {:noreply, assign(socket, test_result: {:ok, name})} + + {:error, reason} -> + {:noreply, assign(socket, test_result: {:error, reason})} + end +end + +defp test_provider_connection("printify", api_key) do + # Build temporary connection struct for testing + conn = %ProviderConnection{provider_type: "printify", api_key: api_key} + SimpleshopTheme.Providers.Printify.test_connection(conn) +end +``` + +### Events + +| Event | Handler | Action | +|-------|---------|--------| +| `"validate"` | FormComponent | Live validation of form fields | +| `"test_connection"` | FormComponent | Call provider's `test_connection/1`, show result | +| `"save"` | FormComponent | Create/update connection, register webhooks, notify parent | +| `"delete"` | Index | Unregister webhooks, delete connection | +| `"sync"` | Index | Enqueue `ProductSyncWorker` job | + +### Webhook Registration Flow + +When a provider connection is saved, automatically register webhooks with the provider so real-time sync works immediately. + +**On Save (FormComponent):** + +```elixir +def handle_event("save", %{"provider" => params}, socket) do + save_result = case socket.assigns.connection.id do + nil -> Products.create_provider_connection(params) + _id -> Products.update_provider_connection(socket.assigns.connection, params) + end + + case save_result do + {:ok, connection} -> + # Register webhooks after successful save + case register_webhooks(connection) do + {:ok, _} -> + Products.update_provider_connection(connection, %{ + config: Map.put(connection.config, "webhooks_registered", true) + }) + + {:error, reason} -> + # Log but don't fail - webhooks can be registered later + Logger.warning("Failed to register webhooks: #{inspect(reason)}") + end + + notify_parent({:saved, connection}) + {:noreply, + socket + |> put_flash(:info, "Provider connected successfully") + |> push_patch(to: socket.assigns.patch)} + + {:error, changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end +end + +defp register_webhooks(%{provider_type: "printify"} = conn) do + SimpleshopTheme.Providers.Printify.register_webhooks(conn) +end +``` + +**On Delete (Index):** + +```elixir +def handle_event("delete", %{"id" => id}, socket) do + connection = Products.get_provider_connection!(id) + + # Unregister webhooks before deleting + unregister_webhooks(connection) + + {:ok, _} = Products.delete_provider_connection(connection) + {:noreply, stream_delete(socket, :connections, connection)} +end + +defp unregister_webhooks(%{provider_type: "printify"} = conn) do + SimpleshopTheme.Providers.Printify.unregister_webhooks(conn) +end +``` + +**Provider Webhook Functions (add to `Providers.Printify`):** + +```elixir +@webhook_topics ~w(product:publish:started product:deleted shop:disconnected) + +def register_webhooks(conn) do + webhook_url = SimpleshopThemeWeb.Endpoint.url() <> "/webhooks/printify" + shop_id = get_shop_id(conn) + + results = Enum.map(@webhook_topics, fn topic -> + Client.post(conn, "/shops/#{shop_id}/webhooks.json", %{ + topic: topic, + url: webhook_url + }) + end) + + if Enum.all?(results, &match?({:ok, _}, &1)) do + {:ok, :registered} + else + {:error, :partial_registration} + end +end + +def unregister_webhooks(conn) do + shop_id = get_shop_id(conn) + + # List existing webhooks and delete ours + case Client.get(conn, "/shops/#{shop_id}/webhooks.json") do + {:ok, %{"webhooks" => webhooks}} -> + our_url = SimpleshopThemeWeb.Endpoint.url() <> "/webhooks/printify" + + webhooks + |> Enum.filter(&(&1["url"] == our_url)) + |> Enum.each(&Client.delete(conn, "/shops/#{shop_id}/webhooks/#{&1["id"]}.json")) + + {:ok, :unregistered} + + error -> + error + end +end +``` + +### UI: Webhook Status Indicator + +Show webhook status on the connection card: + +``` +┌─────────────────────────────────────────────────────────┐ +│ 🟢 Printify [Edit] [×] │ +│ "My Printify Shop" │ +│ Shop: Acme Store • 24 products │ +│ Last synced: 5 minutes ago │ +│ ✓ Real-time updates enabled [Sync Now] │ ← webhook status +└─────────────────────────────────────────────────────────┘ +``` + +Or if webhooks failed to register: + +``` +│ ⚠ Real-time updates unavailable (click Sync manually) │ +``` + +Template snippet: +```heex +