feat: add Stripe checkout, order persistence, and webhook handling
Stripe-hosted Checkout integration with full order lifecycle: - stripity_stripe ~> 3.2 with sandbox/prod config via env vars - Order and OrderItem schemas with price snapshots at purchase time - CheckoutController creates pending order then redirects to Stripe - StripeWebhookController verifies signatures and confirms payment - Success page with real-time PubSub updates from webhook - Shop flash messages for checkout error feedback - Cart cleared after successful payment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
cff21703f1
commit
ff1bc483b9
47
PROGRESS.md
47
PROGRESS.md
@ -7,19 +7,22 @@
|
|||||||
**Working:**
|
**Working:**
|
||||||
- Theme editor with 8 presets, instant switching, full customization
|
- Theme editor with 8 presets, instant switching, full customization
|
||||||
- Image optimization pipeline (AVIF/WebP/JPEG responsive variants)
|
- Image optimization pipeline (AVIF/WebP/JPEG responsive variants)
|
||||||
- Shop pages (home, collections, products, cart, about, contact)
|
- Shop pages (home, collections, products, cart, about, contact, error)
|
||||||
- Mobile-first design with bottom navigation
|
- Mobile-first design with bottom navigation
|
||||||
- 100% PageSpeed score
|
- 100% PageSpeed score
|
||||||
- Variant selector with color swatches and size buttons
|
- Variant selector with color swatches and size buttons
|
||||||
|
- Session-based cart with real variant data (add/remove/quantity, cross-tab sync)
|
||||||
|
- Cart drawer and cart page with hydrated product info
|
||||||
|
- Search modal with keyboard shortcut
|
||||||
|
- Demo content polished and ready for production
|
||||||
|
|
||||||
**In Progress:**
|
**Next Up:**
|
||||||
- Session-based cart
|
- Orders & Fulfillment (Printify submission)
|
||||||
|
|
||||||
## Next Up
|
## Next Up
|
||||||
|
|
||||||
1. **Session-based Cart** - Real cart with actual variants
|
1. **Orders & Fulfillment** - Submit orders to Printify after payment
|
||||||
2. **Stripe Checkout Integration** - Payment processing
|
2. **Email Notifications** - Order confirmation emails
|
||||||
3. **Orders & Fulfillment** - Submit orders to Printify
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -87,19 +90,37 @@ See: [docs/plans/products-context.md](docs/plans/products-context.md) for implem
|
|||||||
See: [docs/plans/printify-integration-research.md](docs/plans/printify-integration-research.md) for API research & risk analysis
|
See: [docs/plans/printify-integration-research.md](docs/plans/printify-integration-research.md) for API research & risk analysis
|
||||||
|
|
||||||
### Cart & Checkout
|
### Cart & Checkout
|
||||||
**Status:** Planned
|
**Status:** In Progress
|
||||||
|
|
||||||
- [ ] Session-based cart module
|
- [x] Cart drawer component with slide-over panel (f244a42)
|
||||||
- [ ] Cart LiveView with real variants
|
- [x] Cart page with item list and order summary (f244a42)
|
||||||
- [ ] Stripe Checkout integration
|
- [x] Shared CartHook for cross-page cart events (f244a42)
|
||||||
- [ ] Order creation and persistence
|
- [x] CartPersist JS hook for localStorage backup
|
||||||
|
- [x] Add-to-cart with flash status feedback
|
||||||
|
- [x] Cart item links to product pages
|
||||||
|
- [x] Session-based cart with real variants (f244a42)
|
||||||
|
- Cart stores {variant_id, qty} tuples in session
|
||||||
|
- Hydrates with real product data via Products context
|
||||||
|
- Cross-tab sync via PubSub, session persistence via CartController API
|
||||||
|
- [x] Stripe Checkout integration (stripity_stripe ~> 3.2)
|
||||||
|
- Stripe-hosted Checkout with redirect flow
|
||||||
|
- Webhook handler for checkout.session.completed/expired
|
||||||
|
- Signature verification via CacheRawBody + construct_event
|
||||||
|
- Shipping address collection during checkout
|
||||||
|
- [x] Order/OrderItem schemas and context
|
||||||
|
- Order number format: SS-YYMMDD-XXXX
|
||||||
|
- Payment status tracking (pending → paid/failed)
|
||||||
|
- Price snapshots in OrderItem (protects against changes)
|
||||||
|
- Idempotent webhook processing
|
||||||
|
- [x] Checkout success page with real-time PubSub updates
|
||||||
|
- [x] Cart clearing after successful payment
|
||||||
|
|
||||||
See: [ROADMAP.md](ROADMAP.md) for design notes
|
See: [ROADMAP.md](ROADMAP.md) for design notes
|
||||||
|
|
||||||
### Orders & Fulfillment
|
### Orders & Fulfillment
|
||||||
**Status:** Planned
|
**Status:** Planned
|
||||||
|
|
||||||
- [ ] Orders context with schemas
|
- [x] Orders context with schemas
|
||||||
- [ ] Order submission to Printify
|
- [ ] Order submission to Printify
|
||||||
- [ ] Order status tracking
|
- [ ] Order status tracking
|
||||||
- [ ] Customer notifications
|
- [ ] Customer notifications
|
||||||
@ -119,6 +140,8 @@ See: [docs/plans/page-builder.md](docs/plans/page-builder.md) for design
|
|||||||
|
|
||||||
| Feature | Commit | Notes |
|
| Feature | Commit | Notes |
|
||||||
|---------|--------|-------|
|
|---------|--------|-------|
|
||||||
|
| Demo content & link fixes | cff2170 | Broken links, placeholder text, responsive about image |
|
||||||
|
| Cart UI infrastructure | f244a42 | Cart drawer, cart page, CartHook, CartPersist |
|
||||||
| Variant selector | 880e7a2 | Color swatches, size buttons, price updates |
|
| Variant selector | 880e7a2 | Color swatches, size buttons, price updates |
|
||||||
| Product image download | 1b49b47 | PageSpeed 100% with local images |
|
| Product image download | 1b49b47 | PageSpeed 100% with local images |
|
||||||
| Wire shop to real data | c818d03 | PreviewData uses Products context |
|
| Wire shop to real data | c818d03 | PreviewData uses Products context |
|
||||||
|
|||||||
39
README.md
39
README.md
@ -10,6 +10,7 @@ A complete storefront with all the pages you need:
|
|||||||
- **Products** - Grid layout with hover effects and filtering
|
- **Products** - Grid layout with hover effects and filtering
|
||||||
- **Product Detail** - Image gallery, variants, reviews, related products
|
- **Product Detail** - Image gallery, variants, reviews, related products
|
||||||
- **Cart** - Full shopping cart with order summary
|
- **Cart** - Full shopping cart with order summary
|
||||||
|
- **Checkout** - Stripe-hosted checkout with order confirmation
|
||||||
- **About** - Rich content with your brand story
|
- **About** - Rich content with your brand story
|
||||||
- **Contact** - Contact form with business details
|
- **Contact** - Contact form with business details
|
||||||
- **Error pages** - Themed 404/500 pages
|
- **Error pages** - Themed 404/500 pages
|
||||||
@ -110,12 +111,50 @@ assets/css/
|
|||||||
| `/collections/:slug` | Category collection (filterable) |
|
| `/collections/:slug` | Category collection (filterable) |
|
||||||
| `/products/:id` | Product detail page |
|
| `/products/:id` | Product detail page |
|
||||||
| `/cart` | Shopping cart |
|
| `/cart` | Shopping cart |
|
||||||
|
| `/checkout` | Create Stripe session (POST) |
|
||||||
|
| `/checkout/success` | Order confirmation |
|
||||||
|
| `/webhooks/stripe` | Stripe webhook endpoint |
|
||||||
| `/about` | About page |
|
| `/about` | About page |
|
||||||
| `/contact` | Contact page |
|
| `/contact` | Contact page |
|
||||||
| `/admin/theme` | Theme editor (requires auth) |
|
| `/admin/theme` | Theme editor (requires auth) |
|
||||||
| `/dev/errors/404` | Preview 404 page (dev only) |
|
| `/dev/errors/404` | Preview 404 page (dev only) |
|
||||||
| `/dev/errors/500` | Preview 500 page (dev only) |
|
| `/dev/errors/500` | Preview 500 page (dev only) |
|
||||||
|
|
||||||
|
## Stripe Checkout
|
||||||
|
|
||||||
|
SimpleShop uses [Stripe Checkout](https://stripe.com/docs/payments/checkout) (hosted payment page) for secure payment processing.
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
1. Create a [Stripe account](https://dashboard.stripe.com/register)
|
||||||
|
2. Get your API keys from the [Stripe Dashboard](https://dashboard.stripe.com/test/apikeys)
|
||||||
|
3. Set environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export STRIPE_SECRET_KEY="sk_test_..."
|
||||||
|
export STRIPE_WEBHOOK_SECRET="whsec_..."
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local webhook testing
|
||||||
|
|
||||||
|
Use the [Stripe CLI](https://stripe.com/docs/stripe-cli) to forward webhooks to your local server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
stripe listen --forward-to localhost:4000/webhooks/stripe
|
||||||
|
```
|
||||||
|
|
||||||
|
The CLI will print a webhook signing secret — use that as `STRIPE_WEBHOOK_SECRET`.
|
||||||
|
|
||||||
|
### Test cards
|
||||||
|
|
||||||
|
| Number | Result |
|
||||||
|
|--------|--------|
|
||||||
|
| `4242 4242 4242 4242` | Successful payment |
|
||||||
|
| `4000 0000 0000 0002` | Declined |
|
||||||
|
| `4000 0025 0000 3155` | Requires 3D Secure |
|
||||||
|
|
||||||
|
Use any future expiry date and any 3-digit CVC.
|
||||||
|
|
||||||
## Generating Mockups
|
## Generating Mockups
|
||||||
|
|
||||||
The project includes a Printify integration for generating product mockups. This is useful for creating sample product images from Unsplash artwork.
|
The project includes a Printify integration for generating product mockups. This is useful for creating sample product images from Unsplash artwork.
|
||||||
|
|||||||
33
ROADMAP.md
33
ROADMAP.md
@ -4,35 +4,14 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Core MVP: Cart & Checkout
|
## Core MVP: Cart & Checkout ✅
|
||||||
|
|
||||||
### Session-Based Cart
|
Session-based cart, Stripe-hosted Checkout, order persistence, and webhook handling are all complete. See [PROGRESS.md](PROGRESS.md) for details.
|
||||||
Store cart in Phoenix session (no separate table needed for MVP).
|
|
||||||
|
|
||||||
```elixir
|
### Orders & Fulfillment (next up)
|
||||||
defmodule SimpleshopTheme.Cart do
|
- Submit paid orders to Printify for fulfillment
|
||||||
def get(session), do: Map.get(session, "cart", %{})
|
- Track fulfillment status updates via webhook
|
||||||
def add_item(session, variant_id, quantity \\ 1)
|
- Display order status to customers
|
||||||
def remove_item(session, variant_id)
|
|
||||||
def update_quantity(session, variant_id, quantity)
|
|
||||||
def clear(session)
|
|
||||||
def to_line_items(cart)
|
|
||||||
def total(cart)
|
|
||||||
def item_count(cart)
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### Stripe Checkout
|
|
||||||
Stripe Checkout (hosted payment page) integration.
|
|
||||||
|
|
||||||
**Dependencies:** `{:stripity_stripe, "~> 3.0"}`
|
|
||||||
|
|
||||||
**Routes:**
|
|
||||||
```elixir
|
|
||||||
post "/webhooks/stripe", StripeWebhookController, :handle
|
|
||||||
live "/checkout/success", ShopLive.CheckoutSuccess
|
|
||||||
live "/checkout/cancel", ShopLive.CheckoutCancel
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cost Verification at Checkout
|
### Cost Verification at Checkout
|
||||||
Verify Printify costs haven't changed before completing checkout to prevent selling at a loss.
|
Verify Printify costs haven't changed before completing checkout to prevent selling at a loss.
|
||||||
|
|||||||
@ -83,6 +83,9 @@ config :phoenix, :json_library, Jason
|
|||||||
# ex_money configuration for currency handling
|
# ex_money configuration for currency handling
|
||||||
config :ex_money, default_cldr_backend: SimpleshopTheme.Cldr
|
config :ex_money, default_cldr_backend: SimpleshopTheme.Cldr
|
||||||
|
|
||||||
|
# Stripe configuration
|
||||||
|
config :stripity_stripe, api_version: "2024-12-18.acacia"
|
||||||
|
|
||||||
# Oban configuration for background jobs
|
# Oban configuration for background jobs
|
||||||
config :simpleshop_theme, Oban,
|
config :simpleshop_theme, Oban,
|
||||||
engine: Oban.Engines.Lite,
|
engine: Oban.Engines.Lite,
|
||||||
@ -91,7 +94,7 @@ config :simpleshop_theme, Oban,
|
|||||||
{Oban.Plugins.Pruner, max_age: 60},
|
{Oban.Plugins.Pruner, max_age: 60},
|
||||||
{Oban.Plugins.Lifeline, rescue_after: :timer.minutes(5)}
|
{Oban.Plugins.Lifeline, rescue_after: :timer.minutes(5)}
|
||||||
],
|
],
|
||||||
queues: [images: 2, sync: 1]
|
queues: [images: 2, sync: 1, checkout: 1]
|
||||||
|
|
||||||
# Import environment specific config. This must remain at the bottom
|
# Import environment specific config. This must remain at the bottom
|
||||||
# of this file so it overrides the configuration defined above.
|
# of this file so it overrides the configuration defined above.
|
||||||
|
|||||||
@ -86,3 +86,8 @@ config :phoenix_live_view,
|
|||||||
|
|
||||||
# Disable swoosh api client as it is only required for production adapters.
|
# Disable swoosh api client as it is only required for production adapters.
|
||||||
config :swoosh, :api_client, false
|
config :swoosh, :api_client, false
|
||||||
|
|
||||||
|
# Stripe test keys (set via environment variables)
|
||||||
|
config :stripity_stripe,
|
||||||
|
api_key: System.get_env("STRIPE_SECRET_KEY"),
|
||||||
|
signing_secret: System.get_env("STRIPE_WEBHOOK_SECRET")
|
||||||
|
|||||||
@ -112,4 +112,13 @@ if config_env() == :prod do
|
|||||||
# config :swoosh, :api_client, Swoosh.ApiClient.Req
|
# config :swoosh, :api_client, Swoosh.ApiClient.Req
|
||||||
#
|
#
|
||||||
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
|
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
|
||||||
|
|
||||||
|
# Stripe payment processing
|
||||||
|
config :stripity_stripe,
|
||||||
|
api_key:
|
||||||
|
System.get_env("STRIPE_SECRET_KEY") ||
|
||||||
|
raise("Missing STRIPE_SECRET_KEY environment variable"),
|
||||||
|
signing_secret:
|
||||||
|
System.get_env("STRIPE_WEBHOOK_SECRET") ||
|
||||||
|
raise("Missing STRIPE_WEBHOOK_SECRET environment variable")
|
||||||
end
|
end
|
||||||
|
|||||||
134
lib/simpleshop_theme/orders.ex
Normal file
134
lib/simpleshop_theme/orders.ex
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
defmodule SimpleshopTheme.Orders do
|
||||||
|
@moduledoc """
|
||||||
|
The Orders context.
|
||||||
|
|
||||||
|
Handles order creation, payment status tracking, and order retrieval.
|
||||||
|
Payment-provider agnostic — all Stripe-specific logic lives in controllers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
alias SimpleshopTheme.Repo
|
||||||
|
alias SimpleshopTheme.Orders.{Order, OrderItem}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Creates an order with line items from hydrated cart data.
|
||||||
|
|
||||||
|
Expects a map with :items (list of hydrated cart item maps) and optional
|
||||||
|
fields like :customer_email. Returns {:ok, order} with items preloaded.
|
||||||
|
"""
|
||||||
|
def create_order(attrs) do
|
||||||
|
items = attrs[:items] || []
|
||||||
|
|
||||||
|
subtotal = Enum.reduce(items, 0, fn item, acc -> acc + item.price * item.quantity end)
|
||||||
|
|
||||||
|
order_attrs = %{
|
||||||
|
order_number: generate_order_number(),
|
||||||
|
subtotal: subtotal,
|
||||||
|
total: subtotal,
|
||||||
|
currency: Map.get(attrs, :currency, "gbp"),
|
||||||
|
customer_email: attrs[:customer_email],
|
||||||
|
payment_status: "pending"
|
||||||
|
}
|
||||||
|
|
||||||
|
Repo.transaction(fn ->
|
||||||
|
case %Order{} |> Order.changeset(order_attrs) |> Repo.insert() do
|
||||||
|
{:ok, order} ->
|
||||||
|
order_items =
|
||||||
|
Enum.map(items, fn item ->
|
||||||
|
%{
|
||||||
|
order_id: order.id,
|
||||||
|
variant_id: item.variant_id,
|
||||||
|
product_name: item.name,
|
||||||
|
variant_title: item.variant,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit_price: item.price,
|
||||||
|
inserted_at: order.inserted_at,
|
||||||
|
updated_at: order.updated_at
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
Repo.insert_all(OrderItem, order_items)
|
||||||
|
|
||||||
|
Repo.preload(order, :items)
|
||||||
|
|
||||||
|
{:error, changeset} ->
|
||||||
|
Repo.rollback(changeset)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Sets the stripe_session_id on an order after creating the Stripe checkout session.
|
||||||
|
"""
|
||||||
|
def set_stripe_session(order, session_id) do
|
||||||
|
order
|
||||||
|
|> Order.changeset(%{stripe_session_id: session_id})
|
||||||
|
|> Repo.update()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Finds an order by its Stripe checkout session ID.
|
||||||
|
"""
|
||||||
|
def get_order_by_stripe_session(session_id) do
|
||||||
|
Order
|
||||||
|
|> where([o], o.stripe_session_id == ^session_id)
|
||||||
|
|> preload(:items)
|
||||||
|
|> Repo.one()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Marks an order as paid and stores the Stripe payment intent ID.
|
||||||
|
|
||||||
|
Returns {:ok, order} or {:error, :already_paid} if idempotency check fails.
|
||||||
|
"""
|
||||||
|
def mark_paid(order, payment_intent_id) do
|
||||||
|
if order.payment_status == "paid" do
|
||||||
|
{:ok, order}
|
||||||
|
else
|
||||||
|
order
|
||||||
|
|> Order.changeset(%{
|
||||||
|
payment_status: "paid",
|
||||||
|
stripe_payment_intent_id: payment_intent_id
|
||||||
|
})
|
||||||
|
|> Repo.update()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Marks an order as failed.
|
||||||
|
"""
|
||||||
|
def mark_failed(order) do
|
||||||
|
order
|
||||||
|
|> Order.changeset(%{payment_status: "failed"})
|
||||||
|
|> Repo.update()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets an order by ID with items preloaded.
|
||||||
|
"""
|
||||||
|
def get_order(id) do
|
||||||
|
Order
|
||||||
|
|> preload(:items)
|
||||||
|
|> Repo.get(id)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Updates an order with the given attributes.
|
||||||
|
"""
|
||||||
|
def update_order(order, attrs) do
|
||||||
|
order
|
||||||
|
|> Order.changeset(attrs)
|
||||||
|
|> Repo.update()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Generates a human-readable order number.
|
||||||
|
|
||||||
|
Format: SS-YYMMDD-XXXX where XXXX is a random alphanumeric string.
|
||||||
|
"""
|
||||||
|
def generate_order_number do
|
||||||
|
date = Date.utc_today() |> Calendar.strftime("%y%m%d")
|
||||||
|
random = :crypto.strong_rand_bytes(2) |> Base.encode16()
|
||||||
|
"SS-#{date}-#{random}"
|
||||||
|
end
|
||||||
|
end
|
||||||
48
lib/simpleshop_theme/orders/order.ex
Normal file
48
lib/simpleshop_theme/orders/order.ex
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
defmodule SimpleshopTheme.Orders.Order do
|
||||||
|
use Ecto.Schema
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
@primary_key {:id, :binary_id, autogenerate: true}
|
||||||
|
@foreign_key_type :binary_id
|
||||||
|
|
||||||
|
@payment_statuses ~w(pending paid failed refunded)
|
||||||
|
|
||||||
|
schema "orders" do
|
||||||
|
field :order_number, :string
|
||||||
|
field :stripe_session_id, :string
|
||||||
|
field :stripe_payment_intent_id, :string
|
||||||
|
field :payment_status, :string, default: "pending"
|
||||||
|
field :customer_email, :string
|
||||||
|
field :shipping_address, :map, default: %{}
|
||||||
|
field :subtotal, :integer
|
||||||
|
field :total, :integer
|
||||||
|
field :currency, :string, default: "gbp"
|
||||||
|
field :metadata, :map, default: %{}
|
||||||
|
|
||||||
|
has_many :items, SimpleshopTheme.Orders.OrderItem
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
def changeset(order, attrs) do
|
||||||
|
order
|
||||||
|
|> cast(attrs, [
|
||||||
|
:order_number,
|
||||||
|
:stripe_session_id,
|
||||||
|
:stripe_payment_intent_id,
|
||||||
|
:payment_status,
|
||||||
|
:customer_email,
|
||||||
|
:shipping_address,
|
||||||
|
:subtotal,
|
||||||
|
:total,
|
||||||
|
:currency,
|
||||||
|
:metadata
|
||||||
|
])
|
||||||
|
|> validate_required([:order_number, :subtotal, :total, :currency])
|
||||||
|
|> validate_inclusion(:payment_status, @payment_statuses)
|
||||||
|
|> validate_number(:subtotal, greater_than_or_equal_to: 0)
|
||||||
|
|> validate_number(:total, greater_than_or_equal_to: 0)
|
||||||
|
|> unique_constraint(:order_number)
|
||||||
|
|> unique_constraint(:stripe_session_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
27
lib/simpleshop_theme/orders/order_item.ex
Normal file
27
lib/simpleshop_theme/orders/order_item.ex
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
defmodule SimpleshopTheme.Orders.OrderItem do
|
||||||
|
use Ecto.Schema
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
@primary_key {:id, :binary_id, autogenerate: true}
|
||||||
|
@foreign_key_type :binary_id
|
||||||
|
|
||||||
|
schema "order_items" do
|
||||||
|
field :variant_id, :string
|
||||||
|
field :product_name, :string
|
||||||
|
field :variant_title, :string
|
||||||
|
field :quantity, :integer
|
||||||
|
field :unit_price, :integer
|
||||||
|
|
||||||
|
belongs_to :order, SimpleshopTheme.Orders.Order
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
def changeset(item, attrs) do
|
||||||
|
item
|
||||||
|
|> cast(attrs, [:variant_id, :product_name, :variant_title, :quantity, :unit_price, :order_id])
|
||||||
|
|> validate_required([:variant_id, :product_name, :quantity, :unit_price])
|
||||||
|
|> validate_number(:quantity, greater_than: 0)
|
||||||
|
|> validate_number(:unit_price, greater_than_or_equal_to: 0)
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -1 +1,2 @@
|
|||||||
|
<SimpleshopThemeWeb.ShopComponents.shop_flash_group flash={@flash} />
|
||||||
{@inner_content}
|
{@inner_content}
|
||||||
|
|||||||
@ -0,0 +1,189 @@
|
|||||||
|
<div
|
||||||
|
id="shop-container"
|
||||||
|
phx-hook="CartPersist"
|
||||||
|
class="shop-container min-h-screen pb-20 md:pb-0"
|
||||||
|
style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);"
|
||||||
|
>
|
||||||
|
<.skip_link />
|
||||||
|
|
||||||
|
<%= if @theme_settings.announcement_bar do %>
|
||||||
|
<.announcement_bar theme_settings={@theme_settings} />
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<.shop_header
|
||||||
|
theme_settings={@theme_settings}
|
||||||
|
logo_image={@logo_image}
|
||||||
|
header_image={@header_image}
|
||||||
|
active_page="checkout"
|
||||||
|
mode={@mode}
|
||||||
|
cart_count={@cart_count}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main id="main-content" class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||||
|
<%= if @order && @order.payment_status == "paid" do %>
|
||||||
|
<div class="text-center mb-12">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center justify-center w-16 h-16 rounded-full mb-6"
|
||||||
|
style="background-color: var(--t-accent); color: var(--t-accent-contrast);"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-8 h-8"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1
|
||||||
|
class="text-3xl font-bold mb-3"
|
||||||
|
style="font-family: var(--t-font-heading); color: var(--t-text-primary);"
|
||||||
|
>
|
||||||
|
Thank you for your order
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="text-lg mb-2" style="color: var(--t-text-secondary);">
|
||||||
|
Order <strong style="color: var(--t-text-primary);">{@order.order_number}</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<%= if @order.customer_email do %>
|
||||||
|
<p style="color: var(--t-text-secondary);">
|
||||||
|
A confirmation will be sent to <strong>{@order.customer_email}</strong>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<.shop_card class="p-6 mb-8">
|
||||||
|
<h2
|
||||||
|
class="text-lg font-semibold mb-4"
|
||||||
|
style="font-family: var(--t-font-heading); color: var(--t-text-primary);"
|
||||||
|
>
|
||||||
|
Order details
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<ul class="flex flex-col gap-4 mb-6" style="list-style: none; margin: 0; padding: 0;">
|
||||||
|
<%= for item <- @order.items do %>
|
||||||
|
<li
|
||||||
|
class="flex justify-between items-start pb-4 border-b last:border-b-0 last:pb-0"
|
||||||
|
style="border-color: var(--t-border-default);"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium" style="color: var(--t-text-primary);">
|
||||||
|
{item.product_name}
|
||||||
|
</p>
|
||||||
|
<%= if item.variant_title do %>
|
||||||
|
<p class="text-sm" style="color: var(--t-text-secondary);">
|
||||||
|
{item.variant_title}
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
<p class="text-sm" style="color: var(--t-text-secondary);">
|
||||||
|
Qty: {item.quantity}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="font-medium" style="color: var(--t-text-primary);">
|
||||||
|
{SimpleshopTheme.Cart.format_price(item.unit_price * item.quantity)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="border-t pt-4" style="border-color: var(--t-border-default);">
|
||||||
|
<div class="flex justify-between text-lg">
|
||||||
|
<span class="font-semibold" style="color: var(--t-text-primary);">Total</span>
|
||||||
|
<span class="font-bold" style="color: var(--t-text-primary);">
|
||||||
|
{SimpleshopTheme.Cart.format_price(@order.total)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</.shop_card>
|
||||||
|
|
||||||
|
<%= if @order.shipping_address != %{} do %>
|
||||||
|
<.shop_card class="p-6 mb-8">
|
||||||
|
<h2
|
||||||
|
class="text-lg font-semibold mb-3"
|
||||||
|
style="font-family: var(--t-font-heading); color: var(--t-text-primary);"
|
||||||
|
>
|
||||||
|
Shipping to
|
||||||
|
</h2>
|
||||||
|
<div style="color: var(--t-text-secondary);">
|
||||||
|
<p>{@order.shipping_address["name"]}</p>
|
||||||
|
<p>{@order.shipping_address["line1"]}</p>
|
||||||
|
<%= if @order.shipping_address["line2"] do %>
|
||||||
|
<p>{@order.shipping_address["line2"]}</p>
|
||||||
|
<% end %>
|
||||||
|
<p>
|
||||||
|
{@order.shipping_address["city"]}, {@order.shipping_address["postal_code"]}
|
||||||
|
</p>
|
||||||
|
<p>{@order.shipping_address["country"]}</p>
|
||||||
|
</div>
|
||||||
|
</.shop_card>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<.shop_link_button href="/collections/all" class="px-8 py-3">
|
||||||
|
Continue shopping
|
||||||
|
</.shop_link_button>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<%!-- Payment pending or order not found --%>
|
||||||
|
<div class="text-center py-16">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center justify-center w-16 h-16 rounded-full mb-6 animate-pulse"
|
||||||
|
style="background-color: var(--t-surface-sunken);"
|
||||||
|
>
|
||||||
|
<span style="color: var(--t-text-secondary);">
|
||||||
|
<svg
|
||||||
|
class="w-8 h-8"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1
|
||||||
|
class="text-3xl font-bold mb-3"
|
||||||
|
style="font-family: var(--t-font-heading); color: var(--t-text-primary);"
|
||||||
|
>
|
||||||
|
Processing your payment
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="text-lg mb-8" style="color: var(--t-text-secondary);">
|
||||||
|
Please wait while we confirm your payment. This usually takes a few seconds.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="text-sm" style="color: var(--t-text-tertiary);">
|
||||||
|
If this page doesn't update, please <a
|
||||||
|
href="/contact"
|
||||||
|
class="underline"
|
||||||
|
style="color: var(--t-accent);"
|
||||||
|
>contact us</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<.shop_footer theme_settings={@theme_settings} mode={@mode} />
|
||||||
|
|
||||||
|
<.cart_drawer
|
||||||
|
cart_items={@cart_items}
|
||||||
|
subtotal={@cart_subtotal}
|
||||||
|
cart_count={@cart_count}
|
||||||
|
mode={@mode}
|
||||||
|
open={assigns[:cart_drawer_open] || false}
|
||||||
|
cart_status={assigns[:cart_status]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<.search_modal hint_text={~s(Try a search – e.g. "mountain" or "notebook")} />
|
||||||
|
|
||||||
|
<.mobile_bottom_nav active_page="checkout" mode={@mode} />
|
||||||
|
</div>
|
||||||
@ -1191,13 +1191,26 @@ defmodule SimpleshopThemeWeb.ShopComponents do
|
|||||||
<span>Subtotal</span>
|
<span>Subtotal</span>
|
||||||
<span>{@display_subtotal}</span>
|
<span>{@display_subtotal}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<%= if @mode == :preview do %>
|
||||||
type="submit"
|
<button
|
||||||
class="cart-drawer-checkout w-full mb-2"
|
type="button"
|
||||||
style="width: 100%; padding: 0.75rem 1.5rem; font-weight: 600; transition: all 0.2s ease; background: var(--t-accent-button, hsl(var(--t-accent-h) var(--t-accent-s) 42%)); color: var(--t-text-inverse); border-radius: var(--t-radius-button); border: none; cursor: pointer; font-family: var(--t-font-body);"
|
class="cart-drawer-checkout w-full mb-2"
|
||||||
>
|
style="width: 100%; padding: 0.75rem 1.5rem; font-weight: 600; transition: all 0.2s ease; background: var(--t-accent-button, hsl(var(--t-accent-h) var(--t-accent-s) 42%)); color: var(--t-text-inverse); border-radius: var(--t-radius-button); border: none; cursor: pointer; font-family: var(--t-font-body);"
|
||||||
Checkout
|
>
|
||||||
</button>
|
Checkout
|
||||||
|
</button>
|
||||||
|
<% else %>
|
||||||
|
<form action="/checkout" method="post">
|
||||||
|
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="cart-drawer-checkout w-full mb-2"
|
||||||
|
style="width: 100%; padding: 0.75rem 1.5rem; font-weight: 600; transition: all 0.2s ease; background: var(--t-accent-button, hsl(var(--t-accent-h) var(--t-accent-s) 42%)); color: var(--t-text-inverse); border-radius: var(--t-radius-button); border: none; cursor: pointer; font-family: var(--t-font-body);"
|
||||||
|
>
|
||||||
|
Checkout
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<% end %>
|
||||||
<%= if @mode == :preview do %>
|
<%= if @mode == :preview do %>
|
||||||
<a
|
<a
|
||||||
href="#"
|
href="#"
|
||||||
@ -3089,22 +3102,16 @@ defmodule SimpleshopThemeWeb.ShopComponents do
|
|||||||
<.order_summary subtotal={3600} />
|
<.order_summary subtotal={3600} />
|
||||||
"""
|
"""
|
||||||
attr :subtotal, :integer, required: true
|
attr :subtotal, :integer, required: true
|
||||||
attr :delivery, :integer, default: 800
|
|
||||||
attr :vat, :integer, default: 720
|
|
||||||
attr :mode, :atom, default: :live
|
attr :mode, :atom, default: :live
|
||||||
|
|
||||||
def order_summary(assigns) do
|
def order_summary(assigns) do
|
||||||
total = assigns.subtotal + assigns.delivery + assigns.vat
|
|
||||||
|
|
||||||
assigns = assign(assigns, :total, total)
|
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<.shop_card class="p-6 sticky top-4">
|
<.shop_card class="p-6 sticky top-4">
|
||||||
<h2
|
<h2
|
||||||
class="text-xl font-bold mb-6"
|
class="text-xl font-bold mb-6"
|
||||||
style="font-family: var(--t-font-heading); color: var(--t-text-primary);"
|
style="font-family: var(--t-font-heading); color: var(--t-text-primary);"
|
||||||
>
|
>
|
||||||
Order Summary
|
Order summary
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 mb-6">
|
<div class="flex flex-col gap-3 mb-6">
|
||||||
@ -3116,42 +3123,43 @@ defmodule SimpleshopThemeWeb.ShopComponents do
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span style="color: var(--t-text-secondary);">Delivery</span>
|
<span style="color: var(--t-text-secondary);">Delivery</span>
|
||||||
<span style="color: var(--t-text-primary);">
|
<span class="text-sm" style="color: var(--t-text-secondary);">
|
||||||
{SimpleshopTheme.Cart.format_price(@delivery)}
|
Calculated at checkout
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
|
||||||
<span style="color: var(--t-text-secondary);">VAT (20%)</span>
|
|
||||||
<span style="color: var(--t-text-primary);">{SimpleshopTheme.Cart.format_price(@vat)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="border-t pt-3" style="border-color: var(--t-border-default);">
|
<div class="border-t pt-3" style="border-color: var(--t-border-default);">
|
||||||
<div class="flex justify-between text-lg">
|
<div class="flex justify-between text-lg">
|
||||||
<span class="font-semibold" style="color: var(--t-text-primary);">Total</span>
|
<span class="font-semibold" style="color: var(--t-text-primary);">Subtotal</span>
|
||||||
<span class="font-bold" style="color: var(--t-text-primary);">
|
<span class="font-bold" style="color: var(--t-text-primary);">
|
||||||
{SimpleshopTheme.Cart.format_price(@total)}
|
{SimpleshopTheme.Cart.format_price(@subtotal)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<.shop_button class="w-full px-6 py-3 font-semibold transition-all mb-3">
|
|
||||||
Checkout
|
|
||||||
</.shop_button>
|
|
||||||
|
|
||||||
<%= if @mode == :preview do %>
|
<%= if @mode == :preview do %>
|
||||||
|
<.shop_button class="w-full px-6 py-3 font-semibold transition-all mb-3">
|
||||||
|
Checkout
|
||||||
|
</.shop_button>
|
||||||
<.shop_button_outline
|
<.shop_button_outline
|
||||||
phx-click="change_preview_page"
|
phx-click="change_preview_page"
|
||||||
phx-value-page="collection"
|
phx-value-page="collection"
|
||||||
class="w-full px-6 py-3 font-semibold transition-all"
|
class="w-full px-6 py-3 font-semibold transition-all"
|
||||||
>
|
>
|
||||||
Continue Shopping
|
Continue shopping
|
||||||
</.shop_button_outline>
|
</.shop_button_outline>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
<form action="/checkout" method="post" class="mb-3">
|
||||||
|
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
|
||||||
|
<.shop_button type="submit" class="w-full px-6 py-3 font-semibold transition-all">
|
||||||
|
Checkout
|
||||||
|
</.shop_button>
|
||||||
|
</form>
|
||||||
<.shop_link_outline
|
<.shop_link_outline
|
||||||
href="/collections/all"
|
href="/collections/all"
|
||||||
class="block w-full px-6 py-3 font-semibold transition-all text-center"
|
class="block w-full px-6 py-3 font-semibold transition-all text-center"
|
||||||
>
|
>
|
||||||
Continue Shopping
|
Continue shopping
|
||||||
</.shop_link_outline>
|
</.shop_link_outline>
|
||||||
<% end %>
|
<% end %>
|
||||||
</.shop_card>
|
</.shop_card>
|
||||||
@ -4298,6 +4306,80 @@ defmodule SimpleshopThemeWeb.ShopComponents do
|
|||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders flash messages styled for the shop theme.
|
||||||
|
"""
|
||||||
|
attr :flash, :map, required: true
|
||||||
|
|
||||||
|
def shop_flash_group(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div
|
||||||
|
id="shop-flash-group"
|
||||||
|
aria-live="polite"
|
||||||
|
class="fixed top-4 right-4 z-[200] flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<%= if msg = Phoenix.Flash.get(@flash, :info) do %>
|
||||||
|
<div
|
||||||
|
id="shop-flash-info"
|
||||||
|
class="flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg max-w-sm animate-in"
|
||||||
|
style="background-color: var(--t-surface-raised, #fff); color: var(--t-text-primary); border: 1px solid var(--t-border-default);"
|
||||||
|
role="alert"
|
||||||
|
phx-click={
|
||||||
|
Phoenix.LiveView.JS.push("lv:clear-flash", value: %{key: :info})
|
||||||
|
|> Phoenix.LiveView.JS.hide(
|
||||||
|
to: "#shop-flash-info",
|
||||||
|
transition: {"ease-out duration-200", "opacity-100", "opacity-0"}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 shrink-0"
|
||||||
|
style="color: var(--t-accent);"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm">{msg}</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<%= if msg = Phoenix.Flash.get(@flash, :error) do %>
|
||||||
|
<div
|
||||||
|
id="shop-flash-error"
|
||||||
|
class="flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg max-w-sm animate-in"
|
||||||
|
style="background-color: var(--t-surface-raised, #fff); color: var(--t-text-primary); border: 1px solid hsl(0 70% 50% / 0.3);"
|
||||||
|
role="alert"
|
||||||
|
phx-click={
|
||||||
|
Phoenix.LiveView.JS.push("lv:clear-flash", value: %{key: :error})
|
||||||
|
|> Phoenix.LiveView.JS.hide(
|
||||||
|
to: "#shop-flash-error",
|
||||||
|
transition: {"ease-out duration-200", "opacity-100", "opacity-0"}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 shrink-0"
|
||||||
|
style="color: hsl(0 70% 50%);"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm">{msg}</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
defp build_srcset(base, widths, format) do
|
defp build_srcset(base, widths, format) do
|
||||||
# Database images end with / (e.g., /images/{id}/variant/)
|
# Database images end with / (e.g., /images/{id}/variant/)
|
||||||
# Mockups use - separator (e.g., /mockups/product-1)
|
# Mockups use - separator (e.g., /mockups/product-1)
|
||||||
|
|||||||
94
lib/simpleshop_theme_web/controllers/checkout_controller.ex
Normal file
94
lib/simpleshop_theme_web/controllers/checkout_controller.ex
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
defmodule SimpleshopThemeWeb.CheckoutController do
|
||||||
|
use SimpleshopThemeWeb, :controller
|
||||||
|
|
||||||
|
alias SimpleshopTheme.Cart
|
||||||
|
alias SimpleshopTheme.Orders
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
def create(conn, _params) do
|
||||||
|
cart_items = Cart.get_from_session(get_session(conn))
|
||||||
|
hydrated = Cart.hydrate(cart_items)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
hydrated == [] ->
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, "Your basket is empty")
|
||||||
|
|> redirect(to: ~p"/cart")
|
||||||
|
|
||||||
|
true ->
|
||||||
|
create_checkout(conn, hydrated)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp create_checkout(conn, hydrated_items) do
|
||||||
|
# Create a pending order with price snapshots
|
||||||
|
case Orders.create_order(%{items: hydrated_items}) do
|
||||||
|
{:ok, order} ->
|
||||||
|
create_stripe_session(conn, order, hydrated_items)
|
||||||
|
|
||||||
|
{:error, _changeset} ->
|
||||||
|
Logger.error("Failed to create order")
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, "Something went wrong. Please try again.")
|
||||||
|
|> redirect(to: ~p"/cart")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp create_stripe_session(conn, order, hydrated_items) do
|
||||||
|
line_items =
|
||||||
|
Enum.map(hydrated_items, fn item ->
|
||||||
|
product_name =
|
||||||
|
if item.variant,
|
||||||
|
do: "#{item.name} — #{item.variant}",
|
||||||
|
else: item.name
|
||||||
|
|
||||||
|
%{
|
||||||
|
price_data: %{
|
||||||
|
currency: "gbp",
|
||||||
|
unit_amount: item.price,
|
||||||
|
product_data: %{name: product_name}
|
||||||
|
},
|
||||||
|
quantity: item.quantity
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
base_url = SimpleshopThemeWeb.Endpoint.url()
|
||||||
|
|
||||||
|
params = %{
|
||||||
|
mode: "payment",
|
||||||
|
line_items: line_items,
|
||||||
|
success_url: "#{base_url}/checkout/success?session_id={CHECKOUT_SESSION_ID}",
|
||||||
|
cancel_url: "#{base_url}/cart",
|
||||||
|
metadata: %{"order_id" => order.id},
|
||||||
|
shipping_address_collection: %{
|
||||||
|
allowed_countries: ["GB", "US", "CA", "AU", "DE", "FR", "NL", "IE", "AT", "BE"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case Stripe.Checkout.Session.create(params) do
|
||||||
|
{:ok, session} ->
|
||||||
|
{:ok, _order} = Orders.set_stripe_session(order, session.id)
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> redirect(external: session.url)
|
||||||
|
|
||||||
|
{:error, %Stripe.Error{message: message}} ->
|
||||||
|
Logger.error("Stripe session creation failed: #{message}")
|
||||||
|
Orders.mark_failed(order)
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, "Payment setup failed. Please try again.")
|
||||||
|
|> redirect(to: ~p"/cart")
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("Stripe session creation failed: #{inspect(reason)}")
|
||||||
|
Orders.mark_failed(order)
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, "Payment setup failed. Please try again.")
|
||||||
|
|> redirect(to: ~p"/cart")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
defmodule SimpleshopThemeWeb.StripeWebhookController do
|
||||||
|
use SimpleshopThemeWeb, :controller
|
||||||
|
|
||||||
|
alias SimpleshopTheme.Orders
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
def handle(conn, _params) do
|
||||||
|
raw_body = conn.assigns[:raw_body] || ""
|
||||||
|
signature = List.first(get_req_header(conn, "stripe-signature")) || ""
|
||||||
|
signing_secret = Application.get_env(:stripity_stripe, :signing_secret) || ""
|
||||||
|
|
||||||
|
case Stripe.Webhook.construct_event(raw_body, signature, signing_secret) do
|
||||||
|
{:ok, %Stripe.Event{} = event} ->
|
||||||
|
handle_event(event)
|
||||||
|
json(conn, %{received: true})
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning("Stripe webhook verification failed: #{inspect(reason)}")
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_status(401)
|
||||||
|
|> json(%{error: "Invalid signature"})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle_event(%Stripe.Event{type: "checkout.session.completed", data: %{object: session}}) do
|
||||||
|
order_id = get_in(session, [:metadata, "order_id"]) || session.metadata["order_id"]
|
||||||
|
|
||||||
|
case Orders.get_order(order_id) do
|
||||||
|
nil ->
|
||||||
|
Logger.warning("Stripe webhook: order not found for id=#{order_id}")
|
||||||
|
|
||||||
|
order ->
|
||||||
|
payment_intent_id = session.payment_intent
|
||||||
|
{:ok, order} = Orders.mark_paid(order, payment_intent_id)
|
||||||
|
|
||||||
|
# Update shipping address if collected by Stripe
|
||||||
|
if session.shipping_details do
|
||||||
|
update_shipping(order, session.shipping_details)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Update customer email from Stripe session
|
||||||
|
if session.customer_details && session.customer_details.email do
|
||||||
|
Orders.update_order(order, %{customer_email: session.customer_details.email})
|
||||||
|
end
|
||||||
|
|
||||||
|
# Broadcast to success page via PubSub
|
||||||
|
Phoenix.PubSub.broadcast(
|
||||||
|
SimpleshopTheme.PubSub,
|
||||||
|
"order:#{order.id}:status",
|
||||||
|
{:order_paid, order}
|
||||||
|
)
|
||||||
|
|
||||||
|
Logger.info("Order #{order.order_number} marked as paid")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle_event(%Stripe.Event{type: "checkout.session.expired", data: %{object: session}}) do
|
||||||
|
order_id = get_in(session, [:metadata, "order_id"]) || session.metadata["order_id"]
|
||||||
|
|
||||||
|
case Orders.get_order(order_id) do
|
||||||
|
nil -> :ok
|
||||||
|
order -> Orders.mark_failed(order)
|
||||||
|
end
|
||||||
|
|
||||||
|
Logger.info("Stripe checkout session expired for order #{order_id}")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle_event(%Stripe.Event{type: type}) do
|
||||||
|
Logger.debug("Unhandled Stripe event: #{type}")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp update_shipping(order, shipping_details) do
|
||||||
|
address = shipping_details.address || %{}
|
||||||
|
|
||||||
|
shipping_address = %{
|
||||||
|
"name" => shipping_details.name,
|
||||||
|
"line1" => address.line1,
|
||||||
|
"line2" => address.line2,
|
||||||
|
"city" => address.city,
|
||||||
|
"postal_code" => address.postal_code,
|
||||||
|
"state" => address.state,
|
||||||
|
"country" => address.country
|
||||||
|
}
|
||||||
|
|
||||||
|
Orders.update_order(order, %{shipping_address: shipping_address})
|
||||||
|
end
|
||||||
|
end
|
||||||
82
lib/simpleshop_theme_web/live/shop_live/checkout_success.ex
Normal file
82
lib/simpleshop_theme_web/live/shop_live/checkout_success.ex
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
defmodule SimpleshopThemeWeb.ShopLive.CheckoutSuccess do
|
||||||
|
use SimpleshopThemeWeb, :live_view
|
||||||
|
|
||||||
|
alias SimpleshopTheme.{Orders, Settings, Media}
|
||||||
|
alias SimpleshopTheme.Theme.{CSSCache, CSSGenerator}
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(%{"session_id" => session_id}, _session, socket) do
|
||||||
|
theme_settings = Settings.get_theme_settings()
|
||||||
|
|
||||||
|
generated_css =
|
||||||
|
case CSSCache.get() do
|
||||||
|
{:ok, css} ->
|
||||||
|
css
|
||||||
|
|
||||||
|
:miss ->
|
||||||
|
css = CSSGenerator.generate(theme_settings)
|
||||||
|
CSSCache.put(css)
|
||||||
|
css
|
||||||
|
end
|
||||||
|
|
||||||
|
logo_image = Media.get_logo()
|
||||||
|
header_image = Media.get_header()
|
||||||
|
|
||||||
|
order = Orders.get_order_by_stripe_session(session_id)
|
||||||
|
|
||||||
|
# Subscribe to order status updates (webhook may arrive after redirect)
|
||||||
|
if order && connected?(socket) do
|
||||||
|
Phoenix.PubSub.subscribe(SimpleshopTheme.PubSub, "order:#{order.id}:status")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Clear the cart after successful checkout
|
||||||
|
socket =
|
||||||
|
if order && connected?(socket) do
|
||||||
|
empty_cart = []
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> SimpleshopThemeWeb.CartHook.broadcast_and_update(empty_cart)
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:page_title, "Order confirmed")
|
||||||
|
|> assign(:theme_settings, theme_settings)
|
||||||
|
|> assign(:generated_css, generated_css)
|
||||||
|
|> assign(:logo_image, logo_image)
|
||||||
|
|> assign(:header_image, header_image)
|
||||||
|
|> assign(:mode, :shop)
|
||||||
|
|> assign(:order, order)
|
||||||
|
|
||||||
|
{:ok, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
{:ok, redirect(socket, to: ~p"/")}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({:order_paid, order}, socket) do
|
||||||
|
{:noreply, assign(socket, :order, order)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<SimpleshopThemeWeb.PageTemplates.checkout_success
|
||||||
|
theme_settings={@theme_settings}
|
||||||
|
logo_image={@logo_image}
|
||||||
|
header_image={@header_image}
|
||||||
|
mode={@mode}
|
||||||
|
cart_items={@cart_items}
|
||||||
|
cart_count={@cart_count}
|
||||||
|
cart_subtotal={@cart_subtotal}
|
||||||
|
cart_drawer_open={@cart_drawer_open}
|
||||||
|
cart_status={@cart_status}
|
||||||
|
order={@order}
|
||||||
|
/>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -39,7 +39,11 @@ defmodule SimpleshopThemeWeb.Router do
|
|||||||
live "/collections/:slug", ShopLive.Collection, :show
|
live "/collections/:slug", ShopLive.Collection, :show
|
||||||
live "/products/:id", ShopLive.ProductShow, :show
|
live "/products/:id", ShopLive.ProductShow, :show
|
||||||
live "/cart", ShopLive.Cart, :index
|
live "/cart", ShopLive.Cart, :index
|
||||||
|
live "/checkout/success", ShopLive.CheckoutSuccess, :show
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Checkout (POST — creates Stripe session and redirects)
|
||||||
|
post "/checkout", CheckoutController, :create
|
||||||
end
|
end
|
||||||
|
|
||||||
# Cart API (session persistence for LiveView)
|
# Cart API (session persistence for LiveView)
|
||||||
@ -66,6 +70,12 @@ defmodule SimpleshopThemeWeb.Router do
|
|||||||
post "/printify", WebhookController, :printify
|
post "/printify", WebhookController, :printify
|
||||||
end
|
end
|
||||||
|
|
||||||
|
scope "/webhooks", SimpleshopThemeWeb do
|
||||||
|
pipe_through [:api]
|
||||||
|
|
||||||
|
post "/stripe", StripeWebhookController, :handle
|
||||||
|
end
|
||||||
|
|
||||||
# Enable LiveDashboard and Swoosh mailbox preview in development
|
# Enable LiveDashboard and Swoosh mailbox preview in development
|
||||||
if Application.compile_env(:simpleshop_theme, :dev_routes) do
|
if Application.compile_env(:simpleshop_theme, :dev_routes) do
|
||||||
# If you want to use the LiveDashboard in production, you should put
|
# If you want to use the LiveDashboard in production, you should put
|
||||||
|
|||||||
3
mix.exs
3
mix.exs
@ -71,7 +71,8 @@ defmodule SimpleshopTheme.MixProject do
|
|||||||
{:image, "~> 0.54"},
|
{:image, "~> 0.54"},
|
||||||
{:oban, "~> 2.18"},
|
{:oban, "~> 2.18"},
|
||||||
{:ex_money, "~> 5.0"},
|
{:ex_money, "~> 5.0"},
|
||||||
{:ex_money_sql, "~> 1.0"}
|
{:ex_money_sql, "~> 1.0"},
|
||||||
|
{:stripity_stripe, "~> 3.2"}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
8
mix.lock
8
mix.lock
@ -2,6 +2,7 @@
|
|||||||
"bandit": {:hex, :bandit, "1.10.2", "d15ea32eb853b5b42b965b24221eb045462b2ba9aff9a0bda71157c06338cbff", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "27b2a61b647914b1726c2ced3601473be5f7aa6bb468564a688646a689b3ee45"},
|
"bandit": {:hex, :bandit, "1.10.2", "d15ea32eb853b5b42b965b24221eb045462b2ba9aff9a0bda71157c06338cbff", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "27b2a61b647914b1726c2ced3601473be5f7aa6bb468564a688646a689b3ee45"},
|
||||||
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
|
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
|
||||||
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||||
|
"certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"},
|
||||||
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
|
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
|
||||||
"cldr_utils": {:hex, :cldr_utils, "2.29.4", "11437b0bf9a0d57db4eccdf751c49f675a04fa4261c5dae1e23552a0347e25c9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "e72a43e69a3f546979085cbdbeae7e9049998cd21cedfdd796cff9155998114e"},
|
"cldr_utils": {:hex, :cldr_utils, "2.29.4", "11437b0bf9a0d57db4eccdf751c49f675a04fa4261c5dae1e23552a0347e25c9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "e72a43e69a3f546979085cbdbeae7e9049998cd21cedfdd796cff9155998114e"},
|
||||||
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
||||||
@ -25,18 +26,22 @@
|
|||||||
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
|
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
|
||||||
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
|
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
|
||||||
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
|
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
|
||||||
|
"hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"},
|
||||||
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
|
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
|
||||||
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
||||||
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
|
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
|
||||||
"image": {:hex, :image, "0.62.1", "1dd3d8d0d29d6562aa2141b5ef08c0f6a60e2a9f843fe475499b2f4f1ef60406", [:mix], [{:bumblebee, "~> 0.6", [hex: :bumblebee, repo: "hexpm", optional: true]}, {:evision, "~> 0.1.33 or ~> 0.2", [hex: :evision, repo: "hexpm", optional: true]}, {:exla, "~> 0.9", [hex: :exla, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:kino, "~> 0.13", [hex: :kino, repo: "hexpm", optional: true]}, {:nx, "~> 0.9", [hex: :nx, repo: "hexpm", optional: true]}, {:nx_image, "~> 0.1", [hex: :nx_image, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.1 or ~> 3.2 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:rustler, "> 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:scholar, "~> 0.3", [hex: :scholar, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}, {:vix, "~> 0.33", [hex: :vix, repo: "hexpm", optional: false]}], "hexpm", "5a5a7acaf68cfaed8932d478b95152cd7d84071442cac558c59f2d31427e91ab"},
|
"image": {:hex, :image, "0.62.1", "1dd3d8d0d29d6562aa2141b5ef08c0f6a60e2a9f843fe475499b2f4f1ef60406", [:mix], [{:bumblebee, "~> 0.6", [hex: :bumblebee, repo: "hexpm", optional: true]}, {:evision, "~> 0.1.33 or ~> 0.2", [hex: :evision, repo: "hexpm", optional: true]}, {:exla, "~> 0.9", [hex: :exla, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:kino, "~> 0.13", [hex: :kino, repo: "hexpm", optional: true]}, {:nx, "~> 0.9", [hex: :nx, repo: "hexpm", optional: true]}, {:nx_image, "~> 0.1", [hex: :nx_image, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.1 or ~> 3.2 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:rustler, "> 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:scholar, "~> 0.3", [hex: :scholar, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}, {:vix, "~> 0.33", [hex: :vix, repo: "hexpm", optional: false]}], "hexpm", "5a5a7acaf68cfaed8932d478b95152cd7d84071442cac558c59f2d31427e91ab"},
|
||||||
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
||||||
"lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"},
|
"lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"},
|
||||||
|
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
|
||||||
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||||
|
"mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"},
|
||||||
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
|
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
|
||||||
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
||||||
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
|
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
|
||||||
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
||||||
"oban": {:hex, :oban, "2.20.2", "f23313d83b578305cafa825a036cad84e7e2d61549ecbece3a2e6526d347cc3b", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "523365ef0217781c061d15f496e3200a5f1b43e08b1a27c34799ef8bfe95815f"},
|
"oban": {:hex, :oban, "2.20.2", "f23313d83b578305cafa825a036cad84e7e2d61549ecbece3a2e6526d347cc3b", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "523365ef0217781c061d15f496e3200a5f1b43e08b1a27c34799ef8bfe95815f"},
|
||||||
|
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
|
||||||
"phoenix": {:hex, :phoenix, "1.8.3", "49ac5e485083cb1495a905e47eb554277bdd9c65ccb4fc5100306b350151aa95", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "36169f95cc2e155b78be93d9590acc3f462f1e5438db06e6248613f27c80caec"},
|
"phoenix": {:hex, :phoenix, "1.8.3", "49ac5e485083cb1495a905e47eb554277bdd9c65ccb4fc5100306b350151aa95", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "36169f95cc2e155b78be93d9590acc3f462f1e5438db06e6248613f27c80caec"},
|
||||||
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
|
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
|
||||||
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
|
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
|
||||||
@ -49,6 +54,8 @@
|
|||||||
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
||||||
"postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"},
|
"postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"},
|
||||||
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
|
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
|
||||||
|
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
|
||||||
|
"stripity_stripe": {:hex, :stripity_stripe, "3.2.0", "07c27f5f2ac87006945b5c997b99d1210e009e380ea78d339d025b11c9c745f5", [:mix], [{:hackney, "~> 1.18", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}, {:uri_query, "~> 0.2.0", [hex: :uri_query, repo: "hexpm", optional: false]}], "hexpm", "f797936a9e9538370bae7dc73d73eafd7e44ecdc95b71c88492c43f6df094cb0"},
|
||||||
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
|
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
|
||||||
"swoosh": {:hex, :swoosh, "1.20.0", "b04134c2b302da74c3a95ca4ddde191e4854d2847d6687783fecb023a9647598", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "13e610f709bae54851d68afb6862882aa646e5c974bf49e3bf5edd84a73cf213"},
|
"swoosh": {:hex, :swoosh, "1.20.0", "b04134c2b302da74c3a95ca4ddde191e4854d2847d6687783fecb023a9647598", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "13e610f709bae54851d68afb6862882aa646e5c974bf49e3bf5edd84a73cf213"},
|
||||||
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
|
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
|
||||||
@ -58,6 +65,7 @@
|
|||||||
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
|
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
|
||||||
"tidewave": {:hex, :tidewave, "0.5.4", "b7b6db62779a6faf139e630eb54f218cf3091ec5d39600197008db8474cb6fb2", [:mix], [{:bandit, ">= 1.10.1", [hex: :bandit, repo: "hexpm", optional: true]}, {:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "252c7cf4ffe81d4c5ad8ef709333e7124c5af554aa07dceab61135d0f205a898"},
|
"tidewave": {:hex, :tidewave, "0.5.4", "b7b6db62779a6faf139e630eb54f218cf3091ec5d39600197008db8474cb6fb2", [:mix], [{:bandit, ">= 1.10.1", [hex: :bandit, repo: "hexpm", optional: true]}, {:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "252c7cf4ffe81d4c5ad8ef709333e7124c5af554aa07dceab61135d0f205a898"},
|
||||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
||||||
|
"uri_query": {:hex, :uri_query, "0.2.0", "0f5e0f7ea6d9e6a7fb4929a81df9ecd756e3c71bdee5c9bc14e57d90069a82f7", [:mix], [], "hexpm", "e99f50a6af7c6643dff948db152a6a420bfe446aaec7f0924cfcdb710c175e63"},
|
||||||
"vix": {:hex, :vix, "0.35.0", "f6319b715e3b072e53eba456a21af5f2ff010a7a7b19b884600ea98a0609b18c", [:make, :mix], [{:cc_precompiler, "~> 0.1.4 or ~> 0.2", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7.3 or ~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}], "hexpm", "a3e80067a89d0631b6cf2b93594e03c1b303a2c7cddbbdd28040750d521984e5"},
|
"vix": {:hex, :vix, "0.35.0", "f6319b715e3b072e53eba456a21af5f2ff010a7a7b19b884600ea98a0609b18c", [:make, :mix], [{:cc_precompiler, "~> 0.1.4 or ~> 0.2", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7.3 or ~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}], "hexpm", "a3e80067a89d0631b6cf2b93594e03c1b303a2c7cddbbdd28040750d521984e5"},
|
||||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||||
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
|
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
|
||||||
|
|||||||
39
priv/repo/migrations/20260207005141_create_orders.exs
Normal file
39
priv/repo/migrations/20260207005141_create_orders.exs
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
defmodule SimpleshopTheme.Repo.Migrations.CreateOrders do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create table(:orders, primary_key: false) do
|
||||||
|
add :id, :binary_id, primary_key: true
|
||||||
|
add :order_number, :string, null: false
|
||||||
|
add :stripe_session_id, :string
|
||||||
|
add :stripe_payment_intent_id, :string
|
||||||
|
add :payment_status, :string, null: false, default: "pending"
|
||||||
|
add :customer_email, :string
|
||||||
|
add :shipping_address, :map, default: %{}
|
||||||
|
add :subtotal, :integer, null: false
|
||||||
|
add :total, :integer, null: false
|
||||||
|
add :currency, :string, null: false, default: "gbp"
|
||||||
|
add :metadata, :map, default: %{}
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
create unique_index(:orders, [:order_number])
|
||||||
|
create unique_index(:orders, [:stripe_session_id])
|
||||||
|
create index(:orders, [:payment_status])
|
||||||
|
|
||||||
|
create table(:order_items, primary_key: false) do
|
||||||
|
add :id, :binary_id, primary_key: true
|
||||||
|
add :order_id, references(:orders, type: :binary_id, on_delete: :delete_all), null: false
|
||||||
|
add :variant_id, :string, null: false
|
||||||
|
add :product_name, :string, null: false
|
||||||
|
add :variant_title, :string
|
||||||
|
add :quantity, :integer, null: false
|
||||||
|
add :unit_price, :integer, null: false
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
create index(:order_items, [:order_id])
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Reference in New Issue
Block a user