improve cart recovery: product links in email, persistent session cookie
All checks were successful
deploy / deploy (push) Successful in 3m32s
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:
parent
2f4cd81f98
commit
61887b9d5b
@ -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)) | | | |
|
||||
| ~~52~~ | ~~Comparison mode: period deltas on stat cards~~ | — | 1h | 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 |
|
||||
| | **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 |
|
||||
@ -452,7 +452,7 @@ See: [plan](docs/plans/shipping-sync.md) for implementation details
|
||||
- [x] Period comparison deltas on stat cards (6eda1de)
|
||||
- [x] 2-year demo seed data with growth curve (6eda1de)
|
||||
- [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
|
||||
|
||||
See: [docs/plans/analytics-v2.md](docs/plans/analytics-v2.md) for v2 plan
|
||||
|
||||
@ -41,7 +41,26 @@ This is enough to produce accurate, legally grounded content automatically.
|
||||
|
||||
**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
|
||||
- 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}
|
||||
|
||||
def privacy_content do
|
||||
shop_name = Settings.get(:shop_name) || "this shop"
|
||||
contact_email = Settings.get(:contact_email)
|
||||
shop_country = Settings.get(:shop_country, "GB")
|
||||
abandoned_cart_enabled = Settings.get(:abandoned_cart_enabled, false)
|
||||
newsletter_enabled = Settings.get(:newsletter_enabled, false)
|
||||
shop_name = Settings.get_setting("shop_name") || "this shop"
|
||||
contact_email = Settings.get_setting("contact_email")
|
||||
shop_country = Settings.get_setting("shop_country", "GB")
|
||||
# Settings.abandoned_cart_recovery_enabled?/0 is already implemented
|
||||
abandoned_cart_enabled = Settings.abandoned_cart_recovery_enabled?()
|
||||
newsletter_enabled = Settings.get_setting("newsletter_enabled", false)
|
||||
|
||||
base_sections()
|
||||
|> maybe_add_abandoned_cart(abandoned_cart_enabled)
|
||||
|> maybe_add_abandoned_cart(abandoned_cart_enabled, shop_country)
|
||||
|> maybe_add_newsletter(newsletter_enabled)
|
||||
|> add_jurisdiction(shop_country)
|
||||
|> add_contact(shop_name, contact_email)
|
||||
|
||||
@ -98,6 +98,7 @@ defmodule Berrypod.Orders do
|
||||
%{
|
||||
order_id: order.id,
|
||||
variant_id: item.variant_id,
|
||||
product_id: item[:product_id],
|
||||
product_name: item.name,
|
||||
variant_title: item.variant,
|
||||
quantity: item.quantity,
|
||||
|
||||
@ -7,6 +7,7 @@ defmodule Berrypod.Orders.OrderItem do
|
||||
|
||||
schema "order_items" do
|
||||
field :variant_id, :string
|
||||
field :product_id, :string
|
||||
field :product_name, :string
|
||||
field :variant_title, :string
|
||||
field :quantity, :integer
|
||||
@ -19,7 +20,15 @@ defmodule Berrypod.Orders.OrderItem do
|
||||
|
||||
def changeset(item, attrs) do
|
||||
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_number(:quantity, greater_than: 0)
|
||||
|> validate_number(:unit_price, greater_than_or_equal_to: 0)
|
||||
|
||||
@ -100,6 +100,7 @@ defmodule Berrypod.Orders.OrderNotifier do
|
||||
def deliver_cart_recovery(cart, order, unsubscribe_url) do
|
||||
from_address = Berrypod.Settings.get_setting("email_from_address", "contact@example.com")
|
||||
subject = "You left something behind"
|
||||
base_url = BerrypodWeb.Endpoint.url()
|
||||
|
||||
body = """
|
||||
==============================
|
||||
@ -107,11 +108,9 @@ defmodule Berrypod.Orders.OrderNotifier do
|
||||
You recently started a checkout but didn't complete it.
|
||||
|
||||
Your cart had:
|
||||
#{format_items(order.items)}
|
||||
#{format_cart_items(order.items, base_url)}
|
||||
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.
|
||||
|
||||
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_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
|
||||
lines =
|
||||
[
|
||||
|
||||
@ -8,7 +8,8 @@ defmodule BerrypodWeb.Endpoint do
|
||||
store: :cookie,
|
||||
key: "_berrypod_key",
|
||||
signing_salt: "JNwRcD7y",
|
||||
same_site: "Lax"
|
||||
same_site: "Lax",
|
||||
max_age: 604_800
|
||||
]
|
||||
|
||||
socket "/live", Phoenix.LiveView.Socket,
|
||||
|
||||
@ -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
|
||||
Loading…
Reference in New Issue
Block a user