improve cart recovery: product links in email, persistent session cookie
All checks were successful
deploy / deploy (push) Successful in 3m32s

- add product_id to order_items (migration + schema + create_order)
- cart recovery email now includes a direct product link per item
- extend session cookie max_age to 7 days so carts survive browser restarts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-24 13:12:41 +00:00
parent 2f4cd81f98
commit 61887b9d5b
7 changed files with 69 additions and 14 deletions

View File

@ -91,7 +91,7 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.m
| | **Analytics v2** ([plan](docs/plans/analytics-v2.md)) | | | | | | **Analytics v2** ([plan](docs/plans/analytics-v2.md)) | | | |
| ~~52~~ | ~~Comparison mode: period deltas on stat cards~~ | — | 1h | done | | ~~52~~ | ~~Comparison mode: period deltas on stat cards~~ | — | 1h | done |
| ~~53~~ | ~~Dashboard filtering (click to filter by dimension)~~ | 52 | 3h | done | | ~~53~~ | ~~Dashboard filtering (click to filter by dimension)~~ | 52 | 3h | done |
| 54 | CSV export | 52 | 1.5h | planned | | ~~54~~ | ~~CSV export~~ | 52 | 1.5h | done |
| ~~55~~ | ~~Entry/exit pages panel~~ | — | 1h | done | | ~~55~~ | ~~Entry/exit pages panel~~ | — | 1h | done |
| | **Favicon & site icons** ([plan](docs/plans/favicon.md)) | | | | | | **Favicon & site icons** ([plan](docs/plans/favicon.md)) | | | |
| 86 | Favicon source upload — `image_type: "icon"`, "use logo as icon" toggle, upload in theme editor, `FaviconGeneratorWorker`, `favicon_variants` table | — | 2.5h | planned | | 86 | Favicon source upload — `image_type: "icon"`, "use logo as icon" toggle, upload in theme editor, `FaviconGeneratorWorker`, `favicon_variants` table | — | 2.5h | planned |
@ -452,7 +452,7 @@ See: [plan](docs/plans/shipping-sync.md) for implementation details
- [x] Period comparison deltas on stat cards (6eda1de) - [x] Period comparison deltas on stat cards (6eda1de)
- [x] 2-year demo seed data with growth curve (6eda1de) - [x] 2-year demo seed data with growth curve (6eda1de)
- [x] Dashboard filtering (click referrer/country/device to filter all panels) (7ceee9c) - [x] Dashboard filtering (click referrer/country/device to filter all panels) (7ceee9c)
- [ ] CSV export - [x] CSV export (ZIP with 12 CSVs, period + filter aware)
- [x] Entry/exit pages panel - [x] Entry/exit pages panel
See: [docs/plans/analytics-v2.md](docs/plans/analytics-v2.md) for v2 plan See: [docs/plans/analytics-v2.md](docs/plans/analytics-v2.md) for v2 plan

View File

@ -41,7 +41,26 @@ This is enough to produce accurate, legally grounded content automatically.
**Conditional sections:** **Conditional sections:**
- Abandoned cart recovery enabled → "If you enter your email on our checkout page but don't complete payment, we may send you a single follow-up email. This is the only email you'll receive. You can unsubscribe at any time using the link in the email. We delete this data after 30 days." (UK PECR soft opt-in / EU legitimate interests — depending on shop country) - **Abandoned cart recovery enabled** (check `Settings.abandoned_cart_recovery_enabled?()` — already implemented):
Section heading: "Cart recovery"
For UK shops (PECR soft opt-in, Regulation 22):
> "If you enter your email address during checkout but don't complete your order, we may send you a single follow-up email about the items in your basket. We do this under the soft opt-in rule in PECR, which permits one reminder email when your address was collected during a checkout you started. We will never send more than one email per abandoned basket. You can unsubscribe at any time using the link included in that email. We store your email address and basket contents for this purpose only, and delete them after 30 days whether or not an email was sent."
For EU shops (GDPR legitimate interests, Article 6(1)(f)):
> "If you enter your email address during checkout but don't complete your order, we may send you a single follow-up email about the items in your basket. Our lawful basis is legitimate interests — recovering a genuine sale that was already in progress. We only ever send this once. You can unsubscribe at any time using the link in the email. We store your email address and basket contents for this purpose only, and delete them within 30 days."
For other shops (generic):
> "If you start a checkout and don't complete it, we may send you one follow-up email about the items you were buying. We send this once only. You can unsubscribe using the link in the email. We delete this data within 30 days."
In all cases append: "No tracking pixels or click-tracking links are used in these emails."
Implementation notes for the generator:
- `Settings.abandoned_cart_recovery_enabled?()` is already live — just call it
- Unsubscribes land at `/unsubscribe/:token` (signed `Phoenix.Token`, 2-year max age), stored in `email_suppressions`
- Records are in `abandoned_carts`, pruned nightly after 30 days by `AbandonedCartPruneWorker`
- The notice shown to customers at Stripe checkout time is the `custom_text` footer added by `CheckoutController` when recovery is enabled — cite this as the collection-time notice
- Newsletter enabled → email marketing section: subscription basis, how to unsubscribe, no third-party sharing - Newsletter enabled → email marketing section: subscription basis, how to unsubscribe, no third-party sharing
- Stripe Tax enabled → "Tax calculation is handled by Stripe, which processes transaction and location data to determine applicable rates." - Stripe Tax enabled → "Tax calculation is handled by Stripe, which processes transaction and location data to determine applicable rates."
@ -151,14 +170,15 @@ defmodule Berrypod.LegalPages do
alias Berrypod.{Settings, Shipping, Providers} alias Berrypod.{Settings, Shipping, Providers}
def privacy_content do def privacy_content do
shop_name = Settings.get(:shop_name) || "this shop" shop_name = Settings.get_setting("shop_name") || "this shop"
contact_email = Settings.get(:contact_email) contact_email = Settings.get_setting("contact_email")
shop_country = Settings.get(:shop_country, "GB") shop_country = Settings.get_setting("shop_country", "GB")
abandoned_cart_enabled = Settings.get(:abandoned_cart_enabled, false) # Settings.abandoned_cart_recovery_enabled?/0 is already implemented
newsletter_enabled = Settings.get(:newsletter_enabled, false) abandoned_cart_enabled = Settings.abandoned_cart_recovery_enabled?()
newsletter_enabled = Settings.get_setting("newsletter_enabled", false)
base_sections() base_sections()
|> maybe_add_abandoned_cart(abandoned_cart_enabled) |> maybe_add_abandoned_cart(abandoned_cart_enabled, shop_country)
|> maybe_add_newsletter(newsletter_enabled) |> maybe_add_newsletter(newsletter_enabled)
|> add_jurisdiction(shop_country) |> add_jurisdiction(shop_country)
|> add_contact(shop_name, contact_email) |> add_contact(shop_name, contact_email)

