docs: consolidate project tracking into PROGRESS.md

- Create PROGRESS.md as single source of truth for status
- Slim ROADMAP.md to vision only (~100 lines, down from ~500)
- Expand CLAUDE.md with streams, auth routing, forms, workflow
- Convert AGENTS.md to stub pointing to CLAUDE.md
- Update plan files with status headers, remove progress trackers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-01-31 14:06:07 +00:00
parent 153f3d049f
commit d97918d66a
6 changed files with 252 additions and 858 deletions

396
AGENTS.md
View File

@ -1,395 +1,5 @@
This is a web application written using the Phoenix web framework.
# Project Guidelines
## Project guidelines
See [CLAUDE.md](CLAUDE.md) for comprehensive project guidelines.
- Use `mix precommit` alias when you are done with all changes and fix any pending issues
- Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`. Req is included by default and is the preferred HTTP client for Phoenix apps
### Phoenix v1.8 guidelines
- **Always** begin your LiveView templates with `<Layouts.app flash={@flash} ...>` which wraps all inner content
- The `MyAppWeb.Layouts` module is aliased in the `my_app_web.ex` file, so you can use it without needing to alias it again
- Anytime you run into errors with no `current_scope` assign:
- You failed to follow the Authenticated Routes guidelines, or you failed to pass `current_scope` to `<Layouts.app>`
- **Always** fix the `current_scope` error by moving your routes to the proper `live_session` and ensure you pass `current_scope` as needed
- Phoenix v1.8 moved the `<.flash_group>` component to the `Layouts` module. You are **forbidden** from calling `<.flash_group>` outside of the `layouts.ex` module
- Out of the box, `core_components.ex` imports an `<.icon name="hero-x-mark" class="w-5 h-5"/>` component for for hero icons. **Always** use the `<.icon>` component for icons, **never** use `Heroicons` modules or similar
- **Always** use the imported `<.input>` component for form inputs from `core_components.ex` when available. `<.input>` is imported and using it will will save steps and prevent errors
- If you override the default input classes (`<.input class="myclass px-2 py-1 rounded-lg">)`) class with your own values, no default classes are inherited, so your
custom classes must fully style the input
### JS and CSS guidelines
- **Use Tailwind CSS classes and custom CSS rules** to create polished, responsive, and visually stunning interfaces.
- Tailwindcss v4 **no longer needs a tailwind.config.js** and uses a new import syntax in `app.css`:
@import "tailwindcss" source(none);
@source "../css";
@source "../js";
@source "../../lib/my_app_web";
- **Always use and maintain this import syntax** in the app.css file for projects generated with `phx.new`
- **Never** use `@apply` when writing raw css
- **Always** manually write your own tailwind-based components instead of using daisyUI for a unique, world-class design
- Out of the box **only the app.js and app.css bundles are supported**
- You cannot reference an external vendor'd script `src` or link `href` in the layouts
- You must import the vendor deps into app.js and app.css to use them
- **Never write inline <script>custom js</script> tags within templates**
### UI/UX & design guidelines
- **Produce world-class UI designs** with a focus on usability, aesthetics, and modern design principles
- Implement **subtle micro-interactions** (e.g., button hover effects, and smooth transitions)
- Ensure **clean typography, spacing, and layout balance** for a refined, premium look
- Focus on **delightful details** like hover effects, loading states, and smooth page transitions
<!-- phoenix-gen-auth-start -->
## Authentication
- **Always** handle authentication flow at the router level with proper redirects
- **Always** be mindful of where to place routes. `phx.gen.auth` creates multiple router plugs and `live_session` scopes:
- A plug `:fetch_current_scope_for_user` that is included in the default browser pipeline
- A plug `:require_authenticated_user` that redirects to the log in page when the user is not authenticated
- A `live_session :current_user` scope - for routes that need the current user but don't require authentication, similar to `:fetch_current_scope_for_user`
- A `live_session :require_authenticated_user` scope - for routes that require authentication, similar to the plug with the same name
- In both cases, a `@current_scope` is assigned to the Plug connection and LiveView socket
- A plug `redirect_if_user_is_authenticated` that redirects to a default path in case the user is authenticated - useful for a registration page that should only be shown to unauthenticated users
- **Always let the user know in which router scopes, `live_session`, and pipeline you are placing the route, AND SAY WHY**
- `phx.gen.auth` assigns the `current_scope` assign - it **does not assign a `current_user` assign**
- Always pass the assign `current_scope` to context modules as first argument. When performing queries, use `current_scope.user` to filter the query results
- To derive/access `current_user` in templates, **always use the `@current_scope.user`**, never use **`@current_user`** in templates or LiveViews
- **Never** duplicate `live_session` names. A `live_session :current_user` can only be defined __once__ in the router, so all routes for the `live_session :current_user` must be grouped in a single block
- Anytime you hit `current_scope` errors or the logged in session isn't displaying the right content, **always double check the router and ensure you are using the correct plug and `live_session` as described below**
### Routes that require authentication
LiveViews that require login should **always be placed inside the __existing__ `live_session :require_authenticated_user` block**:
scope "/", AppWeb do
pipe_through [:browser, :require_authenticated_user]
live_session :require_authenticated_user,
on_mount: [{SimpleshopThemeWeb.UserAuth, :require_authenticated}] do
# phx.gen.auth generated routes
live "/users/settings", UserLive.Settings, :edit
live "/users/settings/confirm-email/:token", UserLive.Settings, :confirm_email
# our own routes that require logged in user
live "/", MyLiveThatRequiresAuth, :index
end
end
Controller routes must be placed in a scope that sets the `:require_authenticated_user` plug:
scope "/", AppWeb do
pipe_through [:browser, :require_authenticated_user]
get "/", MyControllerThatRequiresAuth, :index
end
### Routes that work with or without authentication
LiveViews that can work with or without authentication, **always use the __existing__ `:current_user` scope**, ie:
scope "/", MyAppWeb do
pipe_through [:browser]
live_session :current_user,
on_mount: [{SimpleshopThemeWeb.UserAuth, :mount_current_scope}] do
# our own routes that work with or without authentication
live "/", PublicLive
end
end
Controllers automatically have the `current_scope` available if they use the `:browser` pipeline.
<!-- phoenix-gen-auth-end -->
<!-- usage-rules-start -->
<!-- phoenix:elixir-start -->
## Elixir guidelines
- Elixir lists **do not support index based access via the access syntax**
**Never do this (invalid)**:
i = 0
mylist = ["blue", "green"]
mylist[i]
Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie:
i = 0
mylist = ["blue", "green"]
Enum.at(mylist, i)
- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc
you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie:
# INVALID: we are rebinding inside the `if` and the result never gets assigned
if connected?(socket) do
socket = assign(socket, :val, val)
end
# VALID: we rebind the result of the `if` to a new variable
socket =
if connected?(socket) do
assign(socket, :val, val)
end
- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors
- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets
- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package)
- Don't use `String.to_atom/1` on user input (memory leak risk)
- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards
- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)`
- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option
## Mix guidelines
- Read the docs and options before using tasks (by using `mix help task_name`)
- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed`
- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason
<!-- phoenix:elixir-end -->
<!-- phoenix:phoenix-start -->
## Phoenix guidelines
- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes.
- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie:
scope "/admin", AppWeb.Admin do
pipe_through :browser
live "/users", UserLive, :index
end
the UserLive route would point to the `AppWeb.Admin.UserLive` module
- `Phoenix.View` no longer is needed or included with Phoenix, don't use it
<!-- phoenix:phoenix-end -->
<!-- phoenix:ecto-start -->
## Ecto Guidelines
- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email`
- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs`
- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string`
- `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed
- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields
- Fields which are set programatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct
<!-- phoenix:ecto-end -->
<!-- phoenix:html-start -->
## Phoenix HTML guidelines
- Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E`
- **Always** use the imported `Phoenix.Component.form/1` and `Phoenix.Component.inputs_for/1` function to build forms. **Never** use `Phoenix.HTML.form_for` or `Phoenix.HTML.inputs_for` as they are outdated
- When building forms **always** use the already imported `Phoenix.Component.to_form/2` (`assign(socket, form: to_form(...))` and `<.form for={@form} id="msg-form">`), then access those forms in the template via `@form[:field]`
- **Always** add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (`<.form for={@form} id="product-form">`)
- For "app wide" template imports, you can import/alias into the `my_app_web.ex`'s `html_helpers` block, so they will be available to all LiveViews, LiveComponent's, and all modules that do `use MyAppWeb, :html` (replace "my_app" by the actual app name)
- Elixir supports `if/else` but **does NOT support `if/else if` or `if/elsif`. **Never use `else if` or `elseif` in Elixir**, **always** use `cond` or `case` for multiple conditionals.
**Never do this (invalid)**:
<%= if condition do %>
...
<% else if other_condition %>
...
<% end %>
Instead **always** do this:
<%= cond do %>
<% condition -> %>
...
<% condition2 -> %>
...
<% true -> %>
...
<% end %>
- HEEx require special tag annotation if you want to insert literal curly's like `{` or `}`. If you want to show a textual code snippet on the page in a `<pre>` or `<code>` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
<code phx-no-curly-interpolation>
let obj = {key: "val"}
</code>
Within `phx-no-curly-interpolation` annotated tags, you can use `{` and `}` without escaping them, and dynamic Elixir expressions can still be used with `<%= ... %>` syntax
- HEEx class attrs support lists, but you must **always** use list `[...]` syntax. You can use the class list syntax to conditionally add classes, **always do this for multiple class values**:
<a class={[
"px-2 text-white",
@some_flag && "py-5",
if(@other_condition, do: "border-red-500", else: "border-blue-100"),
...
]}>Text</a>
and **always** wrap `if`'s inside `{...}` expressions with parens, like done above (`if(@other_condition, do: "...", else: "...")`)
and **never** do this, since it's invalid (note the missing `[` and `]`):
<a class={
"px-2 text-white",
@some_flag && "py-5"
}> ...
=> Raises compile syntax error on invalid HEEx attr syntax
- **Never** use `<% Enum.each %>` or non-for comprehensions for generating template content, instead **always** use `<%= for item <- @collection do %>`
- HEEx HTML comments use `<%!-- comment --%>`. **Always** use the HEEx HTML comment syntax for template comments (`<%!-- comment --%>`)
- HEEx allows interpolation via `{...}` and `<%= ... %>`, but the `<%= %>` **only** works within tag bodies. **Always** use the `{...}` syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. **Always** interpolate block constructs (if, cond, case, for) within tag bodies using `<%= ... %>`.
**Always** do this:
<div id={@id}>
{@my_assign}
<%= if @some_block_condition do %>
{@another_assign}
<% end %>
</div>
and **Never** do this the program will terminate with a syntax error:
<%!-- THIS IS INVALID NEVER EVER DO THIS --%>
<div id="<%= @invalid_interpolation %>">
{if @invalid_block_construct do}
{end}
</div>
<!-- phoenix:html-end -->
<!-- phoenix:liveview-start -->
## Phoenix LiveView guidelines
- **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews
- **Avoid LiveComponent's** unless you have a strong, specific need for them
- LiveViews should be named like `AppWeb.WeatherLive`, with a `Live` suffix. When you go to add LiveView routes to the router, the default `:browser` scope is **already aliased** with the `AppWeb` module, so you can just do `live "/weather", WeatherLive`
- Remember anytime you use `phx-hook="MyHook"` and that js hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute
- **Never** write embedded `<script>` tags in HEEx. Instead always write your scripts and hooks in the `assets/js` directory and integrate them with the `assets/js/app.js` file
### LiveView streams
- **Always** use LiveView streams for collections for assigning regular lists to avoid memory ballooning and runtime termination with the following operations:
- basic append of N items - `stream(socket, :messages, [new_msg])`
- resetting stream with new items - `stream(socket, :messages, [new_msg], reset: true)` (e.g. for filtering items)
- prepend to stream - `stream(socket, :messages, [new_msg], at: -1)`
- deleting items - `stream_delete(socket, :messages, msg)`
- When using the `stream/3` interfaces in the LiveView, the LiveView template must 1) always set `phx-update="stream"` on the parent element, with a DOM id on the parent element like `id="messages"` and 2) consume the `@streams.stream_name` collection and use the id as the DOM id for each child. For a call like `stream(socket, :messages, [new_msg])` in the LiveView, the template would be:
<div id="messages" phx-update="stream">
<div :for={{id, msg} <- @streams.messages} id={id}>
{msg.text}
</div>
</div>
- LiveView streams are *not* enumerable, so you cannot use `Enum.filter/2` or `Enum.reject/2` on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you **must refetch the data and re-stream the entire stream collection, passing reset: true**:
def handle_event("filter", %{"filter" => filter}, socket) do
# re-fetch the messages based on the filter
messages = list_messages(filter)
{:noreply,
socket
|> assign(:messages_empty?, messages == [])
# reset the stream with the new messages
|> stream(:messages, messages, reset: true)}
end
- LiveView streams *do not support counting or empty states*. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes:
<div id="tasks" phx-update="stream">
<div class="hidden only:block">No tasks yet</div>
<div :for={{id, task} <- @stream.tasks} id={id}>
{task.name}
</div>
</div>
The above only works if the empty state is the only HTML block alongside the stream for-comprehension.
- **Never** use the deprecated `phx-update="append"` or `phx-update="prepend"` for collections
### LiveView tests
- `Phoenix.LiveViewTest` module and `LazyHTML` (included) for making your assertions
- Form tests are driven by `Phoenix.LiveViewTest`'s `render_submit/2` and `render_change/2` functions
- Come up with a step-by-step test plan that splits major test cases into small, isolated files. You may start with simpler tests that verify content exists, gradually add interaction tests
- **Always reference the key element IDs you added in the LiveView templates in your tests** for `Phoenix.LiveViewTest` functions like `element/2`, `has_element/2`, selectors, etc
- **Never** tests again raw HTML, **always** use `element/2`, `has_element/2`, and similar: `assert has_element?(view, "#my-form")`
- Instead of relying on testing text content, which can change, favor testing for the presence of key elements
- Focus on testing outcomes rather than implementation details
- Be aware that `Phoenix.Component` functions like `<.form>` might produce different HTML than expected. Test against the output HTML structure, not your mental model of what you expect it to be
- When facing test failures with element selectors, add debug statements to print the actual HTML, but use `LazyHTML` selectors to limit the output, ie:
html = render(view)
document = LazyHTML.from_fragment(html)
matches = LazyHTML.filter(document, "your-complex-selector")
IO.inspect(matches, label: "Matches")
### Form handling
#### Creating a form from params
If you want to create a form based on `handle_event` params:
def handle_event("submitted", params, socket) do
{:noreply, assign(socket, form: to_form(params))}
end
When you pass a map to `to_form/1`, it assumes said map contains the form params, which are expected to have string keys.
You can also specify a name to nest the params:
def handle_event("submitted", %{"user" => user_params}, socket) do
{:noreply, assign(socket, form: to_form(user_params, as: :user))}
end
#### Creating a form from changesets
When using changesets, the underlying data, form params, and errors are retrieved from it. The `:as` option is automatically computed too. E.g. if you have a user schema:
defmodule MyApp.Users.User do
use Ecto.Schema
...
end
And then you create a changeset that you pass to `to_form`:
%MyApp.Users.User{}
|> Ecto.Changeset.change()
|> to_form()
Once the form is submitted, the params will be available under `%{"user" => user_params}`.
In the template, the form form assign can be passed to the `<.form>` function component:
<.form for={@form} id="todo-form" phx-change="validate" phx-submit="save">
<.input field={@form[:field]} type="text" />
</.form>
Always give the form an explicit, unique DOM ID, like `id="todo-form"`.
#### Avoiding form errors
**Always** use a form assigned via `to_form/2` in the LiveView, and the `<.input>` component in the template. In the template **always access forms this**:
<%!-- ALWAYS do this (valid) --%>
<.form for={@form} id="my-form">
<.input field={@form[:field]} type="text" />
</.form>
And **never** do this:
<%!-- NEVER do this (invalid) --%>
<.form for={@changeset} id="my-form">
<.input field={@changeset[:field]} type="text" />
</.form>
- You are FORBIDDEN from accessing the changeset in the template as it will cause errors
- **Never** use `<.form let={f} ...>` in the template, instead **always use `<.form for={@form} ...>`**, then drive all form references from the form assign as in `@form[:field]`. The UI should **always** be driven by a `to_form/2` assigned in the LiveView module that is derived from a changeset
<!-- phoenix:liveview-end -->
<!-- usage-rules-end -->
This file exists for LLM-agnostic compatibility.

