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)
|
||||
|
||||
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 "/products/:id", ShopLive.ProductShow, :show
|
||||
live "/cart", ShopLive.Cart, :index
|
||||
live "/checkout/success", ShopLive.CheckoutSuccess, :show
|
||||
end
|
||||
|
||||
# Checkout (POST — creates Stripe session and redirects)
|
||||
post "/checkout", CheckoutController, :create
|
||||
end
|
||||
|
||||
# Cart API (session persistence for LiveView)
|
||||
@@ -66,6 +70,12 @@ defmodule SimpleshopThemeWeb.Router do
|
||||
post "/printify", WebhookController, :printify
|
||||
end
|
||||
|
||||
scope "/webhooks", SimpleshopThemeWeb do
|
||||
pipe_through [:api]
|
||||
|
||||
post "/stripe", StripeWebhookController, :handle
|
||||
end
|
||||
|
||||
# Enable LiveDashboard and Swoosh mailbox preview in development
|
||||
if Application.compile_env(:simpleshop_theme, :dev_routes) do
|
||||
# If you want to use the LiveDashboard in production, you should put
|
||||
|
||||
Reference in New Issue
Block a user