rename project from SimpleshopTheme to Berrypod
All modules, configs, paths, and references updated. 836 tests pass, zero warnings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7
lib/berrypod_web/controllers/admin_controller.ex
Normal file
7
lib/berrypod_web/controllers/admin_controller.ex
Normal file
@@ -0,0 +1,7 @@
|
||||
defmodule BerrypodWeb.AdminController do
|
||||
use BerrypodWeb, :controller
|
||||
|
||||
def index(conn, _params) do
|
||||
redirect(conn, to: ~p"/admin/orders")
|
||||
end
|
||||
end
|
||||
31
lib/berrypod_web/controllers/cart_controller.ex
Normal file
31
lib/berrypod_web/controllers/cart_controller.ex
Normal file
@@ -0,0 +1,31 @@
|
||||
defmodule BerrypodWeb.CartController do
|
||||
@moduledoc """
|
||||
API controller for cart session persistence.
|
||||
|
||||
LiveView cannot write to session directly, so cart updates are persisted
|
||||
via this API endpoint called from a JS hook after each cart modification.
|
||||
"""
|
||||
|
||||
use BerrypodWeb, :controller
|
||||
|
||||
alias Berrypod.Cart
|
||||
|
||||
@doc """
|
||||
Updates the cart in session.
|
||||
|
||||
Expects JSON body with `items` as a list of [variant_id, quantity] arrays.
|
||||
"""
|
||||
def update(conn, %{"items" => items}) when is_list(items) do
|
||||
cart_items = Cart.deserialize(items)
|
||||
|
||||
conn
|
||||
|> Cart.put_in_session(cart_items)
|
||||
|> json(%{ok: true})
|
||||
end
|
||||
|
||||
def update(conn, _params) do
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: "Invalid cart data"})
|
||||
end
|
||||
end
|
||||
129
lib/berrypod_web/controllers/checkout_controller.ex
Normal file
129
lib/berrypod_web/controllers/checkout_controller.ex
Normal file
@@ -0,0 +1,129 @@
|
||||
defmodule BerrypodWeb.CheckoutController do
|
||||
use BerrypodWeb, :controller
|
||||
|
||||
alias Berrypod.Cart
|
||||
alias Berrypod.Orders
|
||||
alias Berrypod.Shipping
|
||||
|
||||
require Logger
|
||||
|
||||
def create(conn, _params) do
|
||||
cart_items = Cart.get_from_session(get_session(conn))
|
||||
hydrated = Cart.hydrate(cart_items)
|
||||
|
||||
if hydrated == [] do
|
||||
conn
|
||||
|> put_flash(:error, "Your basket is empty")
|
||||
|> redirect(to: ~p"/cart")
|
||||
else
|
||||
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 = BerrypodWeb.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"]
|
||||
}
|
||||
}
|
||||
|> maybe_add_shipping_options(hydrated_items)
|
||||
|
||||
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
|
||||
|
||||
defp maybe_add_shipping_options(params, hydrated_items) do
|
||||
gb_result = Shipping.calculate_for_cart(hydrated_items, "GB")
|
||||
us_result = Shipping.calculate_for_cart(hydrated_items, "US")
|
||||
|
||||
options =
|
||||
[]
|
||||
|> maybe_add_option(gb_result, "UK delivery", 5, 10)
|
||||
|> maybe_add_option(us_result, "International delivery", 10, 20)
|
||||
|
||||
if options == [] do
|
||||
params
|
||||
else
|
||||
Map.put(params, :shipping_options, options)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_add_option(options, {:ok, cost}, name, min_days, max_days) when cost > 0 do
|
||||
option = %{
|
||||
shipping_rate_data: %{
|
||||
type: "fixed_amount",
|
||||
display_name: name,
|
||||
fixed_amount: %{amount: cost, currency: "gbp"},
|
||||
delivery_estimate: %{
|
||||
minimum: %{unit: "business_day", value: min_days},
|
||||
maximum: %{unit: "business_day", value: max_days}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
options ++ [option]
|
||||
end
|
||||
|
||||
defp maybe_add_option(options, _result, _name, _min, _max), do: options
|
||||
end
|
||||
135
lib/berrypod_web/controllers/error_html.ex
Normal file
135
lib/berrypod_web/controllers/error_html.ex
Normal file
@@ -0,0 +1,135 @@
|
||||
defmodule BerrypodWeb.ErrorHTML do
|
||||
@moduledoc """
|
||||
This module is invoked by your endpoint in case of errors on HTML requests.
|
||||
|
||||
See config/config.exs.
|
||||
"""
|
||||
use BerrypodWeb, :html
|
||||
|
||||
alias Berrypod.Settings
|
||||
alias Berrypod.Settings.ThemeSettings
|
||||
alias Berrypod.Media
|
||||
alias Berrypod.Products
|
||||
alias Berrypod.Theme.{CSSCache, CSSGenerator}
|
||||
|
||||
def render("404.html", assigns) do
|
||||
render_error_page(
|
||||
assigns,
|
||||
"404",
|
||||
"Page Not Found",
|
||||
"Sorry, we couldn't find the page you're looking for. Perhaps you've mistyped the URL or the page has been moved."
|
||||
)
|
||||
end
|
||||
|
||||
def render("500.html", assigns) do
|
||||
render_error_page(
|
||||
assigns,
|
||||
"500",
|
||||
"Server Error",
|
||||
"Something went wrong on our end. Please try again later or contact support if the problem persists."
|
||||
)
|
||||
end
|
||||
|
||||
def render(template, _assigns) do
|
||||
Phoenix.Controller.status_message_from_template(template)
|
||||
end
|
||||
|
||||
defp render_error_page(assigns, error_code, error_title, error_description) do
|
||||
# Load theme settings with fallback for error conditions
|
||||
{theme_settings, generated_css} = load_theme_data()
|
||||
logo_image = safe_load(&Media.get_logo/0)
|
||||
header_image = safe_load(&Media.get_header/0)
|
||||
|
||||
products = safe_load(fn -> Products.list_visible_products(limit: 4) end) || []
|
||||
categories = safe_load(fn -> Products.list_categories() end) || []
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> Map.put(:theme_settings, theme_settings)
|
||||
|> Map.put(:generated_css, generated_css)
|
||||
|> Map.put(:logo_image, logo_image)
|
||||
|> Map.put(:header_image, header_image)
|
||||
|> Map.put(:products, products)
|
||||
|> Map.put(:categories, categories)
|
||||
|> Map.put(:error_code, error_code)
|
||||
|> Map.put(:error_title, error_title)
|
||||
|> Map.put(:error_description, error_description)
|
||||
|> Map.put(:mode, :shop)
|
||||
|> Map.put(:cart_items, [])
|
||||
|> Map.put(:cart_count, 0)
|
||||
|> Map.put(:cart_subtotal, "£0.00")
|
||||
|
||||
~H"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{@error_code} - {@error_title}</title>
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/css/admin.css"} />
|
||||
<style id="theme-css">
|
||||
<%= Phoenix.HTML.raw(@generated_css) %>
|
||||
</style>
|
||||
</head>
|
||||
<body class="h-full">
|
||||
<div
|
||||
class="shop-root themed h-full"
|
||||
data-mood={@theme_settings.mood}
|
||||
data-typography={@theme_settings.typography}
|
||||
data-shape={@theme_settings.shape}
|
||||
data-density={@theme_settings.density}
|
||||
data-grid={@theme_settings.grid_columns}
|
||||
data-header={@theme_settings.header_layout}
|
||||
data-sticky={to_string(@theme_settings.sticky_header)}
|
||||
data-layout={@theme_settings.layout_width}
|
||||
data-shadow={@theme_settings.card_shadow}
|
||||
>
|
||||
<BerrypodWeb.PageTemplates.error
|
||||
theme_settings={@theme_settings}
|
||||
logo_image={@logo_image}
|
||||
header_image={@header_image}
|
||||
products={@products}
|
||||
categories={@categories}
|
||||
error_code={@error_code}
|
||||
error_title={@error_title}
|
||||
error_description={@error_description}
|
||||
mode={@mode}
|
||||
cart_items={@cart_items}
|
||||
cart_count={@cart_count}
|
||||
cart_subtotal={@cart_subtotal}
|
||||
/>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
end
|
||||
|
||||
defp load_theme_data do
|
||||
try 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
|
||||
|
||||
{theme_settings, generated_css}
|
||||
rescue
|
||||
_ -> {%ThemeSettings{}, ""}
|
||||
end
|
||||
end
|
||||
|
||||
defp safe_load(fun) do
|
||||
try do
|
||||
fun.()
|
||||
rescue
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
end
|
||||
21
lib/berrypod_web/controllers/error_json.ex
Normal file
21
lib/berrypod_web/controllers/error_json.ex
Normal file
@@ -0,0 +1,21 @@
|
||||
defmodule BerrypodWeb.ErrorJSON do
|
||||
@moduledoc """
|
||||
This module is invoked by your endpoint in case of errors on JSON requests.
|
||||
|
||||
See config/config.exs.
|
||||
"""
|
||||
|
||||
# If you want to customize a particular status code,
|
||||
# you may add your own clauses, such as:
|
||||
#
|
||||
# def render("500.json", _assigns) do
|
||||
# %{errors: %{detail: "Internal Server Error"}}
|
||||
# end
|
||||
|
||||
# By default, Phoenix returns the status message from
|
||||
# the template name. For example, "404.json" becomes
|
||||
# "Not Found".
|
||||
def render(template, _assigns) do
|
||||
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
|
||||
end
|
||||
end
|
||||
20
lib/berrypod_web/controllers/error_preview_controller.ex
Normal file
20
lib/berrypod_web/controllers/error_preview_controller.ex
Normal file
@@ -0,0 +1,20 @@
|
||||
defmodule BerrypodWeb.ErrorPreviewController do
|
||||
@moduledoc """
|
||||
Development-only controller for previewing error pages.
|
||||
"""
|
||||
use BerrypodWeb, :controller
|
||||
|
||||
def not_found(conn, _params) do
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> put_view(BerrypodWeb.ErrorHTML)
|
||||
|> render("404.html")
|
||||
end
|
||||
|
||||
def server_error(conn, _params) do
|
||||
conn
|
||||
|> put_status(:internal_server_error)
|
||||
|> put_view(BerrypodWeb.ErrorHTML)
|
||||
|> render("500.html")
|
||||
end
|
||||
end
|
||||
7
lib/berrypod_web/controllers/health_controller.ex
Normal file
7
lib/berrypod_web/controllers/health_controller.ex
Normal file
@@ -0,0 +1,7 @@
|
||||
defmodule BerrypodWeb.HealthController do
|
||||
use BerrypodWeb, :controller
|
||||
|
||||
def show(conn, _params) do
|
||||
json(conn, %{status: "ok"})
|
||||
end
|
||||
end
|
||||
49
lib/berrypod_web/controllers/image_controller.ex
Normal file
49
lib/berrypod_web/controllers/image_controller.ex
Normal file
@@ -0,0 +1,49 @@
|
||||
defmodule BerrypodWeb.ImageController do
|
||||
use BerrypodWeb, :controller
|
||||
|
||||
alias Berrypod.Media
|
||||
alias Berrypod.Media.SVGRecolorer
|
||||
|
||||
@doc """
|
||||
Serves an SVG image recolored with the specified color.
|
||||
|
||||
The color should be a hex color code (with or without the leading #).
|
||||
Only works with SVG images.
|
||||
"""
|
||||
def recolored_svg(conn, %{"id" => id, "color" => color}) do
|
||||
clean_color = normalize_color(color)
|
||||
|
||||
with true <- SVGRecolorer.valid_hex_color?(clean_color),
|
||||
%{is_svg: true, svg_content: svg} when not is_nil(svg) <- Media.get_image(id) do
|
||||
recolored = SVGRecolorer.recolor(svg, clean_color)
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("image/svg+xml")
|
||||
|> put_resp_header("cache-control", "public, max-age=3600")
|
||||
|> put_resp_header("etag", ~s("#{id}-#{clean_color}"))
|
||||
|> send_resp(200, recolored)
|
||||
else
|
||||
false ->
|
||||
send_resp(conn, 400, "Invalid color format")
|
||||
|
||||
nil ->
|
||||
send_resp(conn, 404, "Image not found")
|
||||
|
||||
%{is_svg: false} ->
|
||||
send_resp(conn, 400, "Image is not an SVG")
|
||||
|
||||
%{svg_content: nil} ->
|
||||
send_resp(conn, 400, "SVG content not available")
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_color(color) do
|
||||
color = String.trim(color)
|
||||
|
||||
if String.starts_with?(color, "#") do
|
||||
color
|
||||
else
|
||||
"#" <> color
|
||||
end
|
||||
end
|
||||
end
|
||||
7
lib/berrypod_web/controllers/page_controller.ex
Normal file
7
lib/berrypod_web/controllers/page_controller.ex
Normal file
@@ -0,0 +1,7 @@
|
||||
defmodule BerrypodWeb.PageController do
|
||||
use BerrypodWeb, :controller
|
||||
|
||||
def home(conn, _params) do
|
||||
render(conn, :home)
|
||||
end
|
||||
end
|
||||
10
lib/berrypod_web/controllers/page_html.ex
Normal file
10
lib/berrypod_web/controllers/page_html.ex
Normal file
@@ -0,0 +1,10 @@
|
||||
defmodule BerrypodWeb.PageHTML do
|
||||
@moduledoc """
|
||||
This module contains pages rendered by PageController.
|
||||
|
||||
See the `page_html` directory for all templates available.
|
||||
"""
|
||||
use BerrypodWeb, :html
|
||||
|
||||
embed_templates "page_html/*"
|
||||
end
|
||||
202
lib/berrypod_web/controllers/page_html/home.html.heex
Normal file
202
lib/berrypod_web/controllers/page_html/home.html.heex
Normal file
@@ -0,0 +1,202 @@
|
||||
<Layouts.flash_group flash={@flash} />
|
||||
<div class="left-[40rem] fixed inset-y-0 right-0 z-0 hidden lg:block xl:left-[50rem]">
|
||||
<svg
|
||||
viewBox="0 0 1480 957"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
class="absolute inset-0 h-full w-full"
|
||||
preserveAspectRatio="xMinYMid slice"
|
||||
>
|
||||
<path fill="#EE7868" d="M0 0h1480v957H0z" />
|
||||
<path
|
||||
d="M137.542 466.27c-582.851-48.41-988.806-82.127-1608.412 658.2l67.39 810 3083.15-256.51L1535.94-49.622l-98.36 8.183C1269.29 281.468 734.115 515.799 146.47 467.012l-8.928-.742Z"
|
||||
fill="#FF9F92"
|
||||
/>
|
||||
<path
|
||||
d="M371.028 528.664C-169.369 304.988-545.754 149.198-1361.45 665.565l-182.58 792.025 3014.73 694.98 389.42-1689.25-96.18-22.171C1505.28 697.438 924.153 757.586 379.305 532.09l-8.277-3.426Z"
|
||||
fill="#FA8372"
|
||||
/>
|
||||
<path
|
||||
d="M359.326 571.714C-104.765 215.795-428.003-32.102-1349.55 255.554l-282.3 1224.596 3047.04 722.01 312.24-1354.467C1411.25 1028.3 834.355 935.995 366.435 577.166l-7.109-5.452Z"
|
||||
fill="#E96856"
|
||||
fill-opacity=".6"
|
||||
/>
|
||||
<path
|
||||
d="M1593.87 1236.88c-352.15 92.63-885.498-145.85-1244.602-613.557l-5.455-7.105C-12.347 152.31-260.41-170.8-1225-131.458l-368.63 1599.048 3057.19 704.76 130.31-935.47Z"
|
||||
fill="#C42652"
|
||||
fill-opacity=".2"
|
||||
/>
|
||||
<path
|
||||
d="M1411.91 1526.93c-363.79 15.71-834.312-330.6-1085.883-863.909l-3.822-8.102C72.704 125.95-101.074-242.476-1052.01-408.907l-699.85 1484.267 2837.75 1338.01 326.02-886.44Z"
|
||||
fill="#A41C42"
|
||||
fill-opacity=".2"
|
||||
/>
|
||||
<path
|
||||
d="M1116.26 1863.69c-355.457-78.98-720.318-535.27-825.287-1115.521l-1.594-8.816C185.286 163.833 112.786-237.016-762.678-643.898L-1822.83 608.665 571.922 2635.55l544.338-771.86Z"
|
||||
fill="#A41C42"
|
||||
fill-opacity=".2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
|
||||
<div class="mx-auto max-w-xl lg:mx-0">
|
||||
<svg viewBox="0 0 71 48" class="h-12" aria-hidden="true">
|
||||
<path
|
||||
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.043.03a2.96 2.96 0 0 0 .04-.029c-.038-.117-.107-.12-.197-.054l.122.107c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728.374.388.763.768 1.182 1.106 1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zm17.29-19.32c0-.023.001-.045.003-.068l-.006.006.006-.006-.036-.004.021.018.012.053Zm-20 14.744a7.61 7.61 0 0 0-.072-.041.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zm-.072-.041-.008-.034-.008.01.008-.01-.022-.006.005.026.024.014Z"
|
||||
fill="#FD4F00"
|
||||
/>
|
||||
</svg>
|
||||
<div class="mt-10 flex justify-between items-center">
|
||||
<h1 class="flex items-center text-sm font-semibold leading-6">
|
||||
Phoenix Framework
|
||||
<small class="badge badge-warning badge-sm ml-3">
|
||||
v{Application.spec(:phoenix, :vsn)}
|
||||
</small>
|
||||
</h1>
|
||||
<Layouts.theme_toggle />
|
||||
</div>
|
||||
|
||||
<p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-balance">
|
||||
Peace of mind from prototype to production.
|
||||
</p>
|
||||
<p class="mt-4 leading-7 text-base-content/70">
|
||||
Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale.
|
||||
</p>
|
||||
<div class="flex">
|
||||
<div class="w-full sm:w-auto">
|
||||
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-3">
|
||||
<a
|
||||
href="https://hexdocs.pm/phoenix/overview.html"
|
||||
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
|
||||
>
|
||||
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
|
||||
</span>
|
||||
<span class="relative flex items-center gap-4 sm:flex-col">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
|
||||
<path d="m12 4 10-2v18l-10 2V4Z" fill="currentColor" fill-opacity=".15" />
|
||||
<path
|
||||
d="M12 4 2 2v18l10 2m0-18v18m0-18 10-2v18l-10 2"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Guides & Docs
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/phoenixframework/phoenix"
|
||||
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
|
||||
>
|
||||
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
|
||||
</span>
|
||||
<span class="relative flex items-center gap-4 sm:flex-col">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" class="h-6 w-6">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M12 0C5.37 0 0 5.506 0 12.303c0 5.445 3.435 10.043 8.205 11.674.6.107.825-.262.825-.585 0-.292-.015-1.261-.015-2.291C6 21.67 5.22 20.346 4.98 19.654c-.135-.354-.72-1.446-1.23-1.738-.42-.23-1.02-.8-.015-.815.945-.015 1.62.892 1.845 1.261 1.08 1.86 2.805 1.338 3.495 1.015.105-.8.42-1.338.765-1.645-2.67-.308-5.46-1.37-5.46-6.075 0-1.338.465-2.446 1.23-3.307-.12-.308-.54-1.569.12-3.26 0 0 1.005-.323 3.3 1.26.96-.276 1.98-.415 3-.415s2.04.139 3 .416c2.295-1.6 3.3-1.261 3.3-1.261.66 1.691.24 2.952.12 3.26.765.861 1.23 1.953 1.23 3.307 0 4.721-2.805 5.767-5.475 6.075.435.384.81 1.122.81 2.276 0 1.645-.015 2.968-.015 3.383 0 .323.225.707.825.585a12.047 12.047 0 0 0 5.919-4.489A12.536 12.536 0 0 0 24 12.304C24 5.505 18.63 0 12 0Z"
|
||||
/>
|
||||
</svg>
|
||||
Source Code
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href={"https://github.com/phoenixframework/phoenix/blob/v#{Application.spec(:phoenix, :vsn)}/CHANGELOG.md"}
|
||||
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
|
||||
>
|
||||
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
|
||||
</span>
|
||||
<span class="relative flex items-center gap-4 sm:flex-col">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
|
||||
<path
|
||||
d="M12 1v6M12 17v6"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="4"
|
||||
fill="currentColor"
|
||||
fill-opacity=".15"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Changelog
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-10 grid grid-cols-1 gap-y-4 text-sm leading-6 text-base-content/80 sm:grid-cols-2">
|
||||
<div>
|
||||
<a
|
||||
href="https://elixirforum.com"
|
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
|
||||
>
|
||||
<path d="M8 13.833c3.866 0 7-2.873 7-6.416C15 3.873 11.866 1 8 1S1 3.873 1 7.417c0 1.081.292 2.1.808 2.995.606 1.05.806 2.399.086 3.375l-.208.283c-.285.386-.01.905.465.85.852-.098 2.048-.318 3.137-.81a3.717 3.717 0 0 1 1.91-.318c.263.027.53.041.802.041Z" />
|
||||
</svg>
|
||||
Discuss on the Elixir Forum
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://discord.gg/elixir"
|
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
|
||||
>
|
||||
<path d="M13.545 2.995c-1.02-.46-2.114-.8-3.257-.994a.05.05 0 0 0-.052.024c-.141.246-.297.567-.406.82a12.377 12.377 0 0 0-3.658 0 8.238 8.238 0 0 0-.412-.82.052.052 0 0 0-.052-.024 13.315 13.315 0 0 0-3.257.994.046.046 0 0 0-.021.018C.356 6.063-.213 9.036.066 11.973c.001.015.01.029.02.038a13.353 13.353 0 0 0 3.996 1.987.052.052 0 0 0 .056-.018c.308-.414.582-.85.818-1.309a.05.05 0 0 0-.028-.069 8.808 8.808 0 0 1-1.248-.585.05.05 0 0 1-.005-.084c.084-.062.168-.126.248-.191a.05.05 0 0 1 .051-.007c2.619 1.176 5.454 1.176 8.041 0a.05.05 0 0 1 .053.006c.08.065.164.13.248.192a.05.05 0 0 1-.004.084c-.399.23-.813.423-1.249.585a.05.05 0 0 0-.027.07c.24.457.514.893.817 1.307a.051.051 0 0 0 .056.019 13.31 13.31 0 0 0 4.001-1.987.05.05 0 0 0 .021-.037c.334-3.396-.559-6.345-2.365-8.96a.04.04 0 0 0-.021-.02Zm-8.198 7.19c-.789 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.637 1.587-1.438 1.587Zm5.316 0c-.788 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.63 1.587-1.438 1.587Z" />
|
||||
</svg>
|
||||
Join our Discord server
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://elixir-slack.community/"
|
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
|
||||
>
|
||||
<path d="M3.361 10.11a1.68 1.68 0 1 1-1.68-1.681h1.68v1.682ZM4.209 10.11a1.68 1.68 0 1 1 3.361 0v4.21a1.68 1.68 0 1 1-3.361 0v-4.21ZM5.89 3.361a1.68 1.68 0 1 1 1.681-1.68v1.68H5.89ZM5.89 4.209a1.68 1.68 0 1 1 0 3.361H1.68a1.68 1.68 0 1 1 0-3.361h4.21ZM12.639 5.89a1.68 1.68 0 1 1 1.68 1.681h-1.68V5.89ZM11.791 5.89a1.68 1.68 0 1 1-3.361 0V1.68a1.68 1.68 0 0 1 3.361 0v4.21ZM10.11 12.639a1.68 1.68 0 1 1-1.681 1.68v-1.68h1.682ZM10.11 11.791a1.68 1.68 0 1 1 0-3.361h4.21a1.68 1.68 0 1 1 0 3.361h-4.21Z" />
|
||||
</svg>
|
||||
Join us on Slack
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://fly.io/docs/elixir/getting-started/"
|
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
|
||||
>
|
||||
<path d="M1 12.5A4.5 4.5 0 005.5 17H15a4 4 0 001.866-7.539 3.504 3.504 0 00-4.504-4.272A4.5 4.5 0 004.06 8.235 4.502 4.502 0 001 12.5z" />
|
||||
</svg>
|
||||
Deploy your application
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
132
lib/berrypod_web/controllers/stripe_webhook_controller.ex
Normal file
132
lib/berrypod_web/controllers/stripe_webhook_controller.ex
Normal file
@@ -0,0 +1,132 @@
|
||||
defmodule BerrypodWeb.StripeWebhookController do
|
||||
use BerrypodWeb, :controller
|
||||
|
||||
alias Berrypod.Orders
|
||||
alias Berrypod.Orders.{OrderNotifier, OrderSubmissionWorker}
|
||||
|
||||
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 cost from Stripe (if shipping options were presented)
|
||||
order = update_shipping_cost(order, session)
|
||||
|
||||
# Update shipping address if collected by Stripe
|
||||
order =
|
||||
if session.shipping_details do
|
||||
{:ok, updated} = update_shipping(order, session.shipping_details)
|
||||
updated
|
||||
else
|
||||
order
|
||||
end
|
||||
|
||||
# Update customer email from Stripe session
|
||||
order =
|
||||
if session.customer_details && session.customer_details.email do
|
||||
{:ok, updated} =
|
||||
Orders.update_order(order, %{customer_email: session.customer_details.email})
|
||||
|
||||
updated
|
||||
else
|
||||
order
|
||||
end
|
||||
|
||||
# Reload items for the email (update_order doesn't preload)
|
||||
order = Orders.get_order(order.id)
|
||||
|
||||
# Broadcast to success page via PubSub
|
||||
Phoenix.PubSub.broadcast(
|
||||
Berrypod.PubSub,
|
||||
"order:#{order.id}:status",
|
||||
{:order_paid, order}
|
||||
)
|
||||
|
||||
OrderNotifier.deliver_order_confirmation(order)
|
||||
|
||||
# Submit to fulfilment provider
|
||||
if order.shipping_address && order.shipping_address != %{} do
|
||||
OrderSubmissionWorker.enqueue(order.id)
|
||||
else
|
||||
Logger.warning(
|
||||
"Order #{order.order_number} paid but no shipping address — manual submit needed"
|
||||
)
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
defp update_shipping_cost(order, session) do
|
||||
shipping_amount = get_in(session, [Access.key(:shipping_cost), Access.key(:amount_total)])
|
||||
|
||||
if is_integer(shipping_amount) and shipping_amount > 0 do
|
||||
new_total = order.subtotal + shipping_amount
|
||||
|
||||
case Orders.update_order(order, %{shipping_cost: shipping_amount, total: new_total}) do
|
||||
{:ok, updated} -> updated
|
||||
{:error, _} -> order
|
||||
end
|
||||
else
|
||||
order
|
||||
end
|
||||
end
|
||||
end
|
||||
67
lib/berrypod_web/controllers/user_session_controller.ex
Normal file
67
lib/berrypod_web/controllers/user_session_controller.ex
Normal file
@@ -0,0 +1,67 @@
|
||||
defmodule BerrypodWeb.UserSessionController do
|
||||
use BerrypodWeb, :controller
|
||||
|
||||
alias Berrypod.Accounts
|
||||
alias BerrypodWeb.UserAuth
|
||||
|
||||
def create(conn, %{"_action" => "confirmed"} = params) do
|
||||
create(conn, params, "User confirmed successfully.")
|
||||
end
|
||||
|
||||
def create(conn, params) do
|
||||
create(conn, params, "Welcome back!")
|
||||
end
|
||||
|
||||
# magic link login
|
||||
defp create(conn, %{"user" => %{"token" => token} = user_params}, info) do
|
||||
case Accounts.login_user_by_magic_link(token) do
|
||||
{:ok, {user, tokens_to_disconnect}} ->
|
||||
UserAuth.disconnect_sessions(tokens_to_disconnect)
|
||||
|
||||
conn
|
||||
|> put_flash(:info, info)
|
||||
|> UserAuth.log_in_user(user, user_params)
|
||||
|
||||
_ ->
|
||||
conn
|
||||
|> put_flash(:error, "The link is invalid or it has expired.")
|
||||
|> redirect(to: ~p"/users/log-in")
|
||||
end
|
||||
end
|
||||
|
||||
# email + password login
|
||||
defp create(conn, %{"user" => user_params}, info) do
|
||||
%{"email" => email, "password" => password} = user_params
|
||||
|
||||
if user = Accounts.get_user_by_email_and_password(email, password) do
|
||||
conn
|
||||
|> put_flash(:info, info)
|
||||
|> UserAuth.log_in_user(user, user_params)
|
||||
else
|
||||
# In order to prevent user enumeration attacks, don't disclose whether the email is registered.
|
||||
conn
|
||||
|> put_flash(:error, "Invalid email or password")
|
||||
|> put_flash(:email, String.slice(email, 0, 160))
|
||||
|> redirect(to: ~p"/users/log-in")
|
||||
end
|
||||
end
|
||||
|
||||
def update_password(conn, %{"user" => user_params} = params) do
|
||||
user = conn.assigns.current_scope.user
|
||||
true = Accounts.sudo_mode?(user)
|
||||
{:ok, {_user, expired_tokens}} = Accounts.update_user_password(user, user_params)
|
||||
|
||||
# disconnect all existing LiveViews with old sessions
|
||||
UserAuth.disconnect_sessions(expired_tokens)
|
||||
|
||||
conn
|
||||
|> put_session(:user_return_to, ~p"/admin/settings")
|
||||
|> create(params, "Password updated successfully!")
|
||||
end
|
||||
|
||||
def delete(conn, _params) do
|
||||
conn
|
||||
|> put_flash(:info, "Logged out successfully.")
|
||||
|> UserAuth.log_out_user()
|
||||
end
|
||||
end
|
||||
66
lib/berrypod_web/controllers/webhook_controller.ex
Normal file
66
lib/berrypod_web/controllers/webhook_controller.ex
Normal file
@@ -0,0 +1,66 @@
|
||||
defmodule BerrypodWeb.WebhookController do
|
||||
use BerrypodWeb, :controller
|
||||
|
||||
alias Berrypod.Webhooks
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Receives Printify webhook events.
|
||||
|
||||
Events:
|
||||
- product:publish:started - Product publish initiated
|
||||
- product:updated - Product was modified
|
||||
- product:deleted - Product was deleted
|
||||
- shop:disconnected - Shop was disconnected
|
||||
"""
|
||||
def printify(conn, params) do
|
||||
event_type = params["type"] || params["event"]
|
||||
resource = params["resource"] || params["data"] || %{}
|
||||
|
||||
Logger.info("Received Printify webhook: #{event_type}")
|
||||
|
||||
case Webhooks.handle_printify_event(event_type, resource) do
|
||||
:ok ->
|
||||
json(conn, %{status: "ok"})
|
||||
|
||||
{:ok, _} ->
|
||||
json(conn, %{status: "ok"})
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Webhook handling failed: #{inspect(reason)}")
|
||||
# Still return 200 to prevent Printify retrying
|
||||
json(conn, %{status: "ok"})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Receives Printful webhook events.
|
||||
|
||||
Events:
|
||||
- package_shipped - Package has been shipped
|
||||
- order_failed - Order processing failed
|
||||
- order_canceled - Order was canceled
|
||||
- product_updated - Sync product was updated
|
||||
- product_deleted - Sync product was deleted
|
||||
"""
|
||||
def printful(conn, params) do
|
||||
event_type = params["type"]
|
||||
data = params["data"] || %{}
|
||||
|
||||
Logger.info("Received Printful webhook: #{event_type}")
|
||||
|
||||
case Webhooks.handle_printful_event(event_type, data) do
|
||||
:ok ->
|
||||
json(conn, %{status: "ok"})
|
||||
|
||||
{:ok, _} ->
|
||||
json(conn, %{status: "ok"})
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Printful webhook handling failed: #{inspect(reason)}")
|
||||
# Return 200 to prevent Printful retrying
|
||||
json(conn, %{status: "ok"})
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user