126
CLAUDE.md
View File

@ -50,6 +50,21 @@ Theme switching is instant via CSS custom property injection (no reload).
| `/products/:id` | Product detail |
| `/admin/theme` | Theme editor (auth required) |
## Elixir Guidelines
- Use `:req` library for HTTP requests (not httpoison, tesla, httpc)
- Lists don't support index access (`list[i]`), use `Enum.at/2`
- Access changeset fields with `Ecto.Changeset.get_field/2`, not `changeset[:field]`
- Preload associations in queries when needed in templates
- **Rebinding:** Must bind result of `if`/`case` blocks:
```elixir
# WRONG - rebinding inside block is lost
if connected?(socket), do: socket = assign(socket, :val, val)
# RIGHT - bind result to variable
socket = if connected?(socket), do: assign(socket, :val, val), else: socket
```
## Phoenix 1.8 Guidelines
- **Always** wrap LiveView templates with `<Layouts.app flash={@flash} ...>`
@ -57,15 +72,24 @@ Theme switching is instant via CSS custom property injection (no reload).
- Use `to_form/2` for all form handling, access via `@form[:field]`
- Use `<.input>` component from core_components.ex
- Use `<.icon name="hero-x-mark">` for icons, not Heroicons modules
- Place routes in correct `live_session` scope (`:current_user` or `:require_authenticated_user`)
## Elixir/Ecto Guidelines
### Auth Routing
- Use `:req` library for HTTP requests (not httpoison, tesla, httpc)
- Use streams for collections: `stream(socket, :items, items)`
- Lists don't support index access (`list[i]`), use `Enum.at/2`
- Access changeset fields with `Ecto.Changeset.get_field/2`, not `changeset[:field]`
- Preload associations in queries when needed in templates
Routes requiring auth go in `:require_authenticated_user` live_session:
```elixir
live_session :require_authenticated_user,
on_mount: [{SimpleshopThemeWeb.UserAuth, :require_authenticated}] do
live "/admin/theme", ThemeLive.Index
end
```
Public routes with optional user go in `:current_user` live_session:
```elixir
live_session :current_user,
on_mount: [{SimpleshopThemeWeb.UserAuth, :mount_current_scope}] do
live "/", ShopLive.Home
end
```
## HEEx Template Guidelines
@ -73,9 +97,97 @@ Theme switching is instant via CSS custom property injection (no reload).
- Class lists require bracket syntax: `class={["base", @cond && "extra"]}`
- Use `<%!-- comment --%>` for template comments
- Never use `else if` or `elsif` - use `cond` or `case`
- Use `phx-no-curly-interpolation` for literal braces in code blocks
- **Never** use `<% Enum.each %>` - always use `<%= for item <- @items do %>`
## LiveView Guidelines
### Streams (Required for Collections)
Always use streams for lists to prevent memory issues:
```elixir
# Mount
socket |> stream(:products, Products.list_products())
# Add item
socket |> stream_insert(:products, new_product)
# Reset (e.g., filtering)
socket |> stream(:products, filtered_list, reset: true)
# Delete
socket |> stream_delete(:products, product)
```
Template pattern:
```heex
<div id="products" phx-update="stream">
<div :for={{dom_id, product} <- @streams.products} id={dom_id}>
{product.title}
</div>
</div>
```
Empty state with Tailwind:
```heex
<div id="products" phx-update="stream">
<div class="hidden only:block">No products yet</div>
<div :for={{dom_id, product} <- @streams.products} id={dom_id}>...</div>
</div>
```
### Form Handling
From params:
```elixir
def handle_event("validate", %{"product" => params}, socket) do
{:noreply, assign(socket, form: to_form(params, as: :product))}
end
```
From changeset:
```elixir
changeset = Product.changeset(%Product{}, params)
socket |> assign(form: to_form(changeset))
```
### Gotchas
- `phx-hook` with DOM manipulation requires `phx-update="ignore"`
- Avoid LiveComponents unless you have a specific need (isolated state, targeted updates)
- Never use deprecated `phx-update="append"` or `phx-update="prepend"`
## JS/CSS Guidelines
- Tailwind v4 uses `@import "tailwindcss"` syntax (no tailwind.config.js)
- **Never** use `@apply` in CSS
- **Never** write inline `<script>` tags - use hooks in assets/js/
- All vendor deps must be imported into app.js/app.css
## LiveView Testing
- Use `element/2`, `has_element/2` - never test raw HTML
- Reference DOM IDs from templates in tests
- Debug with LazyHTML: `LazyHTML.filter(document, "selector")`
## Documentation Workflow
**Single source of truth:** [PROGRESS.md](PROGRESS.md)
- Update after completing any feature or task
- Contains current status, next steps, and task breakdown
- Link to plan files for implementation details
**Plan files** (docs/plans/*.md):
- Implementation references, not status trackers
- Mark status at top (e.g., "Status: Complete")
- Keep detailed architecture/design decisions
**Task sizing:**
- Break features into ~1-2 hour tasks
- Each task should fit in one Claude session without context overflow
- Include: files to modify, acceptance criteria, estimate
**Before starting work:**
1. Check PROGRESS.md for current status and next task
2. Read relevant plan file for implementation details
3. Focus on one task at a time

106
PROGRESS.md Normal file
View File

@ -0,0 +1,106 @@
# SimpleShop Progress
> Single source of truth for project status and task tracking.
## Current Status
**Working:**
- Theme editor with 8 presets, instant switching, full customization
- Image optimization pipeline (AVIF/WebP/JPEG responsive variants)
- Shop pages (home, collections, products, cart, about, contact)
- Mobile-first design with bottom navigation
- 100% PageSpeed score
**In Progress:**
- Products context with provider integration (Phase 1 complete)
## Next Up
1. Wire Products context to shop LiveViews (replace PreviewData)
2. Add Printify product sync worker
3. Session-based cart with real variants
---
## Feature Areas
### Theme System
**Status:** Complete
- 8 theme presets (Gallery, Studio, Boutique, etc.)
- Three-layer CSS architecture (primitives, attributes, semantic)
- Instant theme switching via CSS custom property injection
- Logo/header image uploads with SVG recoloring
- Self-hosted fonts (10 typefaces, GDPR compliant)
- ETS-cached CSS generation
### Image Optimization
**Status:** Complete
- Oban background job processing
- Responsive `<picture>` element (AVIF/WebP/JPEG)
- Only generates sizes <= source dimensions
- Disk cache for variants (regenerable from DB)
- `mix optimize_images` task for mockups
- On-demand JPEG fallback generation
See: [docs/plans/image-optimization.md](docs/plans/image-optimization.md) for implementation details
### Products & Provider Integration
**Status:** In Progress
#### Completed
- [x] Products context with schemas (c5c06d9)
- [x] Provider abstraction layer
- [x] Printify client integration
- [x] Product/variant/image schemas
#### Remaining Tasks
- [ ] Add `list_products/1` to Printify client (~1hr)
- [ ] Create ProductSyncWorker (Oban) (~1hr)
- [ ] 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
### Cart & Checkout
**Status:** Planned
- [ ] Session-based cart module
- [ ] Cart LiveView with real variants
- [ ] Stripe Checkout integration
- [ ] Order creation and persistence
See: [ROADMAP.md](ROADMAP.md) for design notes
### Orders & Fulfillment
**Status:** Planned
- [ ] Orders context with schemas
- [ ] Order submission to Printify
- [ ] Order status tracking
- [ ] Customer notifications
See: [docs/plans/products-context.md](docs/plans/products-context.md) for schema design
### Page Builder
**Status:** Future
Database-driven pages with drag-and-drop sections.
See: [docs/plans/page-builder.md](docs/plans/page-builder.md) for design
---
## Completed Work Reference
| Feature | Commit | Notes |
|---------|--------|-------|
| Products context Phase 1 | c5c06d9 | Schemas, provider abstraction |
| Oban Lifeline plugin | c1e1988 | Rescue orphaned jobs |
| Image optimization | Multiple | Full pipeline complete |
| Self-hosted fonts | - | 10 typefaces, 728KB |
| Mobile bottom nav | - | Fixed tab bar |
| PageSpeed 100% | - | All optimizations |
| Theme presets (8) | - | Gallery, Studio, etc. |

View File

@ -1,195 +1,31 @@
# SimpleShop Roadmap
This document tracks future improvements, features, and known gaps.
> Vision and future features. For current status, see [PROGRESS.md](PROGRESS.md).
---
## Core MVP: Real Products & Checkout (Priority)
## Core MVP: Cart & Checkout
This section covers the work needed to turn SimpleShop from a theme demo into a working e-commerce storefront. Estimated total effort: **3-4 days** (leveraging existing Printify demo code).
### Session-Based Cart
Store cart in Phoenix session (no separate table needed for MVP).
### Phase A: Products Context + Printify Sync
**Status:** Not implemented
**Effort:** 1-1.5 days
**Dependencies:** None
Replace `PreviewData` with real products synced from Printify.
**Schemas:**
```elixir
# products table
field :printify_id, :string
field :title, :string
field :description, :text
field :images, {:array, :map} # [{src, position}]
field :print_provider_id, :integer
field :blueprint_id, :integer
field :synced_at, :utc_datetime
field :published, :boolean, default: true
timestamps()
# product_variants table
field :printify_variant_id, :integer
field :title, :string # e.g. "Black / M"
field :sku, :string
field :price_cents, :integer # selling price
field :cost_cents, :integer # Printify cost (for profit calc)
field :options, :map # %{"Color" => "Black", "Size" => "M"}
field :is_available, :boolean
belongs_to :product
# product_cost_history table (append-only for future analytics)
field :cost_cents, :integer
field :recorded_at, :utc_datetime
belongs_to :product_variant
```
**Implementation:**
1. Migrate OAuth + Client modules from `simpleshop_printify` demo
- Adapt `Simpleshop.Printify.OAuth``SimpleshopTheme.Printify.OAuth`
- Adapt `Simpleshop.Printify.Client``SimpleshopTheme.Printify.Client`
- Adapt `Simpleshop.Printify.TokenStore``SimpleshopTheme.Printify.TokenStore`
2. Create `SimpleshopTheme.Products` context with schemas
3. Add `mix sync_products` task to pull products from Printify
4. Add webhook endpoint for `product:publish:started` events
5. Replace `PreviewData` calls in LiveViews with `Products` context queries
6. Store cost history on each sync for future profit analytics
**Webhook flow:**
1. Seller clicks "Publish to SimpleShop" in Printify dashboard
2. Printify fires `product:publish:started` with product data
3. SimpleShop stores product locally in SQLite
4. SimpleShop calls Printify "publish succeeded" endpoint
**Files to create:**
- `lib/simpleshop_theme/printify/oauth.ex`
- `lib/simpleshop_theme/printify/token_store.ex`
- `lib/simpleshop_theme/products.ex`
- `lib/simpleshop_theme/products/product.ex`
- `lib/simpleshop_theme/products/variant.ex`
- `lib/simpleshop_theme/products/cost_history.ex`
- `lib/simpleshop_theme_web/controllers/printify_webhook_controller.ex`
- `lib/mix/tasks/sync_products.ex`
- `priv/repo/migrations/*_create_products.exs`
---
### Phase B: Session-Based Cart
**Status:** Not implemented
**Effort:** 0.5 days
**Dependencies:** Phase A (Products)
Real cart functionality with session persistence.
**Approach:** Store cart in Phoenix session (no separate cart table needed for MVP). Cart is a map of `%{variant_id => quantity}` stored in the session.
**Implementation:**
```elixir
# lib/simpleshop_theme/cart.ex
defmodule SimpleshopTheme.Cart do
alias SimpleshopTheme.Products
def get(session), do: Map.get(session, "cart", %{})
def add_item(session, variant_id, quantity \\ 1)
def remove_item(session, variant_id)
def update_quantity(session, variant_id, quantity)
def clear(session)
def to_line_items(cart) do
# Returns list of %{variant: variant, quantity: qty, subtotal: price}
end
def total(cart) # Returns total in cents
def item_count(cart) # For header badge
def to_line_items(cart)
def total(cart)
def item_count(cart)
end
```
**LiveView integration:**
- Add `phx-click="add_to_cart"` to product pages
- Update cart LiveView to use real data
- Add cart count to header (assign in `on_mount`)
**Files to create/modify:**
- `lib/simpleshop_theme/cart.ex`
- Modify `lib/simpleshop_theme_web/live/shop_live/product_show.ex`
- Modify `lib/simpleshop_theme_web/live/shop_live/cart.ex`
- Modify `lib/simpleshop_theme_web/components/layouts.ex` (cart count)
---
### Phase C: Stripe Checkout
**Status:** Not implemented
**Effort:** 0.5-1 day
**Dependencies:** Phase B (Cart)
### Stripe Checkout
Stripe Checkout (hosted payment page) integration.
**Dependencies to add:**
```elixir
# mix.exs
{:stripity_stripe, "~> 3.0"}
```
**Config:**
```elixir
# config/runtime.exs
config :stripity_stripe,
api_key: System.get_env("STRIPE_SECRET_KEY")
# Also need STRIPE_WEBHOOK_SECRET for webhook verification
```
**Implementation:**
```elixir
# lib/simpleshop_theme/checkout.ex
defmodule SimpleshopTheme.Checkout do
def create_session(cart, success_url, cancel_url) do
line_items = Enum.map(cart, fn {variant_id, qty} ->
variant = Products.get_variant!(variant_id)
%{
price_data: %{
currency: "gbp",
unit_amount: variant.price_cents,
product_data: %{
name: "#{variant.product.title} - #{variant.title}",
images: [hd(variant.product.images)["src"]]
}
},
quantity: qty
}
end)
Stripe.Checkout.Session.create(%{
mode: "payment",
line_items: line_items,
success_url: success_url <> "?session_id={CHECKOUT_SESSION_ID}",
cancel_url: cancel_url,
shipping_address_collection: %{allowed_countries: ["GB"]},
metadata: %{cart: Jason.encode!(cart)}
})
end
end
```
**Webhook handler:**
```elixir
# lib/simpleshop_theme_web/controllers/stripe_webhook_controller.ex
def handle_event(%Stripe.Event{type: "checkout.session.completed"} = event) do
session = event.data.object
cart = Jason.decode!(session.metadata["cart"])
shipping = session.shipping_details
# Create order and push to Printify (Phase D)
Orders.create_from_checkout(session, cart, shipping)
end
```
**Files to create:**
- `lib/simpleshop_theme/checkout.ex`
- `lib/simpleshop_theme_web/controllers/stripe_webhook_controller.ex`
- `lib/simpleshop_theme_web/live/shop_live/checkout_success.ex`
- `lib/simpleshop_theme_web/live/shop_live/checkout_cancel.ex`
**Dependencies:** `{:stripity_stripe, "~> 3.0"}`
**Routes:**
```elixir
@ -198,177 +34,34 @@ live "/checkout/success", ShopLive.CheckoutSuccess
live "/checkout/cancel", ShopLive.CheckoutCancel
```
---
### Phase D: Orders + Printify Fulfillment
**Status:** Not implemented
**Effort:** 0.5-1 day
**Dependencies:** Phase C (Stripe Checkout)
Create orders locally and push to Printify for fulfillment.
**Schema:**
```elixir
# orders table
field :stripe_session_id, :string
field :stripe_payment_intent_id, :string
field :printify_order_id, :string
field :status, Ecto.Enum, values: [:pending, :paid, :submitted, :in_production, :shipped, :delivered, :cancelled]
field :total_cents, :integer
field :shipping_address, :map
field :customer_email, :string
timestamps()
# order_items table
field :quantity, :integer
field :unit_price_cents, :integer
field :unit_cost_cents, :integer # For profit tracking
belongs_to :order
belongs_to :product_variant
```
**Implementation:**
```elixir
# lib/simpleshop_theme/orders.ex
def create_from_checkout(stripe_session, cart, shipping) do
Repo.transaction(fn ->
# 1. Create local order
order = create_order(stripe_session, shipping)
# 2. Create order items
create_order_items(order, cart)
# 3. Push to Printify
case Printify.Client.create_order(order) do
{:ok, printify_response} ->
update_order(order, %{
printify_order_id: printify_response["id"],
status: :submitted
})
{:error, reason} ->
# Log error but don't fail - can retry later
Logger.error("Failed to submit to Printify: #{inspect(reason)}")
update_order(order, %{status: :paid}) # Mark as paid, needs manual submission
end
end)
end
```
**Printify order creation:**
```elixir
# Add to lib/simpleshop_theme/printify/client.ex
def create_order(order) do
body = %{
external_id: order.id,
shipping_method: 1, # Standard
address_to: %{
first_name: order.shipping_address["name"] |> String.split() |> hd(),
last_name: order.shipping_address["name"] |> String.split() |> List.last(),
email: order.customer_email,
address1: order.shipping_address["line1"],
address2: order.shipping_address["line2"],
city: order.shipping_address["city"],
zip: order.shipping_address["postal_code"],
country: order.shipping_address["country"]
},
line_items: Enum.map(order.items, fn item ->
%{
product_id: item.variant.product.printify_id,
variant_id: item.variant.printify_variant_id,
quantity: item.quantity
}
end)
}
post("/shops/#{shop_id()}/orders.json", body)
end
```
**Files to create:**
- `lib/simpleshop_theme/orders.ex`
- `lib/simpleshop_theme/orders/order.ex`
- `lib/simpleshop_theme/orders/order_item.ex`
- `priv/repo/migrations/*_create_orders.exs`
- Update `lib/simpleshop_theme/printify/client.ex` with `create_order/1`
---
### Phase E: Cost Verification at Checkout (Safety Net)
**Status:** Not implemented
**Effort:** 0.25 days
**Dependencies:** Phase D (Orders)
Verify Printify costs haven't changed before completing checkout.
**Implementation:**
```elixir
# In checkout flow, before creating Stripe session
def verify_costs(cart) do
Enum.reduce_while(cart, :ok, fn {variant_id, _qty}, _acc ->
variant = Products.get_variant!(variant_id)
case Printify.Client.get_product(variant.product.printify_id) do
{:ok, printify_product} ->
current_cost = find_variant_cost(printify_product, variant.printify_variant_id)
if current_cost != variant.cost_cents do
# Update local cost
Products.update_variant_cost(variant, current_cost)
if cost_increase_exceeds_threshold?(variant.cost_cents, current_cost) do
{:halt, {:error, :costs_changed, variant}}
else
{:cont, :ok}
end
else
{:cont, :ok}
end
{:error, _} ->
# Can't verify - proceed but log warning
Logger.warning("Could not verify costs for variant #{variant_id}")
{:cont, :ok}
end
end)
end
```
This ensures sellers never unknowingly sell at a loss due to Printify price changes.
---
### Cost Verification at Checkout
Verify Printify costs haven't changed before completing checkout to prevent selling at a loss.
---
## Medium Features
### Page Builder (Database-Driven Pages)
**Status:** Planned (see `docs/plans/page-builder.md`)
**Effort:** Large
Allow shop owners to build custom pages by combining pre-built sections:
- Hero, Featured Products, Testimonials, Newsletter, etc.
- Drag-and-drop section ordering
### Page Builder
Database-driven pages with drag-and-drop sections:
- Hero, Featured Products, Testimonials, Newsletter
- Per-section configuration
- Database-backed page storage
- See: [docs/plans/page-builder.md](docs/plans/page-builder.md)
---
## Future Features (Large Scope)
## Future Features
### Multi-Admin Support
Currently single-user authentication:
- Multiple admin users
- Role-based permissions
- Audit logging
### Custom Domains
Allow shops to use their own domain:
- Domain verification
- SSL certificate provisioning
- DNS configuration guidance
### Theme Export/Import
Backup and restore theme settings:
- JSON export of all settings
- Import with validation
- Preset sharing between shops
@ -378,7 +71,7 @@ Backup and restore theme settings:
- Custom JavaScript snippets
- Code-level overrides for developers
### Multi-Provider Support (Future)
### Multi-Provider Support
Support multiple POD providers beyond Printify:
- Prodigi (better for art prints)
- Gelato (global fulfillment)
@ -390,7 +83,7 @@ Support multiple POD providers beyond Printify:
## Technical Debt
### Test Coverage
Phase 9 testing is basic. Areas needing better coverage:
Areas needing better coverage:
- Shop LiveView integration tests
- CSS cache invalidation flow
- Theme application across all pages
@ -402,113 +95,5 @@ Phase 9 testing is basic. Areas needing better coverage:
- Graceful degradation when theme settings are invalid
- Network error handling in LiveView
### Rename Project to SimpleShop
**Status:** Not implemented
**Effort:** Medium
The project is currently named `simpleshop_theme` (reflecting its origins as a theme system), but it's now a full e-commerce storefront. Rename to `simple_shop` or `simpleshop` to reflect this.
**Files to update:**
- `mix.exs` - app name
- `lib/simpleshop_theme/``lib/simple_shop/`
- `lib/simpleshop_theme_web/``lib/simple_shop_web/`
- All module names (`SimpleshopTheme` → `SimpleShop`)
- `config/*.exs` - endpoint and repo references
- `test/` directories
- Database file name
---
## Completed (For Reference)
### Sample Content ("Wildprint Studio") ✅
- 16 POD products across 5 categories
- Nature/botanical theme with testimonials
- UK-focused (prices in £)
- Printify API integration for mockup generation (`mix generate_mockups`)
### Phase 1-8: Theme Editor ✅
- Theme settings schema and persistence
- CSS three-layer architecture
- 8 theme presets
- All customisation controls
- Logo/header image uploads
- SVG recolouring
- Preview system with 7 pages
### Phase 9: Storefront Integration ✅
- Public shop routes (/, /collections/:slug, /products/:id, /cart, /about, /contact)
- Shared PageTemplates for shop and preview
- CSS injection via shop layout
- Themed error pages (404/500)
- Dev routes for error page preview
### CSS Cache Warming on Startup ✅
- ETS cache pre-warmed in `CSSCache.init/1`
- First request doesn't need to generate CSS
### Navigation Links Between Admin and Shop ✅
- "View Shop" button in theme editor header
- Collapsible navigation sidebar in theme editor
### Collection Routes with Filtering & Sorting ✅
- `/collections/:slug` routes for category filtering
- `/collections/all` for all products
- Product sorting (featured, newest, price, name)
- Sort parameter preserved in URL across navigation
- Category filter pills with sort persistence
### Header Navigation Accessibility ✅
- Current page is not a link (avoids self-links)
- Logo links to home except when on home page
- `aria-current="page"` with visual underline indicator
### Enhanced Contact Page ✅
- `newsletter_card` component with `:card` and `:inline` variants (shared with footer)
- `social_links_card` component with icon + text label cards
- Contact form with integrated email link and response time
- Reorganized layout: contact form left, info cards right
### Mobile Bottom Navigation ✅
- Fixed bottom tab bar for thumb-friendly mobile navigation
- Icons + labels for Home, Shop, About, Contact
- Active page has accent-colored background highlight
- Shadow above nav for visual separation
- Hidden on desktop (≥768px), replaces header nav on mobile
- Works in both live shop and theme preview modes
### Self-Hosted Fonts ✅
- Removed Google Fonts external dependency
- All 10 typefaces (35 font files, 728KB) served from `/fonts/`
- Privacy improvement (no Google tracking)
- Performance improvement (no DNS lookup to fonts.googleapis.com)
- GDPR compliant (no third-party requests)
### Admin Access Route ✅
- `/admin` redirects to `/admin/theme` (requires auth)
- Shop owners can bookmark or type `/admin` to access
### PageSpeed 100% Score ✅
- Self-hosted fonts (removed Google Fonts external dependency)
- Production asset pipeline (minified, gzipped CSS/JS)
- Proper image dimensions on all `<img>` tags (CLS prevention)
- Critical font preloading via `<link rel="preload">`
- Lazy loading for below-fold images
- `fetchpriority="high"` on hero/priority images via `responsive_image` component
- Responsive image variants (AVIF/WebP/JPEG) via image optimization pipeline
- Shop CSS reduced from 122KB to 36KB (split bundles)
- Cache headers (`max-age=31536000, immutable`) on static assets
### Image Optimization Pipeline ✅
- Oban background job processing for variant generation
- Responsive `<picture>` element with AVIF/WebP/JPEG sources
- Only generates sizes ≤ source dimensions (no upscaling)
- Disk cache for variants (regenerable from DB)
- Mix task for mockup optimization
- On-demand JPEG fallback generation
### Themed Form Components ✅
- Semantic CSS classes (`.themed-input`, `.themed-button`, `.themed-card`, etc.)
- Phoenix components (`shop_input`, `shop_button`, `shop_card`, etc.)
- Consistent styling across all shop forms
- Reduced repeated inline styles
### Rename Project
The project is named `simpleshop_theme` but it's now a full storefront. Consider renaming to `simple_shop`.

View File

@ -1,29 +1,8 @@
# Plan: Automatic Image Optimization Pipeline
**Location:** `docs/plans/image-optimization.md`
**Purpose:** Track implementation progress - update this file after each phase completes.
> **Status:** Complete - See [PROGRESS.md](../../PROGRESS.md) for current status.
---
## Progress Tracker
**Current Phase:** Complete
**Last Updated:** 2026-01-21
| Phase | Status | Commit |
|-------|--------|--------|
| 1. Oban dependency + config | ✅ Complete | dbadd2a |
| 2. Migration + Schema | ✅ Complete | cefec1a |
| 3. Optimizer module | ✅ Complete | 2b5b749 |
| 4. Oban worker | ✅ Complete | 2b5b749 |
| 5. Media module integration | ✅ Complete | (pending commit) |
| 6. VariantCache GenServer | ✅ Complete | (pending commit) |
| 7. Responsive image component | ✅ Complete | (pending commit) |
| 8. ImageController disk serving | ✅ Complete | (pending commit) |
| 9. Mix task for mockups | ✅ Complete | (pending commit) |
| 10. Final integration + Lighthouse | ✅ Complete | (pending commit) |
**Legend:** ⬜ Pending | 🔄 In Progress | ✅ Complete | ❌ Blocked
This document contains implementation details for reference.
---

View File

@ -1,5 +1,7 @@
# Plan: Products Context with Provider Integration
> **Status:** Phase 1 Complete (c5c06d9) - See [PROGRESS.md](../../PROGRESS.md) for current status.
## Goal
Build a Products context that syncs products from external POD providers (Printify first), stores them locally, and enables order submission for fulfillment.