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:
@@ -1 +1,2 @@
|
||||
<SimpleshopThemeWeb.ShopComponents.shop_flash_group flash={@flash} />
|
||||
{@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>{@display_subtotal}</span>
|
||||
</div>
|
||||
<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>
|
||||
<%= if @mode == :preview do %>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
<% 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 %>
|
||||
<a
|
||||
href="#"
|
||||
@@ -3089,22 +3102,16 @@ defmodule SimpleshopThemeWeb.ShopComponents do
|
||||
<.order_summary subtotal={3600} />
|
||||
"""
|
||||
attr :subtotal, :integer, required: true
|
||||
attr :delivery, :integer, default: 800
|
||||
attr :vat, :integer, default: 720
|
||||
attr :mode, :atom, default: :live
|
||||
|
||||
def order_summary(assigns) do
|
||||
total = assigns.subtotal + assigns.delivery + assigns.vat
|
||||
|
||||
assigns = assign(assigns, :total, total)
|
||||
|
||||
~H"""
|
||||
<.shop_card class="p-6 sticky top-4">
|
||||
<h2
|
||||
class="text-xl font-bold mb-6"
|
||||
style="font-family: var(--t-font-heading); color: var(--t-text-primary);"
|
||||
>
|
||||
Order Summary
|
||||
Order summary
|
||||
</h2>
|
||||
|
||||
<div class="flex flex-col gap-3 mb-6">
|
||||
@@ -3116,42 +3123,43 @@ defmodule SimpleshopThemeWeb.ShopComponents do
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span style="color: var(--t-text-secondary);">Delivery</span>
|
||||
<span style="color: var(--t-text-primary);">
|
||||
{SimpleshopTheme.Cart.format_price(@delivery)}
|
||||
<span class="text-sm" style="color: var(--t-text-secondary);">
|
||||
Calculated at checkout
|
||||
</span>
|
||||
</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="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);">
|
||||
{SimpleshopTheme.Cart.format_price(@total)}
|
||||
{SimpleshopTheme.Cart.format_price(@subtotal)}
|
||||
</span>
|
||||
</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 %>
|
||||
<.shop_button class="w-full px-6 py-3 font-semibold transition-all mb-3">
|
||||
Checkout
|
||||
</.shop_button>
|
||||
<.shop_button_outline
|
||||
phx-click="change_preview_page"
|
||||
phx-value-page="collection"
|
||||
class="w-full px-6 py-3 font-semibold transition-all"
|
||||
>
|
||||
Continue Shopping
|
||||
Continue shopping
|
||||
</.shop_button_outline>
|
||||
<% 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
|
||||
href="/collections/all"
|
||||
class="block w-full px-6 py-3 font-semibold transition-all text-center"
|
||||
>
|
||||
Continue Shopping
|
||||
Continue shopping
|
||||
</.shop_link_outline>
|
||||
<% end %>
|
||||
</.shop_card>
|
||||
@@ -4298,6 +4306,80 @@ defmodule SimpleshopThemeWeb.ShopComponents do
|
||||
"""
|
||||
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
|
||||
# Database images end with / (e.g., /images/{id}/variant/)
|
||||
# Mockups use - separator (e.g., /mockups/product-1)
|
||||
|
||||
Reference in New Issue
Block a user