View File

@ -98,6 +98,7 @@ defmodule Berrypod.Orders do
%{ %{
order_id: order.id, order_id: order.id,
variant_id: item.variant_id, variant_id: item.variant_id,
product_id: item[:product_id],
product_name: item.name, product_name: item.name,
variant_title: item.variant, variant_title: item.variant,
quantity: item.quantity, quantity: item.quantity,

View File

@ -7,6 +7,7 @@ defmodule Berrypod.Orders.OrderItem do
schema "order_items" do schema "order_items" do
field :variant_id, :string field :variant_id, :string
field :product_id, :string
field :product_name, :string field :product_name, :string
field :variant_title, :string field :variant_title, :string
field :quantity, :integer field :quantity, :integer
@ -19,7 +20,15 @@ defmodule Berrypod.Orders.OrderItem do
def changeset(item, attrs) do def changeset(item, attrs) do
item item
|> cast(attrs, [:variant_id, :product_name, :variant_title, :quantity, :unit_price, :order_id]) |> cast(attrs, [
:variant_id,
:product_id,
:product_name,
:variant_title,
:quantity,
:unit_price,
:order_id
])
|> validate_required([:variant_id, :product_name, :quantity, :unit_price]) |> validate_required([:variant_id, :product_name, :quantity, :unit_price])
|> validate_number(:quantity, greater_than: 0) |> validate_number(:quantity, greater_than: 0)
|> validate_number(:unit_price, greater_than_or_equal_to: 0) |> validate_number(:unit_price, greater_than_or_equal_to: 0)

View File

@ -100,6 +100,7 @@ defmodule Berrypod.Orders.OrderNotifier do
def deliver_cart_recovery(cart, order, unsubscribe_url) do def deliver_cart_recovery(cart, order, unsubscribe_url) do
from_address = Berrypod.Settings.get_setting("email_from_address", "contact@example.com") from_address = Berrypod.Settings.get_setting("email_from_address", "contact@example.com")
subject = "You left something behind" subject = "You left something behind"
base_url = BerrypodWeb.Endpoint.url()
body = """ body = """
============================== ==============================
@ -107,11 +108,9 @@ defmodule Berrypod.Orders.OrderNotifier do
You recently started a checkout but didn't complete it. You recently started a checkout but didn't complete it.
Your cart had: Your cart had:
#{format_items(order.items)} #{format_cart_items(order.items, base_url)}
Total: #{Cart.format_price(cart.cart_total)} Total: #{Cart.format_price(cart.cart_total)}
If you'd like to complete your order, head to our shop and add these items again.
We're only sending this once. We're only sending this once.
Don't want to hear from us? Unsubscribe: #{unsubscribe_url} Don't want to hear from us? Unsubscribe: #{unsubscribe_url}
@ -169,6 +168,22 @@ defmodule Berrypod.Orders.OrderNotifier do
defp format_items(_), do: "" defp format_items(_), do: ""
defp format_cart_items(items, base_url) when is_list(items) do
items
|> Enum.map_join("\n", fn item ->
price = Cart.format_price(item.unit_price * item.quantity)
line = " #{item.quantity}x #{item.product_name} (#{item.variant_title}) - #{price}"
if item.product_id do
line <> "\n #{base_url}/products/#{item.product_id}"
else
line
end
end)
end
defp format_cart_items(_, _), do: ""
defp format_shipping_address(address) when is_map(address) and map_size(address) > 0 do defp format_shipping_address(address) when is_map(address) and map_size(address) > 0 do
lines = lines =
[ [

View File

@ -8,7 +8,8 @@ defmodule BerrypodWeb.Endpoint do
store: :cookie, store: :cookie,
key: "_berrypod_key", key: "_berrypod_key",
signing_salt: "JNwRcD7y", signing_salt: "JNwRcD7y",
same_site: "Lax" same_site: "Lax",
max_age: 604_800
] ]
socket "/live", Phoenix.LiveView.Socket, socket "/live", Phoenix.LiveView.Socket,

View File

@ -0,0 +1,9 @@
defmodule Berrypod.Repo.Migrations.AddProductIdToOrderItems do
use Ecto.Migration
def change do
alter table(:order_items) do
add :product_id, :string
end
end
end