18 KiB
Competitive gaps
Status: Planned Tasks: #75–101 in PROGRESS.md Tier: 4.5 (Core commerce gaps)
Goal
Close the critical gaps identified in the competitive analysis that block Berrypod from competing with Shopify, Squarespace, and established POD platforms.
Phasing
The gaps are grouped into three phases, ordered by impact:
- Phase 1 (Core commerce) — unblocks repeat customers and essential e-commerce features
- Phase 2 (Retention & growth) — keeps customers coming back and scales operations
- Phase 3 (Scale) — differentiators and advanced features
Items already planned elsewhere (profit-aware-pricing, SEO enhancements) are not duplicated here.
Phase 1: Core commerce gaps
Customer accounts (#75–80)
The biggest unlock. Returning customers can't see past orders, save addresses, or leave reviews without accounts.
#75 — Customer authentication schema (2h)
New table and auth infrastructure for customer accounts (separate from admin users).
Schema:
create table(:customers, primary_key: false) do
add :id, :binary_id, primary_key: true
add :email, :citext, null: false
add :hashed_password, :string, null: false
add :confirmed_at, :utc_datetime
add :name, :string
timestamps()
end
create unique_index(:customers, [:email])
Files:
- Migration
lib/berrypod/customers/customer.ex— schema + registration/password changesetslib/berrypod/customers.ex— context (register, authenticate, get_by_email)
Tests:
- Registration with valid/invalid data
- Authentication with correct/wrong password
- Email uniqueness
#76 — Customer auth flows (3h)
Login, register, password reset, email confirmation for shop customers.
Routes:
POST /account/register— create accountPOST /account/login— log inDELETE /account/logout— log outGET /account/confirm/:token— email confirmationPOST /account/reset-password— request resetPUT /account/reset-password/:token— set new password
Sessions:
- Customer session separate from admin session (different cookie)
@current_customerassign in shop live_session
UI:
- Register/login forms (modal or page TBD)
- Email templates for confirmation and reset
- "Already have an account?" / "Create account" links
Files:
lib/berrypod_web/controllers/customer_session_controller.exlib/berrypod_web/controllers/customer_registration_controller.exlib/berrypod_web/live/shop/customer_auth_live.ex— optional LiveView forms- Email templates
- Router updates
No-JS: Forms work as standard POST.
#77 — Link orders to customers (1.5h)
Associate orders with customer accounts.
Migration:
alter table(:orders) do
add :customer_id, references(:customers, type: :binary_id, on_delete: :nilify_all)
end
create index(:orders, [:customer_id])
Flow:
- At checkout, if
@current_customerexists, setcustomer_idon order - Guest checkout still works (customer_id = nil)
- "Create account?" prompt after guest checkout (pre-fill email from order)
Files:
- Migration
lib/berrypod/orders/order.ex— add field- Checkout controller — set customer_id
- Post-checkout prompt component
#78 — Customer account dashboard (2h)
/account page for logged-in customers.
Features:
- Order history list (status, date, total, tracking link)
- Click into order detail (items, addresses, timeline)
- Account settings (name, email, password)
Files:
lib/berrypod_web/live/shop/account_live.exlib/berrypod_web/live/shop/account_orders_live.ex- Router —
/account,/account/orders/:id
#79 — Saved addresses (1.5h)
Address book for returning customers.
Schema:
create table(:customer_addresses, primary_key: false) do
add :id, :binary_id, primary_key: true
add :customer_id, references(:customers, type: :binary_id, on_delete: :delete_all), null: false
add :label, :string # "Home", "Work", etc.
add :name, :string, null: false
add :line1, :string, null: false
add :line2, :string
add :city, :string, null: false
add :state, :string
add :postal_code, :string, null: false
add :country, :string, null: false # ISO code
add :is_default, :boolean, default: false
timestamps()
end
Features:
- Add/edit/delete addresses in account settings
- Address selector at checkout (for logged-in customers)
- "Save this address" checkbox at checkout
- Prefill Stripe Checkout with selected address
Files:
- Migration + schema
lib/berrypod/customers.ex— address CRUD- Account settings component
- Checkout integration
#80 — Guest checkout linking (1h)
After guest checkout, prompt to create account and link the order.
Flow:
- Order confirmation page shows "Create account to track orders"
- Email pre-filled from order
- On registration, link order to new customer
Files:
- Order confirmation component
- Registration flow — accept order_id param, link on create
PayPal integration (#81–83)
~30% of buyers expect PayPal. Critical for conversions.
#81 — PayPal SDK integration (2h)
Set up PayPal JavaScript SDK and server-side API.
Dependencies:
- PayPal REST API (use
:reqfor HTTP) - Client ID + Secret in encrypted settings
Files:
lib/berrypod/payments/paypal.ex— API client (create order, capture payment)- Settings — PayPal credentials
- Admin settings UI — PayPal section
#82 — PayPal checkout flow (3h)
Add PayPal as payment option alongside Stripe.
Flow:
- Cart page shows "Pay with PayPal" button (PayPal JS SDK)
- Customer clicks → PayPal modal opens
- Customer approves → returns to site
- Server captures payment
- Order created/confirmed
Approach:
- PayPal handles the entire payment UI (like Stripe Checkout)
- Server creates PayPal order, client approves, server captures
- Same order creation flow as Stripe, different payment capture
Files:
lib/berrypod_web/controllers/paypal_controller.ex— create order, capture- Cart/checkout UI — PayPal button
- JS — PayPal SDK integration
- Order creation — handle PayPal payments
#83 — PayPal webhooks (1.5h)
Handle PayPal webhooks for refunds and disputes.
Events:
PAYMENT.CAPTURE.REFUNDED— mark order refundedCUSTOMER.DISPUTE.CREATED— flag order
Files:
lib/berrypod_web/controllers/paypal_webhook_controller.ex- Router —
/webhooks/paypal - Orders context — refund/dispute handling
Product reviews (#84–88)
Reviews block exists but has no backend. Critical for social proof.
#84 — Reviews schema (1.5h)
Schema:
create table(:reviews, primary_key: false) do
add :id, :binary_id, primary_key: true
add :product_id, references(:products, type: :binary_id, on_delete: :delete_all), null: false
add :customer_id, references(:customers, type: :binary_id, on_delete: :nilify_all)
add :order_id, references(:orders, type: :binary_id, on_delete: :nilify_all)
add :rating, :integer, null: false # 1-5
add :title, :string
add :body, :text
add :status, :string, default: "pending" # pending, approved, rejected
add :verified_purchase, :boolean, default: false
add :author_name, :string # for guest reviews or if customer deleted
timestamps()
end
create index(:reviews, [:product_id])
create index(:reviews, [:customer_id])
create index(:reviews, [:status])
Files:
- Migration
lib/berrypod/reviews/review.exlib/berrypod/reviews.ex— context
#85 — Review submission (2h)
Customers can leave reviews on purchased products.
Routes:
POST /products/:id/reviews— submit review
Rules:
- Must have purchased the product (order with this product, status paid+)
- One review per customer per product
- Optional: allow guest reviews with email verification
UI:
- Review form on product page (only for purchasers)
- Star rating selector
- Optional title and body
Files:
lib/berrypod_web/live/shop/product_live.ex— review form component- Review creation with validation
#86 — Review moderation (1.5h)
Admin can approve/reject reviews before display.
Admin UI:
/admin/reviews— pending reviews queue- Approve/reject buttons
- Bulk actions
Moderation:
status: pendingby default- Only
approvedreviews shown on product pages - Email notification to admin on new review (optional)
Files:
lib/berrypod_web/live/admin/reviews_live.ex- Router
- Admin nav
#87 — Reviews display (1.5h)
Show approved reviews on product pages.
Features:
- Average rating + count in product header
- Review list below product details
- "Verified purchase" badge
- Sort by date/rating
- Pagination
Files:
- Product page — reviews section
lib/berrypod/reviews.ex— queries (avg rating, list for product)- Shop components — review card, star display
#88 — Review schema markup (1h)
Add JSON-LD Review and AggregateRating schema.
Schema:
{
"@type": "Product",
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.5",
"reviewCount": "12"
},
"review": [...]
}
Files:
- Product page — extend existing JSON-LD
lib/berrypod_web/components/seo_components.ex
Stock status improvements (#89–90) — Complete
Accurate availability status from providers. No artificial urgency — POD products are made to order. Stock limits reflect provider availability, not scarcity marketing.
#89 — Provider stock sync — Already exists
The is_available field already exists on ProductVariant and is synced from both providers:
- Printify:
var["is_available"] - Printful:
sv["availability_status"] == "active"
Product-level in_stock is computed from variant availability in Products.recompute_cached_fields/1.
#90 — Availability display — Complete
Implemented:
- Unavailable variants are visually disabled in the variant selector (opacity 0.3, dashed border)
- "This option is currently unavailable" message shown when an unavailable variant is selected
- Add-to-cart button disabled for unavailable variants
- Cart shows "This item is currently unavailable" warning for affected items
- Checkout blocked if cart contains unavailable items
No "X left" urgency — that's a dark pattern for made-to-order products.
Phase 2: Retention & growth
Returns management (#91–94)
No RMA system currently. Customers can't request returns.
#91 — Returns schema (1.5h)
Schema:
create table(:return_requests, primary_key: false) do
add :id, :binary_id, primary_key: true
add :order_id, references(:orders, type: :binary_id, on_delete: :delete_all), null: false
add :customer_id, references(:customers, type: :binary_id, on_delete: :nilify_all)
add :status, :string, default: "requested" # requested, approved, rejected, completed
add :reason, :string, null: false
add :notes, :text
add :refund_amount, :integer # approved refund amount
timestamps()
end
create table(:return_request_items, primary_key: false) do
add :id, :binary_id, primary_key: true
add :return_request_id, references(:return_requests, type: :binary_id, on_delete: :delete_all)
add :order_item_id, references(:order_items, type: :binary_id, on_delete: :delete_all)
add :quantity, :integer, null: false
end
Files:
- Migration
- Schemas
- Context
#92 — Return request flow (2h)
Customers can request returns from their account.
Flow:
- Customer goes to order detail in account
- Clicks "Request return" (within return window)
- Selects items and reason
- Submits request
- Email sent to admin
Files:
- Account orders — return request button/form
lib/berrypod/returns.ex— create request- Email notification
#93 — Return admin (2h)
Admin can process return requests.
Admin UI:
/admin/returns— list of requests (pending, processed)- Approve/reject with refund amount
- Status tracking
Actions:
- Approve → triggers refund (Stripe or PayPal)
- Reject → email customer with reason
Files:
lib/berrypod_web/live/admin/returns_live.ex- Stripe/PayPal refund integration
- Activity log entries
#94 — Return policy settings (1h)
Configure return window and reasons.
Settings:
return_window_days— default 30return_reasons— list of selectable reasons
Files:
- Settings
- Admin settings UI
- Return form uses configured reasons
Email sequences (#95–97)
Only single campaigns exist. Need automated flows.
#95 — Email sequence schema (2h)
Schema:
create table(:email_sequences, primary_key: false) do
add :id, :binary_id, primary_key: true
add :name, :string, null: false
add :trigger, :string, null: false # "welcome", "post_purchase", "browse_abandon"
add :active, :boolean, default: true
timestamps()
end
create table(:email_sequence_steps, primary_key: false) do
add :id, :binary_id, primary_key: true
add :sequence_id, references(:email_sequences, type: :binary_id, on_delete: :delete_all)
add :position, :integer, null: false
add :delay_hours, :integer, null: false # hours after trigger/previous step
add :subject, :string, null: false
add :body, :text, null: false
timestamps()
end
create table(:email_sequence_sends, primary_key: false) do
add :id, :binary_id, primary_key: true
add :step_id, references(:email_sequence_steps, type: :binary_id, on_delete: :delete_all)
add :customer_id, references(:customers, type: :binary_id, on_delete: :delete_all)
add :email, :string, null: false
add :sent_at, :utc_datetime
add :opened_at, :utc_datetime
add :clicked_at, :utc_datetime
end
Files:
- Migration
- Schemas
- Context
#96 — Sequence triggers & sending (3h)
Oban jobs to trigger and send sequence emails.
Triggers:
welcome— on customer registrationpost_purchase— on order confirmationbrowse_abandon— on product view without purchase (24h later)
Flow:
- Trigger event → enqueue first step job with delay
- Job runs → send email → enqueue next step
- Track opens/clicks (pixel + link tracking)
Files:
lib/berrypod/workers/email_sequence_worker.ex- Trigger hooks in registration/order/analytics
- Email sending integration
#97 — Sequence admin (2h)
Admin can create and manage sequences.
Admin UI:
/admin/sequences— list of sequences- Create/edit sequence with steps
- Preview emails
- Analytics (sent, opened, clicked)
Files:
lib/berrypod_web/live/admin/sequences_live.ex- Router
- Admin nav
GDPR data export/deletion (#98–99)
Right to erasure not fully implemented.
#98 — Customer data export (1.5h)
Customers can export their data.
Export includes:
- Account info (email, name)
- Orders (with items, addresses)
- Reviews
- Newsletter subscription status
Format: JSON download
Files:
- Account settings — "Download my data" button
lib/berrypod/customers.ex— export function- Controller endpoint
#99 — Customer data deletion (2h)
Customers can delete their account.
Process:
- Customer requests deletion
- Confirmation email sent
- After confirmation, anonymise data:
- Remove name, email (replace with "deleted-{id}")
- Keep order records for accounting (anonymised)
- Delete reviews or anonymise
- Remove from newsletter
Files:
- Account settings — "Delete my account"
- Deletion flow with confirmation
- Anonymisation logic
Phase 3: Scale
Blog functionality (#100)
Page builder exists but no blog post type.
#100 — Blog post type (3h)
Extend page builder for blog posts.
New fields on pages:
add :page_type, :string, default: "page" # "page" or "post"
add :published_at, :utc_datetime
add :author, :string
add :category, :string
add :excerpt, :text
Features:
/blogindex page (posts sorted by date)/blog/:slugindividual posts- Category filtering
- RSS feed
- Previous/next navigation
Files:
- Migration
- Page schema updates
- Blog index LiveView
- RSS controller
- Admin pages — post type option
Staff accounts (#101)
Single admin only. Need team access.
#101 — Staff accounts & RBAC (4h)
Multiple staff with role-based permissions.
Schema:
alter table(:users) do
add :role, :string, default: "owner" # owner, admin, staff
add :permissions, :map, default: %{}
end
Permissions:
orders— view/manage ordersproducts— view/manage productspages— edit pagessettings— access settingsanalytics— view analytics
Admin UI:
/admin/team— list staff- Invite staff (email with setup link)
- Assign role/permissions
- Activity log shows who did what
Files:
- Migration
- User schema update
- Permission checks in LiveViews
- Team management LiveView
- Invite flow
Dependencies
| Task | Depends on |
|---|---|
| #77 (Link orders to customers) | #75, #76 |
| #78 (Account dashboard) | #76 |
| #79 (Saved addresses) | #76 |
| #80 (Guest checkout linking) | #75, #76 |
| #85 (Review submission) | #84, #75 (optional) |
| #92 (Return request flow) | #91, #78 |
| #96 (Sequence triggers) | #95 |
| #98, #99 (GDPR) | #75 |
Already covered elsewhere
These gaps are addressed in other plans:
| Gap | Covered in |
|---|---|
| Discount/coupon codes | profit-aware-pricing.md (#69) |
| Tax management | profit-aware-pricing.md (#66) |
| Profit dashboard | profit-aware-pricing.md (#67) |
| Margin warnings | profit-aware-pricing.md (#68, #70) |
| Announcement bar | profit-aware-pricing.md (#71) |
| SEO enhancements | seo-enhancements.md |
| Multiple print providers | ROADMAP.md |
| Buy-now-pay-later (Klarna) | Future (dependent on demand) |