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:
jamey
2026-02-07 08:30:17 +00:00
parent cff21703f1
commit ff1bc483b9
19 changed files with 931 additions and 69 deletions

View File

@@ -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>