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
|
This file exists for LLM-agnostic compatibility.
|
||||||
- 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 -->
|
|
||||||
|
|||||||
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 |
|
| `/products/:id` | Product detail |
|
||||||
| `/admin/theme` | Theme editor (auth required) |
|
| `/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
|
## Phoenix 1.8 Guidelines
|
||||||
|
|
||||||
- **Always** wrap LiveView templates with `<Layouts.app flash={@flash} ...>`
|
- **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 `to_form/2` for all form handling, access via `@form[:field]`
|
||||||
- Use `<.input>` component from core_components.ex
|
- Use `<.input>` component from core_components.ex
|
||||||
- Use `<.icon name="hero-x-mark">` for icons, not Heroicons modules
|
- 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)
|
Routes requiring auth go in `:require_authenticated_user` live_session:
|
||||||
- Use streams for collections: `stream(socket, :items, items)`
|
```elixir
|
||||||
- Lists don't support index access (`list[i]`), use `Enum.at/2`
|
live_session :require_authenticated_user,
|
||||||
- Access changeset fields with `Ecto.Changeset.get_field/2`, not `changeset[:field]`
|
on_mount: [{SimpleshopThemeWeb.UserAuth, :require_authenticated}] do
|
||||||
- Preload associations in queries when needed in templates
|
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
|
## 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"]}`
|
- Class lists require bracket syntax: `class={["base", @cond && "extra"]}`
|
||||||
- Use `<%!-- comment --%>` for template comments
|
- Use `<%!-- comment --%>` for template comments
|
||||||
- Never use `else if` or `elsif` - use `cond` or `case`
|
- 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
|
## LiveView Testing
|
||||||
|
|
||||||
- Use `element/2`, `has_element/2` - never test raw HTML
|
- Use `element/2`, `has_element/2` - never test raw HTML
|
||||||
- Reference DOM IDs from templates in tests
|
- Reference DOM IDs from templates in tests
|
||||||
- Debug with LazyHTML: `LazyHTML.filter(document, "selector")`
|
- 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
|
# 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
|
```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
|
defmodule SimpleshopTheme.Cart do
|
||||||
alias SimpleshopTheme.Products
|
|
||||||
|
|
||||||
def get(session), do: Map.get(session, "cart", %{})
|
def get(session), do: Map.get(session, "cart", %{})
|
||||||
|
|
||||||
def add_item(session, variant_id, quantity \\ 1)
|
def add_item(session, variant_id, quantity \\ 1)
|
||||||
def remove_item(session, variant_id)
|
def remove_item(session, variant_id)
|
||||||
def update_quantity(session, variant_id, quantity)
|
def update_quantity(session, variant_id, quantity)
|
||||||
def clear(session)
|
def clear(session)
|
||||||
|
def to_line_items(cart)
|
||||||
def to_line_items(cart) do
|
def total(cart)
|
||||||
# Returns list of %{variant: variant, quantity: qty, subtotal: price}
|
def item_count(cart)
|
||||||
end
|
|
||||||
|
|
||||||
def total(cart) # Returns total in cents
|
|
||||||
def item_count(cart) # For header badge
|
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
**LiveView integration:**
|
### Stripe Checkout
|
||||||
- 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 (hosted payment page) integration.
|
Stripe Checkout (hosted payment page) integration.
|
||||||
|
|
||||||
**Dependencies to add:**
|
**Dependencies:** `{:stripity_stripe, "~> 3.0"}`
|
||||||
```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`
|
|
||||||
|
|
||||||
**Routes:**
|
**Routes:**
|
||||||
```elixir
|
```elixir
|
||||||
@ -198,177 +34,34 @@ live "/checkout/success", ShopLive.CheckoutSuccess
|
|||||||
live "/checkout/cancel", ShopLive.CheckoutCancel
|
live "/checkout/cancel", ShopLive.CheckoutCancel
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### Cost Verification at Checkout
|
||||||
|
Verify Printify costs haven't changed before completing checkout to prevent selling at a loss.
|
||||||
### 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.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Medium Features
|
## Medium Features
|
||||||
|
|
||||||
### Page Builder (Database-Driven Pages)
|
### Page Builder
|
||||||
**Status:** Planned (see `docs/plans/page-builder.md`)
|
Database-driven pages with drag-and-drop sections:
|
||||||
**Effort:** Large
|
- Hero, Featured Products, Testimonials, Newsletter
|
||||||
|
|
||||||
Allow shop owners to build custom pages by combining pre-built sections:
|
|
||||||
- Hero, Featured Products, Testimonials, Newsletter, etc.
|
|
||||||
- Drag-and-drop section ordering
|
|
||||||
- Per-section configuration
|
- 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
|
### Multi-Admin Support
|
||||||
Currently single-user authentication:
|
|
||||||
- Multiple admin users
|
- Multiple admin users
|
||||||
- Role-based permissions
|
- Role-based permissions
|
||||||
- Audit logging
|
- Audit logging
|
||||||
|
|
||||||
### Custom Domains
|
### Custom Domains
|
||||||
Allow shops to use their own domain:
|
|
||||||
- Domain verification
|
- Domain verification
|
||||||
- SSL certificate provisioning
|
- SSL certificate provisioning
|
||||||
- DNS configuration guidance
|
- DNS configuration guidance
|
||||||
|
|
||||||
### Theme Export/Import
|
### Theme Export/Import
|
||||||
Backup and restore theme settings:
|
|
||||||
- JSON export of all settings
|
- JSON export of all settings
|
||||||
- Import with validation
|
- Import with validation
|
||||||
- Preset sharing between shops
|
- Preset sharing between shops
|
||||||
@ -378,7 +71,7 @@ Backup and restore theme settings:
|
|||||||
- Custom JavaScript snippets
|
- Custom JavaScript snippets
|
||||||
- Code-level overrides for developers
|
- Code-level overrides for developers
|
||||||
|
|
||||||
### Multi-Provider Support (Future)
|
### Multi-Provider Support
|
||||||
Support multiple POD providers beyond Printify:
|
Support multiple POD providers beyond Printify:
|
||||||
- Prodigi (better for art prints)
|
- Prodigi (better for art prints)
|
||||||
- Gelato (global fulfillment)
|
- Gelato (global fulfillment)
|
||||||
@ -390,7 +83,7 @@ Support multiple POD providers beyond Printify:
|
|||||||
## Technical Debt
|
## Technical Debt
|
||||||
|
|
||||||
### Test Coverage
|
### Test Coverage
|
||||||
Phase 9 testing is basic. Areas needing better coverage:
|
Areas needing better coverage:
|
||||||
- Shop LiveView integration tests
|
- Shop LiveView integration tests
|
||||||
- CSS cache invalidation flow
|
- CSS cache invalidation flow
|
||||||
- Theme application across all pages
|
- 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
|
- Graceful degradation when theme settings are invalid
|
||||||
- Network error handling in LiveView
|
- Network error handling in LiveView
|
||||||
|
|
||||||
### Rename Project to SimpleShop
|
### Rename Project
|
||||||
**Status:** Not implemented
|
The project is named `simpleshop_theme` but it's now a full storefront. Consider renaming to `simple_shop`.
|
||||||
**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
|
|
||||||
|
|||||||
@ -1,29 +1,8 @@
|
|||||||
# Plan: Automatic Image Optimization Pipeline
|
# Plan: Automatic Image Optimization Pipeline
|
||||||
|
|
||||||
**Location:** `docs/plans/image-optimization.md`
|
> **Status:** Complete - See [PROGRESS.md](../../PROGRESS.md) for current status.
|
||||||
**Purpose:** Track implementation progress - update this file after each phase completes.
|
|
||||||
|
|
||||||
---
|
This document contains implementation details for reference.
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
# Plan: Products Context with Provider Integration
|
# Plan: Products Context with Provider Integration
|
||||||
|
|
||||||
|
> **Status:** Phase 1 Complete (c5c06d9) - See [PROGRESS.md](../../PROGRESS.md) for current status.
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
Build a Products context that syncs products from external POD providers (Printify first), stores them locally, and enables order submission for fulfillment.
|
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