All checks were successful
deploy / deploy (push) Successful in 1m19s
- Capitalise lead sentence regardless of shop_name value - Add stripe.com/privacy URL when mentioning Stripe in payment section - Remove mention of logout from session cookie description - Make third-party sharing text provider-agnostic (no longer names Printify etc.) - Add :updated_at block to privacy, delivery, and terms pages showing when content last changed — auto-tracked via content hash, so the date advances automatically whenever relevant settings change Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
446 lines
18 KiB
Elixir
446 lines
18 KiB
Elixir
defmodule Berrypod.LegalPages do
|
||
@moduledoc """
|
||
Generates legally accurate content for the shop's policy pages.
|
||
|
||
Content is derived from actual shop settings — connected providers,
|
||
shipping destinations, enabled features, and shop country. Each function
|
||
returns a list of content blocks in the format used by `<.rich_text>`.
|
||
|
||
Not legal advice. All generated pages include a disclaimer.
|
||
"""
|
||
|
||
alias Berrypod.{Products, Settings, Shipping}
|
||
|
||
@eu_countries ~w(AT BE BG CY CZ DE DK EE ES FI FR GR HR HU IE IT LT LU LV MT NL PL PT RO SE SI SK)
|
||
@north_america ~w(US CA)
|
||
|
||
# =============================================================================
|
||
# Public API
|
||
# =============================================================================
|
||
|
||
@doc """
|
||
Generates privacy policy content blocks.
|
||
|
||
Sections adapt based on shop country (jurisdiction language), connected
|
||
providers (third-party sharing), and enabled features (cart recovery,
|
||
newsletter).
|
||
"""
|
||
def privacy_content do
|
||
blocks = privacy_blocks()
|
||
date = track_and_get_date("privacy", blocks)
|
||
insert_version(blocks, date)
|
||
end
|
||
|
||
defp privacy_blocks do
|
||
shop_name = Settings.get_setting("shop_name", "this shop")
|
||
contact_email = Settings.get_setting("contact_email")
|
||
shop_country = Settings.get_setting("shop_country", "GB")
|
||
abandoned_cart = Settings.abandoned_cart_recovery_enabled?()
|
||
newsletter = Settings.get_setting("newsletter_enabled", false)
|
||
providers = connected_provider_names()
|
||
|
||
intro = [
|
||
%{
|
||
type: :lead,
|
||
text:
|
||
"#{shop_name} takes your privacy seriously. This policy explains what personal data we collect and how we use it, #{jurisdiction(shop_country)}."
|
||
|> capitalise()
|
||
},
|
||
%{type: :heading, text: "What we collect"},
|
||
%{
|
||
type: :paragraph,
|
||
text:
|
||
"When you place an order, we collect the information needed to fulfil it: your name, email address, and shipping address. Our lawful basis is performance of a contract (Article 6(1)(b) UK/EU GDPR)."
|
||
},
|
||
%{type: :heading, text: "Payment"},
|
||
%{
|
||
type: :paragraph,
|
||
text:
|
||
"Payment is processed by Stripe (stripe.com/privacy). We never see or store your card number or payment details — these go directly to Stripe and are subject to their privacy policy."
|
||
},
|
||
%{type: :heading, text: "Analytics"},
|
||
%{
|
||
type: :paragraph,
|
||
text:
|
||
"We collect privacy-first analytics: page views, device type, browser, operating system, and general country (derived from your IP address at the time of the visit — your IP address itself is not stored). No cookies are used for analytics, and no personal data is retained."
|
||
},
|
||
%{type: :heading, text: "Cookies"},
|
||
%{
|
||
type: :list,
|
||
items: [
|
||
"_berrypod_session — keeps your shopping cart and login session active. Stored for up to 7 days. Strictly necessary; no consent required.",
|
||
"country_code — remembers your shipping country preference. Stored for 1 year. Strictly necessary for showing correct shipping rates; no consent required."
|
||
]
|
||
},
|
||
%{
|
||
type: :paragraph,
|
||
text:
|
||
"We do not use advertising cookies, tracking pixels, or third-party analytics cookies."
|
||
}
|
||
]
|
||
|
||
cart_recovery_blocks = if abandoned_cart, do: cart_recovery_section(shop_country), else: []
|
||
newsletter_blocks = if newsletter, do: newsletter_section(), else: []
|
||
|
||
retention_items =
|
||
[
|
||
"Order data is kept for 7 years (required by UK company law and HMRC accounting rules).",
|
||
"Analytics data is retained for 2 years.",
|
||
if(abandoned_cart,
|
||
do:
|
||
"Cart recovery data (email address and basket contents) is deleted within 30 days whether or not an email was sent.",
|
||
else: nil
|
||
)
|
||
]
|
||
|> Enum.reject(&is_nil/1)
|
||
|
||
tail = [
|
||
%{type: :heading, text: "Third parties"},
|
||
%{type: :paragraph, text: provider_sharing_text(providers)},
|
||
%{type: :heading, text: "Data retention"},
|
||
%{type: :list, items: retention_items},
|
||
%{type: :heading, text: "Your rights"},
|
||
%{
|
||
type: :paragraph,
|
||
text:
|
||
"You have the right to access, correct, or request deletion of your personal data at any time. Note that certain data may need to be retained for the statutory periods above. You also have the right to object to marketing communications."
|
||
},
|
||
%{type: :heading, text: "Contact"},
|
||
%{type: :paragraph, text: contact_text(contact_email)},
|
||
%{type: :closing, text: legal_disclaimer()}
|
||
]
|
||
|
||
intro ++ cart_recovery_blocks ++ newsletter_blocks ++ tail
|
||
end
|
||
|
||
@doc """
|
||
Generates delivery & returns content blocks.
|
||
|
||
Production lead times are derived from connected providers. Shipping
|
||
destinations are grouped by region from the shipping_rates table.
|
||
Returns policy cites the correct UK legal exemptions for POD.
|
||
"""
|
||
def delivery_content do
|
||
blocks = delivery_blocks()
|
||
date = track_and_get_date("delivery", blocks)
|
||
insert_version(blocks, date)
|
||
end
|
||
|
||
defp delivery_blocks do
|
||
providers = connected_providers()
|
||
countries = Shipping.list_available_countries_with_names()
|
||
contact_email = Settings.get_setting("contact_email")
|
||
|
||
[
|
||
%{
|
||
type: :lead,
|
||
text:
|
||
"All products are printed on demand and dispatched directly from our print provider. Here's everything you need to know about delivery and returns."
|
||
},
|
||
%{type: :heading, text: "Production time"},
|
||
%{type: :paragraph, text: production_lead_time(providers)},
|
||
%{type: :heading, text: "Delivery"}
|
||
] ++
|
||
shipping_region_blocks(countries) ++
|
||
[
|
||
%{type: :heading, text: "Returns & exchanges"},
|
||
%{
|
||
type: :paragraph,
|
||
text:
|
||
"Because every item is made to order, we cannot accept returns for change of mind. Under the Consumer Contracts (Information, Cancellation and Additional Charges) Regulations 2013, Regulation 28(1)(b), the 14-day cancellation right does not apply to goods made to the consumer's specifications or clearly personalised. Every product in this shop qualifies for this exemption."
|
||
},
|
||
%{
|
||
type: :paragraph,
|
||
text:
|
||
"Your rights under the Consumer Rights Act 2015 are unaffected. If your order arrives damaged or with a printing defect, you're entitled to a repair, replacement, or refund. Contact us within 14 days of receiving your order with your order number and a photo of the issue."
|
||
},
|
||
%{type: :heading, text: "Cancellations"},
|
||
%{type: :paragraph, text: cancellation_text(contact_email)},
|
||
%{type: :closing, text: legal_disclaimer()}
|
||
]
|
||
end
|
||
|
||
@doc """
|
||
Generates terms of service content blocks.
|
||
|
||
Governing law is derived from shop country. VAT clause included only
|
||
when the vat_enabled setting is true.
|
||
"""
|
||
def terms_content do
|
||
blocks = terms_blocks()
|
||
date = track_and_get_date("terms", blocks)
|
||
insert_version(blocks, date)
|
||
end
|
||
|
||
defp terms_blocks do
|
||
shop_name = Settings.get_setting("shop_name", "this shop")
|
||
shop_country = Settings.get_setting("shop_country", "GB")
|
||
vat_enabled = Settings.get_setting("vat_enabled", false)
|
||
law = governing_law(shop_country)
|
||
|
||
payment_text =
|
||
"Payment is taken at the time of purchase via Stripe. Your order is confirmed only on successful payment." <>
|
||
if(vat_enabled, do: " Prices include VAT where applicable.", else: "")
|
||
|
||
[
|
||
%{
|
||
type: :lead,
|
||
text:
|
||
"By placing an order through #{shop_name}, you agree to these terms. These terms are governed by #{law}."
|
||
},
|
||
%{type: :heading, text: "Products"},
|
||
%{
|
||
type: :paragraph,
|
||
text:
|
||
"All products are made to order using print-on-demand services. Colours may vary slightly between screen and the finished print — minor differences are normal. We do our best to represent products accurately in photos and descriptions."
|
||
},
|
||
%{type: :heading, text: "Payment"},
|
||
%{type: :paragraph, text: payment_text},
|
||
%{type: :heading, text: "Delivery"},
|
||
%{
|
||
type: :paragraph,
|
||
text:
|
||
"Orders include a production period before dispatch. Delivery times are estimates and may vary. We are not liable for delays caused by print providers or postal services. See our delivery page for current timeframes."
|
||
},
|
||
%{type: :heading, text: "Returns"},
|
||
%{
|
||
type: :paragraph,
|
||
text:
|
||
"As all items are made to order, we do not accept returns for change of mind — this falls under the Consumer Contracts (Information, Cancellation and Additional Charges) Regulations 2013, Regulation 28(1)(b). If an item arrives damaged or defective, you are entitled to a remedy under the Consumer Rights Act 2015. See our delivery and returns page for the full process."
|
||
},
|
||
%{type: :heading, text: "Intellectual property"},
|
||
%{
|
||
type: :paragraph,
|
||
text:
|
||
"All designs, images, and content on this site are the property of #{shop_name} and may not be reproduced without permission. Purchasing a product grants a personal-use licence only."
|
||
},
|
||
%{type: :heading, text: "Liability"},
|
||
%{
|
||
type: :paragraph,
|
||
text:
|
||
"Our liability is limited to the value of your order. We are not responsible for delays or errors caused by print providers, postal services, or other third parties beyond our reasonable control."
|
||
},
|
||
%{type: :heading, text: "Governing law"},
|
||
%{
|
||
type: :paragraph,
|
||
text:
|
||
"These terms are governed by #{law}. Any dispute will be subject to the exclusive jurisdiction of the relevant courts."
|
||
},
|
||
%{type: :heading, text: "Changes"},
|
||
%{
|
||
type: :paragraph,
|
||
text:
|
||
"We may update these terms from time to time. Any changes apply to orders placed after the update. The current version is always available at this page."
|
||
},
|
||
%{type: :closing, text: legal_disclaimer()}
|
||
]
|
||
end
|
||
|
||
# =============================================================================
|
||
# Private helpers
|
||
# =============================================================================
|
||
|
||
defp connected_providers do
|
||
Products.list_provider_connections()
|
||
|> Enum.filter(& &1.enabled)
|
||
end
|
||
|
||
defp connected_provider_names do
|
||
connected_providers()
|
||
|> Enum.map(fn conn ->
|
||
case conn.provider_type do
|
||
"printify" -> "Printify"
|
||
"printful" -> "Printful"
|
||
_ -> conn.name || "our print provider"
|
||
end
|
||
end)
|
||
end
|
||
|
||
defp jurisdiction("GB"),
|
||
do: "under UK GDPR and the Privacy and Electronic Communications Regulations (PECR)"
|
||
|
||
defp jurisdiction(code) when code in @eu_countries,
|
||
do: "under the EU General Data Protection Regulation (GDPR)"
|
||
|
||
defp jurisdiction(_), do: "under applicable data protection laws"
|
||
|
||
defp governing_law("GB"), do: "English law"
|
||
|
||
defp governing_law(code) when code in @eu_countries do
|
||
name = Shipping.country_name(code)
|
||
"the law of #{name} and EU regulations"
|
||
end
|
||
|
||
defp governing_law(code) do
|
||
name = Shipping.country_name(code)
|
||
if name == code, do: "applicable local law", else: "the law of #{name}"
|
||
end
|
||
|
||
defp production_lead_time([]) do
|
||
"Orders are printed on demand and typically take several business days to produce before dispatch. Production time varies by product."
|
||
end
|
||
|
||
defp production_lead_time(connections) do
|
||
types = MapSet.new(connections, & &1.provider_type)
|
||
|
||
cond do
|
||
MapSet.member?(types, "printify") and MapSet.member?(types, "printful") ->
|
||
"Orders are printed on demand. Production time varies by product: Printify items typically take 2–7 business days, Printful items typically take 2–5 business days."
|
||
|
||
MapSet.member?(types, "printify") ->
|
||
"Orders are printed on demand and typically take 2–7 business days to produce before dispatch."
|
||
|
||
MapSet.member?(types, "printful") ->
|
||
"Orders are printed on demand and typically take 2–5 business days to produce before dispatch."
|
||
|
||
true ->
|
||
"Orders are printed on demand and typically take several business days to produce before dispatch."
|
||
end
|
||
end
|
||
|
||
defp shipping_region_blocks([]) do
|
||
[
|
||
%{
|
||
type: :paragraph,
|
||
text:
|
||
"We ship worldwide. Contact us for current delivery time estimates for your location."
|
||
}
|
||
]
|
||
end
|
||
|
||
defp shipping_region_blocks(country_tuples) do
|
||
codes = Enum.map(country_tuples, &elem(&1, 0))
|
||
known = ["GB"] ++ @eu_countries ++ @north_america
|
||
|
||
items =
|
||
[
|
||
"GB" in codes && "United Kingdom: typically 1–3 business days after dispatch",
|
||
Enum.any?(codes, &(&1 in @eu_countries)) &&
|
||
"Europe: typically 3–7 business days after dispatch",
|
||
Enum.any?(codes, &(&1 in @north_america)) &&
|
||
"United States and Canada: typically 5–10 business days after dispatch",
|
||
Enum.any?(codes, &(&1 not in known)) &&
|
||
"Rest of world: typically 7–21 business days after dispatch"
|
||
]
|
||
|> Enum.filter(&(&1 != false))
|
||
|
||
[
|
||
%{type: :list, items: items},
|
||
%{
|
||
type: :paragraph,
|
||
text:
|
||
"Delivery times are estimates and are in addition to the production time above. You'll receive tracking information once your order is dispatched."
|
||
}
|
||
]
|
||
end
|
||
|
||
defp cart_recovery_section("GB") do
|
||
[
|
||
%{type: :heading, text: "Cart recovery"},
|
||
%{
|
||
type: :paragraph,
|
||
text:
|
||
"If you enter your email address during checkout but don't complete your order, we may send you one follow-up email about the items in your basket. We do this under the soft opt-in rule in the Privacy and Electronic Communications Regulations (PECR), Regulation 22, which permits a single 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 in that email. We store your email address and basket contents for this purpose only, and delete them within 30 days whether or not an email was sent. No tracking pixels or click-tracking links are used in these emails."
|
||
}
|
||
]
|
||
end
|
||
|
||
defp cart_recovery_section(code) when code in @eu_countries do
|
||
[
|
||
%{type: :heading, text: "Cart recovery"},
|
||
%{
|
||
type: :paragraph,
|
||
text:
|
||
"If you enter your email address during checkout but don't complete your order, we may send you one follow-up email about the items in your basket. Our lawful basis is legitimate interests (EU GDPR Article 6(1)(f)) — 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. No tracking pixels or click-tracking links are used in these emails."
|
||
}
|
||
]
|
||
end
|
||
|
||
defp cart_recovery_section(_) do
|
||
[
|
||
%{type: :heading, text: "Cart recovery"},
|
||
%{
|
||
type: :paragraph,
|
||
text:
|
||
"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. No tracking pixels or click-tracking links are used in these emails."
|
||
}
|
||
]
|
||
end
|
||
|
||
defp newsletter_section do
|
||
[
|
||
%{type: :heading, text: "Newsletter"},
|
||
%{
|
||
type: :paragraph,
|
||
text:
|
||
"If you subscribe to our newsletter, we use your email address to send marketing emails about new products and offers. We'll only contact you if you've opted in. You can unsubscribe at any time using the link in any email we send."
|
||
}
|
||
]
|
||
end
|
||
|
||
defp provider_sharing_text([]) do
|
||
"To fulfil your order, we share your name and shipping address with our print-on-demand provider. Payment details are handled by Stripe. Both process data in accordance with their own privacy policies."
|
||
end
|
||
|
||
defp provider_sharing_text([_]) do
|
||
"To fulfil your order, we share your name and shipping address with our print-on-demand provider. Payment details are handled by Stripe. Both process data in accordance with their own privacy policies."
|
||
end
|
||
|
||
defp provider_sharing_text(_names) do
|
||
"To fulfil your order, we share your name and shipping address with our print-on-demand providers. Payment details are handled by Stripe. All process data in accordance with their own privacy policies."
|
||
end
|
||
|
||
defp contact_text(nil),
|
||
do: "For any data-related questions or requests, get in touch via our contact page."
|
||
|
||
defp contact_text(""), do: contact_text(nil)
|
||
|
||
defp contact_text(email),
|
||
do: "For any data-related questions or requests, contact us at #{email}."
|
||
|
||
defp cancellation_text(nil),
|
||
do:
|
||
"Orders can be cancelled within approximately 2 hours of being placed, before production begins. Contact us via our contact page as soon as possible if you need to cancel."
|
||
|
||
defp cancellation_text(""), do: cancellation_text(nil)
|
||
|
||
defp cancellation_text(email),
|
||
do:
|
||
"Orders can be cancelled within approximately 2 hours of being placed, before production begins. Get in touch at #{email} as soon as possible if you need to cancel."
|
||
|
||
defp legal_disclaimer do
|
||
"This page was generated from this shop's configuration. It's a good starting point — review it and take independent legal advice if you're unsure about anything."
|
||
end
|
||
|
||
# Inserts an :updated_at block immediately after the first :lead block.
|
||
defp insert_version([%{type: :lead} = lead | rest], date) do
|
||
[lead, %{type: :updated_at, date: date} | rest]
|
||
end
|
||
|
||
defp insert_version(blocks, _date), do: blocks
|
||
|
||
# Stores a hash of the content blocks in settings. When the hash changes
|
||
# (i.e. relevant settings changed), records today as the new updated date.
|
||
defp track_and_get_date(page_key, blocks) do
|
||
hash = :erlang.phash2(blocks) |> Integer.to_string()
|
||
hash_key = "#{page_key}_policy_hash"
|
||
date_key = "#{page_key}_policy_updated_at"
|
||
|
||
if hash != Settings.get_setting(hash_key) do
|
||
today = format_date(Date.utc_today())
|
||
Settings.put_setting(hash_key, hash)
|
||
Settings.put_setting(date_key, today)
|
||
today
|
||
else
|
||
Settings.get_setting(date_key, format_date(Date.utc_today()))
|
||
end
|
||
end
|
||
|
||
defp format_date(%Date{} = date) do
|
||
"#{date.day} #{Calendar.strftime(date, "%B")} #{date.year}"
|
||
end
|
||
|
||
defp capitalise(<<first::utf8, rest::binary>>), do: String.upcase(<<first::utf8>>) <> rest
|
||
defp capitalise(""), do: ""
|
||
end
|