add newsletter and email campaigns

Subscribers with double opt-in confirmation, campaign composer with
draft/scheduled/sent lifecycle, admin dashboard with overview stats,
CSV export, and shop signup form wired into page builder blocks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-28 23:25:28 +00:00
parent 8f989d892d
commit ad2e6d1e6d
32 changed files with 2497 additions and 32 deletions

View File

@@ -286,16 +286,18 @@ defmodule BerrypodWeb.ShopComponents.Content do
## Attributes
* `title` - Optional. Card heading. Defaults to "Stay in touch".
* `title` - Optional. Card heading. Defaults to "Newsletter".
* `description` - Optional. Card description.
* `button_text` - Optional. Button text. Defaults to "Subscribe".
* `variant` - Optional. Either `:card` (default, with border/background) or `:inline` (no card styling, for embedding in footer).
* `newsletter_state` - Optional. `:idle | :submitted | :error | :disabled`. Defaults to `:idle`.
* `newsletter_enabled` - Optional. Whether signups are active. Defaults to `true`.
## Examples
<.newsletter_card />
<.newsletter_card title="Studio news" description="Get updates on new products." />
<.newsletter_card variant={:inline} />
<.newsletter_card variant={:inline} newsletter_state={@newsletter_state} />
"""
attr :title, :string, default: "Newsletter"
@@ -304,6 +306,30 @@ defmodule BerrypodWeb.ShopComponents.Content do
attr :button_text, :string, default: "Subscribe"
attr :variant, :atom, default: :card
attr :newsletter_state, :atom, default: :idle
attr :newsletter_enabled, :boolean, default: true
def newsletter_card(%{newsletter_state: :submitted, variant: :inline} = assigns) do
~H"""
<div>
<h3 class="newsletter-heading">{@title}</h3>
<p class="card-text card-text--spaced">
Check your inbox to confirm your subscription.
</p>
</div>
"""
end
def newsletter_card(%{newsletter_state: :submitted} = assigns) do
~H"""
<.shop_card class="card-section">
<h3 class="card-heading">{@title}</h3>
<p class="card-text card-text--spaced">
Check your inbox to confirm your subscription.
</p>
</.shop_card>
"""
end
def newsletter_card(%{variant: :inline} = assigns) do
~H"""
@@ -314,10 +340,27 @@ defmodule BerrypodWeb.ShopComponents.Content do
<p class="card-text card-text--spaced">
{@description}
</p>
<form class="card-inline-form" onsubmit="return false">
<.shop_input type="email" placeholder="your@email.com" class="email-input" />
<.shop_button type="submit">{@button_text}</.shop_button>
</form>
<%= if @newsletter_enabled do %>
<form
action="/newsletter/subscribe"
method="post"
phx-submit="newsletter_subscribe"
class="card-inline-form"
>
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
<.shop_input
type="email"
name="email"
placeholder="your@email.com"
class="email-input"
required
/>
<.shop_button type="submit">{@button_text}</.shop_button>
</form>
<p :if={@newsletter_state == :error} class="card-text newsletter-error">
Something went wrong. Please try again.
</p>
<% end %>
</div>
"""
end
@@ -329,10 +372,27 @@ defmodule BerrypodWeb.ShopComponents.Content do
<p class="card-text card-text--spaced">
{@description}
</p>
<form class="card-inline-form" onsubmit="return false">
<.shop_input type="email" placeholder="your@email.com" class="email-input" />
<.shop_button type="submit">{@button_text}</.shop_button>
</form>
<%= if @newsletter_enabled do %>
<form
action="/newsletter/subscribe"
method="post"
phx-submit="newsletter_subscribe"
class="card-inline-form"
>
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
<.shop_input
type="email"
name="email"
placeholder="your@email.com"
class="email-input"
required
/>
<.shop_button type="submit">{@button_text}</.shop_button>
</form>
<p :if={@newsletter_state == :error} class="card-text newsletter-error">
Something went wrong. Please try again.
</p>
<% end %>
</.shop_card>
"""
end

View File

@@ -52,7 +52,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
cart_subtotal cart_total cart_drawer_open cart_status active_page error_page is_admin
search_query search_results search_open categories shipping_estimate
country_code available_countries editing editor_current_path editor_sidebar_open
header_nav_items footer_nav_items)a
header_nav_items footer_nav_items newsletter_enabled newsletter_state)a
@doc """
Extracts the assigns relevant to `shop_layout` from a full assigns map.
@@ -98,6 +98,8 @@ defmodule BerrypodWeb.ShopComponents.Layout do
attr :available_countries, :list, default: []
attr :header_nav_items, :list, default: []
attr :footer_nav_items, :list, default: []
attr :newsletter_enabled, :boolean, default: false
attr :newsletter_state, :atom, default: :idle
slot :inner_block, required: true
@@ -136,6 +138,8 @@ defmodule BerrypodWeb.ShopComponents.Layout do
mode={@mode}
categories={assigns[:categories] || []}
footer_nav_items={@footer_nav_items}
newsletter_enabled={@newsletter_enabled}
newsletter_state={@newsletter_state}
/>
<.cart_drawer
@@ -522,6 +526,8 @@ defmodule BerrypodWeb.ShopComponents.Layout do
attr :mode, :atom, default: :live
attr :categories, :list, default: []
attr :footer_nav_items, :list, default: []
attr :newsletter_enabled, :boolean, default: false
attr :newsletter_state, :atom, default: :idle
def shop_footer(assigns) do
assigns = assign(assigns, :current_year, Date.utc_today().year)
@@ -530,7 +536,11 @@ defmodule BerrypodWeb.ShopComponents.Layout do
<footer class="shop-footer">
<div class="shop-footer-inner">
<div class="footer-grid">
<.newsletter_card variant={:inline} />
<.newsletter_card
variant={:inline}
newsletter_enabled={@newsletter_enabled}
newsletter_state={@newsletter_state}
/>
<div class="footer-links">
<div>