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:
parent
153f3d049f
commit
d97918d66a
396
AGENTS.md
396
AGENTS.md
@ -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
126
CLAUDE.md
@ -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
106
PROGRESS.md
Normal 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. |
|
||||
455
ROADMAP.md
455
ROADMAP.md
@ -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`.
|
||||
|
||||
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user