add provider sync enhancements for product lifecycle
- add discontinued status to products (soft-delete when removed from provider) - add availability helpers to variants (available/out_of_stock/discontinued) - add detailed sync audit logging (product created/updated/discontinued) - add cost change detection with threshold alerts (5% warning, 20% critical) - update cart to show unavailable items with appropriate messaging - block checkout when cart contains unavailable items - show discontinued badge on product pages Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
dd7146cb41
commit
d3fe6f4b56
@ -343,6 +343,11 @@
|
|||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge-discontinued {
|
||||||
|
background-color: var(--t-text-tertiary, #737373);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
/* Product Card Images — mobile: swipe, desktop: hover crossfade */
|
/* Product Card Images — mobile: swipe, desktop: hover crossfade */
|
||||||
.product-card-image-wrap {
|
.product-card-image-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
212
docs/plans/provider-sync-enhancements.md
Normal file
212
docs/plans/provider-sync-enhancements.md
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
# Provider Sync Enhancements
|
||||||
|
|
||||||
|
> Status: Planned
|
||||||
|
> Tier: 3.5 (Business tools)
|
||||||
|
|
||||||
|
Small improvements to provider sync for better visibility and resilience. Not versioning products (overkill), just better audit trail and handling of edge cases.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Three enhancements to the provider sync system:
|
||||||
|
|
||||||
|
1. **Detailed audit logging** — Field-level change tracking, not just "3 updated"
|
||||||
|
2. **Soft-delete for discontinued products** — Don't break carts/orders/links
|
||||||
|
3. **Variant-level availability** — Handle out of stock / discontinued variants
|
||||||
|
4. **Price change alerts** — Warn when margins might be affected
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Detailed sync audit logging
|
||||||
|
|
||||||
|
Currently `sync.completed` logs "47 products, 3 updated" but doesn't capture *what* changed. Enhance to log field-level changes.
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```elixir
|
||||||
|
# In product sync, before update
|
||||||
|
def sync_product(product, provider_data) do
|
||||||
|
changes = diff_product(product, provider_data)
|
||||||
|
|
||||||
|
if changes != %{} do
|
||||||
|
ActivityLog.log_event("sync.product_updated",
|
||||||
|
"#{product.title}: #{summarize_changes(changes)}",
|
||||||
|
payload: %{product_id: product.id, changes: changes}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
update_product(product, provider_data)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp diff_product(current, new) do
|
||||||
|
fields = [:title, :description, :base_price, :status]
|
||||||
|
Enum.reduce(fields, %{}, fn field, acc ->
|
||||||
|
old_val = Map.get(current, field)
|
||||||
|
new_val = Map.get(new, field)
|
||||||
|
if old_val != new_val, do: Map.put(acc, field, {old_val, new_val}), else: acc
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp summarize_changes(changes) do
|
||||||
|
changes
|
||||||
|
|> Map.keys()
|
||||||
|
|> Enum.map(&to_string/1)
|
||||||
|
|> Enum.join(", ")
|
||||||
|
# => "title, base_price"
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Activity log entries:**
|
||||||
|
- `sync.product_updated` — "Awesome T-Shirt: title, base_price changed"
|
||||||
|
- `sync.product_added` — "New product: Cool Hoodie"
|
||||||
|
- `sync.product_removed` — "Product removed: Old Mug" (if provider removes it)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Soft-delete for discontinued products
|
||||||
|
|
||||||
|
When a product disappears from the provider catalogue (sync returns 404 or product not in list), don't hard-delete it. Set `status: "discontinued"` instead.
|
||||||
|
|
||||||
|
**Why:**
|
||||||
|
- Existing carts might have this product
|
||||||
|
- Order history references it
|
||||||
|
- Links from external sites won't 404 immediately
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```elixir
|
||||||
|
# products schema
|
||||||
|
field :status, :string, default: "active" # "active", "discontinued", "draft"
|
||||||
|
|
||||||
|
# In sync
|
||||||
|
def handle_missing_product(product) do
|
||||||
|
Products.update_product(product, %{status: "discontinued"})
|
||||||
|
ActivityLog.log_event("sync.product_discontinued",
|
||||||
|
"#{product.title} no longer available from provider",
|
||||||
|
level: "warning",
|
||||||
|
payload: %{product_id: product.id}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI behaviour:**
|
||||||
|
- Discontinued products hidden from shop browse/search
|
||||||
|
- Product page shows "This product is no longer available"
|
||||||
|
- Cart shows warning if item is discontinued
|
||||||
|
- Admin can see discontinued products in a filtered view
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2b. Variant-level availability
|
||||||
|
|
||||||
|
Variants can go out of stock or be discontinued independently of the product. This matters because:
|
||||||
|
- A customer may have "Blue / Large" in their cart when that specific variant becomes unavailable
|
||||||
|
- The product is still active, just that combination isn't
|
||||||
|
|
||||||
|
**Variant statuses:**
|
||||||
|
```elixir
|
||||||
|
# variants schema
|
||||||
|
field :availability, :string, default: "available"
|
||||||
|
# "available", "out_of_stock", "discontinued"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sync handling:**
|
||||||
|
```elixir
|
||||||
|
def sync_variant(variant, provider_data) do
|
||||||
|
new_availability = case provider_data do
|
||||||
|
%{is_available: false, is_enabled: false} -> "discontinued"
|
||||||
|
%{is_available: false} -> "out_of_stock"
|
||||||
|
%{is_available: true} -> "available"
|
||||||
|
end
|
||||||
|
|
||||||
|
if variant.availability != new_availability do
|
||||||
|
Products.update_variant(variant, %{availability: new_availability})
|
||||||
|
|
||||||
|
if new_availability in ["out_of_stock", "discontinued"] do
|
||||||
|
ActivityLog.log_event("sync.variant_unavailable",
|
||||||
|
"#{product.title} (#{variant.title}): now #{new_availability}",
|
||||||
|
level: "warning",
|
||||||
|
payload: %{variant_id: variant.id, product_id: variant.product_id}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cart impact:**
|
||||||
|
- On cart load: check all variants still available
|
||||||
|
- If out of stock: show warning "This size/colour is currently out of stock"
|
||||||
|
- If discontinued: show warning "This option is no longer available"
|
||||||
|
- Prevent checkout if cart contains unavailable variants (or offer to remove them)
|
||||||
|
|
||||||
|
**Product page:**
|
||||||
|
- Out of stock variants: show crossed out or greyed, not selectable
|
||||||
|
- Discontinued variants: hide entirely from selector
|
||||||
|
- If all variants unavailable: show "Currently unavailable" on product
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Price change alerts
|
||||||
|
|
||||||
|
Log a warning when a product's cost basis changes significantly (>5% or configurable threshold). Helps shop owner know when their margins might be affected.
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```elixir
|
||||||
|
def check_price_change(product, new_cost) do
|
||||||
|
old_cost = product.base_cost
|
||||||
|
return if old_cost == nil or old_cost == 0
|
||||||
|
|
||||||
|
change_pct = abs((new_cost - old_cost) / old_cost * 100)
|
||||||
|
|
||||||
|
if change_pct > 5 do
|
||||||
|
ActivityLog.log_event("sync.price_change",
|
||||||
|
"#{product.title}: cost changed from £#{old_cost} to £#{new_cost} (#{round(change_pct)}%)",
|
||||||
|
level: if(change_pct > 20, do: "warning", else: "info"),
|
||||||
|
payload: %{product_id: product.id, old_cost: old_cost, new_cost: new_cost, change_pct: change_pct}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `lib/berrypod/products/product.ex` | Add `status` field if not present |
|
||||||
|
| `lib/berrypod/products/variant.ex` | Add `availability` field |
|
||||||
|
| `lib/berrypod/providers/printify.ex` | Add diff logic, price change check, soft-delete handling, variant availability sync |
|
||||||
|
| `lib/berrypod/providers/printful.ex` | Same as above |
|
||||||
|
| `lib/berrypod/products.ex` | Query helpers for discontinued products/variants |
|
||||||
|
| `lib/berrypod_web/live/shop/pages/product_page.ex` | Handle discontinued status, variant availability display |
|
||||||
|
| `lib/berrypod_web/components/shop_components/cart.ex` | Warn on discontinued/unavailable items |
|
||||||
|
| `lib/berrypod_web/components/shop_components/product.ex` | Grey out/hide unavailable variants in selector |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
| # | Task | Est |
|
||||||
|
|---|------|-----|
|
||||||
|
| 1 | Add `status` field to products, `availability` field to variants | 30m |
|
||||||
|
| 2 | Product discontinued handling in sync (soft-delete) | 45m |
|
||||||
|
| 3 | Variant availability sync (out of stock / discontinued) | 45m |
|
||||||
|
| 4 | Detailed sync audit logging (field-level diff) | 1h |
|
||||||
|
| 5 | Price change alerts with threshold | 30m |
|
||||||
|
| 6 | Product page UI: discontinued products, variant availability display | 1h |
|
||||||
|
| 7 | Cart UI: warn on discontinued/unavailable items, prevent checkout | 1h |
|
||||||
|
| **Total** | | **5.5h** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Manually trigger a sync
|
||||||
|
2. Check activity log shows field-level changes
|
||||||
|
3. Remove a product from provider (or mock it) — verify soft-delete
|
||||||
|
4. Change a product's cost — verify price change alert appears
|
||||||
|
5. Try to add discontinued product to cart — verify warning
|
||||||
|
6. Mark a variant as out of stock — verify greyed out on product page
|
||||||
|
7. Mark a variant as discontinued — verify hidden from selector
|
||||||
|
8. Add item to cart, then mark its variant unavailable — verify cart warning
|
||||||
|
9. Try to checkout with unavailable variant — verify blocked or prompted to remove
|
||||||
@ -133,7 +133,8 @@ defmodule Berrypod.Cart do
|
|||||||
price: variant.price,
|
price: variant.price,
|
||||||
quantity: quantity,
|
quantity: quantity,
|
||||||
image: variant_image_url(variant.product),
|
image: variant_image_url(variant.product),
|
||||||
is_available: variant.is_available
|
is_available: variant.is_available,
|
||||||
|
product_discontinued: variant.product.status == "discontinued"
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|||||||
@ -173,6 +173,19 @@ defmodule Berrypod.Products do
|
|||||||
|> Repo.one()
|
|> Repo.one()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns active (non-discontinued) products for a provider connection.
|
||||||
|
|
||||||
|
Used by product sync to detect products that are no longer returned by the provider.
|
||||||
|
"""
|
||||||
|
def list_active_products_for_connection(connection_id) do
|
||||||
|
from(p in Product,
|
||||||
|
where: p.provider_connection_id == ^connection_id and p.status != "discontinued",
|
||||||
|
select: [:id, :provider_product_id, :title, :status]
|
||||||
|
)
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Enqueues a product sync job for the given provider connection.
|
Enqueues a product sync job for the given provider connection.
|
||||||
Returns `{:ok, job}` or `{:error, changeset}`.
|
Returns `{:ok, job}` or `{:error, changeset}`.
|
||||||
@ -615,6 +628,9 @@ defmodule Berrypod.Products do
|
|||||||
product ->
|
product ->
|
||||||
old_slug = product.slug
|
old_slug = product.slug
|
||||||
|
|
||||||
|
# Compute field-level diff before update
|
||||||
|
changes = compute_product_diff(product, attrs)
|
||||||
|
|
||||||
case update_product(product, attrs) do
|
case update_product(product, attrs) do
|
||||||
{:ok, updated} ->
|
{:ok, updated} ->
|
||||||
if old_slug != updated.slug do
|
if old_slug != updated.slug do
|
||||||
@ -625,6 +641,11 @@ defmodule Berrypod.Products do
|
|||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Log field-level changes
|
||||||
|
if changes != %{} do
|
||||||
|
log_product_updated(updated, changes)
|
||||||
|
end
|
||||||
|
|
||||||
{:ok, updated, :updated}
|
{:ok, updated, :updated}
|
||||||
|
|
||||||
error ->
|
error ->
|
||||||
@ -633,6 +654,44 @@ defmodule Berrypod.Products do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Compute diff between existing product and new attributes
|
||||||
|
defp compute_product_diff(product, attrs) do
|
||||||
|
fields = [:title, :description, :category]
|
||||||
|
|
||||||
|
Enum.reduce(fields, %{}, fn field, acc ->
|
||||||
|
old_val = Map.get(product, field)
|
||||||
|
new_val = attrs[field] || attrs[to_string(field)]
|
||||||
|
|
||||||
|
if old_val != new_val and not is_nil(new_val) do
|
||||||
|
Map.put(acc, field, {old_val, new_val})
|
||||||
|
else
|
||||||
|
acc
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp log_product_updated(product, changes) do
|
||||||
|
changed_fields =
|
||||||
|
changes
|
||||||
|
|> Map.keys()
|
||||||
|
|> Enum.map(&to_string/1)
|
||||||
|
|> Enum.join(", ")
|
||||||
|
|
||||||
|
Berrypod.ActivityLog.log_event(
|
||||||
|
"sync.product_updated",
|
||||||
|
"#{product.title}: #{changed_fields} changed",
|
||||||
|
payload: %{product_id: product.id, changes: changes}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp log_product_created(product) do
|
||||||
|
Berrypod.ActivityLog.log_event(
|
||||||
|
"sync.product_added",
|
||||||
|
"New product: #{product.title}",
|
||||||
|
payload: %{product_id: product.id, title: product.title}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
# If product exists with same slug, update it (including new provider_product_id)
|
# If product exists with same slug, update it (including new provider_product_id)
|
||||||
# Otherwise insert new product
|
# Otherwise insert new product
|
||||||
defp find_by_slug_or_insert(conn_id, slug, attrs, new_checksum) do
|
defp find_by_slug_or_insert(conn_id, slug, attrs, new_checksum) do
|
||||||
@ -679,6 +738,7 @@ defmodule Berrypod.Products do
|
|||||||
defp do_insert_product(attrs) do
|
defp do_insert_product(attrs) do
|
||||||
case create_product(attrs) do
|
case create_product(attrs) do
|
||||||
{:ok, product} ->
|
{:ok, product} ->
|
||||||
|
log_product_created(product)
|
||||||
{:ok, product, :created}
|
{:ok, product, :created}
|
||||||
|
|
||||||
{:error, %Ecto.Changeset{errors: errors} = changeset} ->
|
{:error, %Ecto.Changeset{errors: errors} = changeset} ->
|
||||||
@ -987,7 +1047,7 @@ defmodule Berrypod.Products do
|
|||||||
|> Repo.delete_all()
|
|> Repo.delete_all()
|
||||||
end
|
end
|
||||||
|
|
||||||
# Upsert incoming variants
|
# Upsert incoming variants, tracking availability changes
|
||||||
Enum.map(variants, fn variant_data ->
|
Enum.map(variants, fn variant_data ->
|
||||||
provider_variant_id =
|
provider_variant_id =
|
||||||
variant_data[:provider_variant_id] || variant_data["provider_variant_id"]
|
variant_data[:provider_variant_id] || variant_data["provider_variant_id"]
|
||||||
@ -999,11 +1059,96 @@ defmodule Berrypod.Products do
|
|||||||
create_product_variant(attrs)
|
create_product_variant(attrs)
|
||||||
|
|
||||||
existing ->
|
existing ->
|
||||||
update_product_variant(existing, attrs)
|
# Check for availability changes before update
|
||||||
|
old_availability = ProductVariant.availability(existing)
|
||||||
|
|
||||||
|
new_availability =
|
||||||
|
cond do
|
||||||
|
attrs[:is_available] == true and attrs[:is_enabled] == true -> :available
|
||||||
|
attrs[:is_available] == false and attrs[:is_enabled] == true -> :out_of_stock
|
||||||
|
attrs[:is_enabled] == false -> :discontinued
|
||||||
|
true -> :available
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check for cost changes before update
|
||||||
|
old_cost = existing.cost
|
||||||
|
new_cost = attrs[:cost]
|
||||||
|
|
||||||
|
result = update_product_variant(existing, attrs)
|
||||||
|
|
||||||
|
# Log if availability changed to a worse state
|
||||||
|
if old_availability == :available and new_availability != :available do
|
||||||
|
log_variant_availability_change(existing, product_id, new_availability)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Log significant cost changes (>5%)
|
||||||
|
if old_cost && new_cost && old_cost > 0 do
|
||||||
|
check_cost_change(existing, product_id, old_cost, new_cost)
|
||||||
|
end
|
||||||
|
|
||||||
|
result
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Cost change threshold (5%)
|
||||||
|
@cost_change_threshold 5
|
||||||
|
|
||||||
|
defp check_cost_change(variant, product_id, old_cost, new_cost) do
|
||||||
|
change_pct = abs((new_cost - old_cost) / old_cost * 100)
|
||||||
|
|
||||||
|
if change_pct > @cost_change_threshold do
|
||||||
|
# Load the product title for the log message
|
||||||
|
product = Repo.get(Product, product_id)
|
||||||
|
product_title = if product, do: product.title, else: "Unknown product"
|
||||||
|
|
||||||
|
level = if change_pct > 20, do: "warning", else: "info"
|
||||||
|
|
||||||
|
# Format costs as currency (they're stored in pence)
|
||||||
|
old_formatted = format_cost(old_cost)
|
||||||
|
new_formatted = format_cost(new_cost)
|
||||||
|
|
||||||
|
Berrypod.ActivityLog.log_event(
|
||||||
|
"sync.price_change",
|
||||||
|
"#{product_title} (#{variant.title}): cost #{old_formatted} → #{new_formatted} (#{round(change_pct)}%)",
|
||||||
|
level: level,
|
||||||
|
payload: %{
|
||||||
|
variant_id: variant.id,
|
||||||
|
product_id: product_id,
|
||||||
|
variant_title: variant.title,
|
||||||
|
old_cost: old_cost,
|
||||||
|
new_cost: new_cost,
|
||||||
|
change_pct: Float.round(change_pct, 1)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_cost(cost_in_pence) do
|
||||||
|
pounds = cost_in_pence / 100
|
||||||
|
"£#{:erlang.float_to_binary(pounds, decimals: 2)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp log_variant_availability_change(variant, product_id, new_availability) do
|
||||||
|
# Load the product title for the log message
|
||||||
|
product = Repo.get(Product, product_id)
|
||||||
|
product_title = if product, do: product.title, else: "Unknown product"
|
||||||
|
|
||||||
|
status_text = if new_availability == :out_of_stock, do: "out of stock", else: "discontinued"
|
||||||
|
|
||||||
|
Berrypod.ActivityLog.log_event(
|
||||||
|
"sync.variant_unavailable",
|
||||||
|
"#{product_title} (#{variant.title}): now #{status_text}",
|
||||||
|
level: "warning",
|
||||||
|
payload: %{
|
||||||
|
variant_id: variant.id,
|
||||||
|
product_id: product_id,
|
||||||
|
variant_title: variant.title,
|
||||||
|
new_availability: new_availability
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Private Helpers
|
# Private Helpers
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@ -12,7 +12,7 @@ defmodule Berrypod.Products.Product do
|
|||||||
@primary_key {:id, :binary_id, autogenerate: true}
|
@primary_key {:id, :binary_id, autogenerate: true}
|
||||||
@foreign_key_type :binary_id
|
@foreign_key_type :binary_id
|
||||||
|
|
||||||
@statuses ~w(active draft archived)
|
@statuses ~w(active draft archived discontinued)
|
||||||
|
|
||||||
schema "products" do
|
schema "products" do
|
||||||
field :provider_product_id, :string
|
field :provider_product_id, :string
|
||||||
@ -43,6 +43,21 @@ defmodule Berrypod.Products.Product do
|
|||||||
"""
|
"""
|
||||||
def statuses, do: @statuses
|
def statuses, do: @statuses
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns true if the product is discontinued (no longer available from provider).
|
||||||
|
"""
|
||||||
|
def discontinued?(%__MODULE__{status: "discontinued"}), do: true
|
||||||
|
def discontinued?(_), do: false
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns true if the product can be purchased.
|
||||||
|
"""
|
||||||
|
def purchasable?(%__MODULE__{status: status, visible: visible}) do
|
||||||
|
status == "active" and visible == true
|
||||||
|
end
|
||||||
|
|
||||||
|
def purchasable?(_), do: false
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Changeset for creating or updating a product.
|
Changeset for creating or updating a product.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -96,4 +96,25 @@ defmodule Berrypod.Products.ProductVariant do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def options_title(_), do: nil
|
def options_title(_), do: nil
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the availability status of the variant.
|
||||||
|
|
||||||
|
- `:available` — in stock and enabled
|
||||||
|
- `:out_of_stock` — not in stock but still enabled (may come back)
|
||||||
|
- `:discontinued` — disabled by provider (won't come back)
|
||||||
|
"""
|
||||||
|
def availability(%__MODULE__{is_available: true, is_enabled: true}), do: :available
|
||||||
|
def availability(%__MODULE__{is_available: false, is_enabled: true}), do: :out_of_stock
|
||||||
|
def availability(%__MODULE__{is_enabled: false}), do: :discontinued
|
||||||
|
def availability(_), do: :available
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns true if the variant can be purchased.
|
||||||
|
"""
|
||||||
|
def purchasable?(%__MODULE__{} = variant) do
|
||||||
|
availability(variant) == :available
|
||||||
|
end
|
||||||
|
|
||||||
|
def purchasable?(_), do: false
|
||||||
end
|
end
|
||||||
|
|||||||
@ -92,6 +92,9 @@ defmodule Berrypod.Sync.ProductSyncWorker do
|
|||||||
|
|
||||||
results = sync_all_products(conn, products)
|
results = sync_all_products(conn, products)
|
||||||
|
|
||||||
|
# Mark products not returned by provider as discontinued
|
||||||
|
discontinued = mark_discontinued_products(conn, products)
|
||||||
|
|
||||||
created = Enum.count(results, &match?({:ok, _, :created}, &1))
|
created = Enum.count(results, &match?({:ok, _, :created}, &1))
|
||||||
updated = Enum.count(results, &match?({:ok, _, :updated}, &1))
|
updated = Enum.count(results, &match?({:ok, _, :updated}, &1))
|
||||||
unchanged = Enum.count(results, &match?({:ok, _, :unchanged}, &1))
|
unchanged = Enum.count(results, &match?({:ok, _, :unchanged}, &1))
|
||||||
@ -99,17 +102,20 @@ defmodule Berrypod.Sync.ProductSyncWorker do
|
|||||||
|
|
||||||
Logger.info(
|
Logger.info(
|
||||||
"Product sync complete for #{conn.provider_type}: " <>
|
"Product sync complete for #{conn.provider_type}: " <>
|
||||||
"#{created} created, #{updated} updated, #{unchanged} unchanged, #{errors} errors"
|
"#{created} created, #{updated} updated, #{unchanged} unchanged, " <>
|
||||||
|
"#{discontinued} discontinued, #{errors} errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
ActivityLog.log_event(
|
ActivityLog.log_event(
|
||||||
"sync.completed",
|
"sync.completed",
|
||||||
"Product sync complete — #{created + updated + unchanged} products, #{created} created, #{updated} updated",
|
"Product sync complete — #{created + updated + unchanged} products, #{created} created, #{updated} updated" <>
|
||||||
|
if(discontinued > 0, do: ", #{discontinued} discontinued", else: ""),
|
||||||
payload: %{
|
payload: %{
|
||||||
provider_type: conn.provider_type,
|
provider_type: conn.provider_type,
|
||||||
created: created,
|
created: created,
|
||||||
updated: updated,
|
updated: updated,
|
||||||
unchanged: unchanged,
|
unchanged: unchanged,
|
||||||
|
discontinued: discontinued,
|
||||||
errors: errors
|
errors: errors
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -168,6 +174,30 @@ defmodule Berrypod.Sync.ProductSyncWorker do
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Mark products that exist locally but weren't returned by the provider as discontinued.
|
||||||
|
# Returns the count of products that were discontinued.
|
||||||
|
defp mark_discontinued_products(conn, products) do
|
||||||
|
# Get provider_product_ids from the fetched products
|
||||||
|
fetched_ids = MapSet.new(products, & &1[:provider_product_id])
|
||||||
|
|
||||||
|
# Find local active products not in the fetched list
|
||||||
|
Products.list_active_products_for_connection(conn.id)
|
||||||
|
|> Enum.reject(fn product -> MapSet.member?(fetched_ids, product.provider_product_id) end)
|
||||||
|
|> Enum.map(fn product ->
|
||||||
|
Logger.info("Marking product #{product.title} as discontinued (no longer in provider)")
|
||||||
|
|
||||||
|
ActivityLog.log_event(
|
||||||
|
"sync.product_discontinued",
|
||||||
|
"#{product.title} no longer available from provider",
|
||||||
|
level: "warning",
|
||||||
|
payload: %{product_id: product.id, title: product.title}
|
||||||
|
)
|
||||||
|
|
||||||
|
Products.update_product(product, %{status: "discontinued"})
|
||||||
|
end)
|
||||||
|
|> length()
|
||||||
|
end
|
||||||
|
|
||||||
defp sync_single_product(conn, product_data) do
|
defp sync_single_product(conn, product_data) do
|
||||||
case sync_product(conn, product_data) do
|
case sync_product(conn, product_data) do
|
||||||
{:ok, product, status} ->
|
{:ok, product, status} ->
|
||||||
|
|||||||
@ -45,10 +45,18 @@ defmodule BerrypodWeb.ShopComponents.Cart do
|
|||||||
attr :stripe_connected, :boolean, default: true
|
attr :stripe_connected, :boolean, default: true
|
||||||
|
|
||||||
def cart_drawer(assigns) do
|
def cart_drawer(assigns) do
|
||||||
|
# Check if any cart items are unavailable (out of stock or discontinued)
|
||||||
|
has_unavailable_items =
|
||||||
|
Enum.any?(assigns.cart_items, fn item ->
|
||||||
|
Map.get(item, :product_discontinued) == true or Map.get(item, :is_available) == false
|
||||||
|
end)
|
||||||
|
|
||||||
assigns =
|
assigns =
|
||||||
assign_new(assigns, :display_total, fn ->
|
assigns
|
||||||
|
|> assign_new(:display_total, fn ->
|
||||||
assigns.total || assigns.subtotal || "£0.00"
|
assigns.total || assigns.subtotal || "£0.00"
|
||||||
end)
|
end)
|
||||||
|
|> assign(:has_unavailable_items, has_unavailable_items)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<%!-- Screen reader announcements for cart changes --%>
|
<%!-- Screen reader announcements for cart changes --%>
|
||||||
@ -142,6 +150,13 @@ defmodule BerrypodWeb.ShopComponents.Cart do
|
|||||||
Checkout
|
Checkout
|
||||||
</button>
|
</button>
|
||||||
<p class="cart-drawer-notice">Checkout isn't available yet.</p>
|
<p class="cart-drawer-notice">Checkout isn't available yet.</p>
|
||||||
|
<% @has_unavailable_items -> %>
|
||||||
|
<button type="button" disabled class="cart-drawer-checkout">
|
||||||
|
Checkout
|
||||||
|
</button>
|
||||||
|
<p class="cart-drawer-notice">
|
||||||
|
Remove unavailable items to checkout.
|
||||||
|
</p>
|
||||||
<% true -> %>
|
<% true -> %>
|
||||||
<form action="/checkout" method="post">
|
<form action="/checkout" method="post">
|
||||||
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
|
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
|
||||||
@ -212,8 +227,14 @@ defmodule BerrypodWeb.ShopComponents.Cart do
|
|||||||
</p>
|
</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<p :if={Map.get(@item, :is_available) == false} class="cart-item-unavailable">
|
<p :if={Map.get(@item, :product_discontinued)} class="cart-item-unavailable">
|
||||||
This item is currently unavailable
|
This product is no longer available
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
:if={!Map.get(@item, :product_discontinued) && Map.get(@item, :is_available) == false}
|
||||||
|
class="cart-item-unavailable"
|
||||||
|
>
|
||||||
|
This option is currently out of stock
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="cart-item-actions">
|
<div class="cart-item-actions">
|
||||||
@ -457,6 +478,7 @@ defmodule BerrypodWeb.ShopComponents.Cart do
|
|||||||
attr :available_countries, :list, default: []
|
attr :available_countries, :list, default: []
|
||||||
attr :mode, :atom, default: :live
|
attr :mode, :atom, default: :live
|
||||||
attr :stripe_connected, :boolean, default: true
|
attr :stripe_connected, :boolean, default: true
|
||||||
|
attr :has_unavailable_items, :boolean, default: false
|
||||||
|
|
||||||
def order_summary(assigns) do
|
def order_summary(assigns) do
|
||||||
assigns =
|
assigns =
|
||||||
@ -516,6 +538,17 @@ defmodule BerrypodWeb.ShopComponents.Cart do
|
|||||||
>
|
>
|
||||||
Continue shopping
|
Continue shopping
|
||||||
</.shop_link_outline>
|
</.shop_link_outline>
|
||||||
|
<% @has_unavailable_items -> %>
|
||||||
|
<.shop_button disabled class="order-summary-checkout">
|
||||||
|
Checkout
|
||||||
|
</.shop_button>
|
||||||
|
<p class="order-summary-notice">Remove unavailable items to checkout.</p>
|
||||||
|
<.shop_link_outline
|
||||||
|
href="/collections/all"
|
||||||
|
class="order-summary-continue"
|
||||||
|
>
|
||||||
|
Continue shopping
|
||||||
|
</.shop_link_outline>
|
||||||
<% true -> %>
|
<% true -> %>
|
||||||
<form action="/checkout" method="post" class="order-summary-checkout-form">
|
<form action="/checkout" method="post" class="order-summary-checkout-form">
|
||||||
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
|
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
|
||||||
|
|||||||
@ -300,6 +300,8 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
|||||||
defp product_badge(assigns) do
|
defp product_badge(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<%= cond do %>
|
<%= cond do %>
|
||||||
|
<% Map.get(@product, :status) == "discontinued" -> %>
|
||||||
|
<span class="product-badge badge-discontinued">Discontinued</span>
|
||||||
<% Map.get(@product, :in_stock, true) == false -> %>
|
<% Map.get(@product, :in_stock, true) == false -> %>
|
||||||
<span class="product-badge badge-sold-out">Sold out</span>
|
<span class="product-badge badge-sold-out">Sold out</span>
|
||||||
<% Map.get(@product, :is_new) -> %>
|
<% Map.get(@product, :is_new) -> %>
|
||||||
|
|||||||
@ -12,10 +12,15 @@ defmodule BerrypodWeb.Shop.Pages.Product do
|
|||||||
alias Berrypod.Products.{Product, ProductImage}
|
alias Berrypod.Products.{Product, ProductImage}
|
||||||
|
|
||||||
def init(socket, %{"id" => slug}, _uri) do
|
def init(socket, %{"id" => slug}, _uri) do
|
||||||
case Products.get_visible_product(slug) do
|
# Try to get product by slug, including discontinued products
|
||||||
|
case Products.get_product_by_slug(slug, preload: [:variants, :images]) do
|
||||||
nil ->
|
nil ->
|
||||||
{:noreply, push_navigate(socket, to: "/collections/all")}
|
{:noreply, push_navigate(socket, to: "/collections/all")}
|
||||||
|
|
||||||
|
%{visible: false, status: status} when status != "discontinued" ->
|
||||||
|
# Hidden but not discontinued - redirect away
|
||||||
|
{:noreply, push_navigate(socket, to: "/collections/all")}
|
||||||
|
|
||||||
product ->
|
product ->
|
||||||
all_images =
|
all_images =
|
||||||
(product.images || [])
|
(product.images || [])
|
||||||
@ -46,6 +51,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do
|
|||||||
og_image = og_image_url(all_images)
|
og_image = og_image_url(all_images)
|
||||||
|
|
||||||
page = Pages.get_page("pdp")
|
page = Pages.get_page("pdp")
|
||||||
|
is_discontinued = product.status == "discontinued"
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
@ -61,6 +67,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do
|
|||||||
|> assign(:option_types, option_types)
|
|> assign(:option_types, option_types)
|
||||||
|> assign(:variants, variants)
|
|> assign(:variants, variants)
|
||||||
|> assign(:page, page)
|
|> assign(:page, page)
|
||||||
|
|> assign(:product_discontinued, is_discontinued)
|
||||||
|
|
||||||
# Block data loaders (related_products, reviews) run after product is assigned
|
# Block data loaders (related_products, reviews) run after product is assigned
|
||||||
extra = Pages.load_block_data(page.blocks, socket.assigns)
|
extra = Pages.load_block_data(page.blocks, socket.assigns)
|
||||||
@ -89,8 +96,9 @@ defmodule BerrypodWeb.Shop.Pages.Product do
|
|||||||
|
|
||||||
def handle_event("add_to_cart", _params, socket) do
|
def handle_event("add_to_cart", _params, socket) do
|
||||||
variant = socket.assigns.selected_variant
|
variant = socket.assigns.selected_variant
|
||||||
|
is_discontinued = socket.assigns[:product_discontinued] || false
|
||||||
|
|
||||||
if variant && variant.is_available do
|
if variant && variant.is_available && not is_discontinued do
|
||||||
cart = Cart.add_item(socket.assigns.raw_cart, variant.id, socket.assigns.quantity)
|
cart = Cart.add_item(socket.assigns.raw_cart, variant.id, socket.assigns.quantity)
|
||||||
|
|
||||||
if socket.assigns[:analytics_visitor_hash] do
|
if socket.assigns[:analytics_visitor_hash] do
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user