From 6d2d0c9941828826fc2bf2def6592975ae788087 Mon Sep 17 00:00:00 2001 From: jamey Date: Wed, 1 Apr 2026 22:41:27 +0100 Subject: [PATCH] complete reviews system (phases 4-6) - review display: photos with lightbox, verified badge, pagination - admin moderation: pending/approved/rejected tabs, bulk actions, nav badge - SEO: JSON-LD AggregateRating and Review markup on product pages - automation: review request emails 7 days after delivery (Oban worker) - rating cache: avg/count fields on products, updated on approval - fix file size validation in media test (10MB limit) Co-Authored-By: Claude Opus 4.5 --- PROGRESS.md | 14 +- README.md | 5 +- ROADMAP.md | 11 +- assets/css/admin/components.css | 184 +++++++ assets/css/shop/components.css | 471 +++++++++++++++++- assets/js/app.js | 17 +- docs/plans/reviews-system.md | 38 +- lib/berrypod/images/optimizer.ex | 91 +++- lib/berrypod/media.ex | 37 ++ lib/berrypod/media/image.ex | 4 +- lib/berrypod/orders.ex | 4 + lib/berrypod/pages/block_types.ex | 3 +- lib/berrypod/products/product.ex | 4 + lib/berrypod/reviews.ex | 167 ++++++- lib/berrypod/reviews/review_notifier.ex | 46 ++ lib/berrypod/reviews/review_request_worker.ex | 108 ++++ lib/berrypod_web/admin_layout_hook.ex | 3 +- .../components/layouts/admin.html.heex | 14 + .../components/shop_components/content.ex | 241 ++++++++- .../components/shop_components/product.ex | 33 +- lib/berrypod_web/live/admin/reviews.ex | 389 +++++++++++++++ lib/berrypod_web/live/shop/pages/product.ex | 165 ++++-- lib/berrypod_web/live/shop/review_form.ex | 248 +++++++-- lib/berrypod_web/router.ex | 1 + ...401221813_add_rating_cache_to_products.exs | 10 + test/berrypod/media_test.exs | 4 +- 26 files changed, 2155 insertions(+), 157 deletions(-) create mode 100644 lib/berrypod/reviews/review_request_worker.ex create mode 100644 lib/berrypod_web/live/admin/reviews.ex create mode 100644 priv/repo/migrations/20260401221813_add_rating_cache_to_products.exs diff --git a/PROGRESS.md b/PROGRESS.md index db8a6a8..a787ef0 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -30,7 +30,8 @@ Tier 1 MVP complete. Tier 2 production readiness complete (except Litestream and - Fully Tailwind-free CSS (12 KB gzipped shop+theme, 95 KB gzipped admin total) - CI pipeline (compile warnings, format, credo, dialyzer, tests) - Deployed on Fly.io with observability (LiveDashboard, ErrorTracker, structured logging) -- 1800+ tests passing, 99-100 PageSpeed mobile +- Product reviews with verified purchase, photo uploads, admin moderation +- 1851+ tests passing, 99-100 PageSpeed mobile ## Next up @@ -120,11 +121,11 @@ Close critical gaps identified in the competitive analysis. Phased approach: cor | 81 | PayPal SDK integration | — | 2h | planned | | 82 | PayPal checkout flow | 81 | 3h | planned | | 83 | PayPal webhooks | 82 | 1.5h | planned | -| 84 | Reviews schema | — | 1.5h | planned | -| 85 | Review submission | 84 | 2h | planned | -| 86 | Review moderation | 84 | 1.5h | planned | -| 87 | Reviews display | 84 | 1.5h | planned | -| 88 | Review schema markup | 87 | 1h | planned | +| 84 | Reviews schema | — | 1.5h | done | +| 85 | Review submission | 84 | 2h | done | +| 86 | Review moderation | 84 | 1.5h | done | +| 87 | Reviews display | 84 | 1.5h | done | +| 88 | Review schema markup + rating cache + request emails | 87 | 2h | done | | 89 | Provider stock sync | — | — | done (already existed) | | 90 | Availability display | 89 | 1h | done | @@ -260,3 +261,4 @@ All plans in [docs/plans/](docs/plans/). Completed plans are kept as architectur | [seo-enhancements.md](docs/plans/seo-enhancements.md) | Planned | | [competitive-gap-analysis.md](docs/plans/competitive-gap-analysis.md) | Reference | | [competitive-gaps.md](docs/plans/competitive-gaps.md) | Planned | +| [reviews-system.md](docs/plans/reviews-system.md) | Complete | diff --git a/README.md b/README.md index ac192f2..ce40ad3 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ A customisable e-commerce storefront for print-on-demand sellers, built with Pho Complete storefront with all the pages you need: - **Home** — hero banner, category navigation, featured products, newsletter - **Products** — grid layout with hover effects, sorting, filtering by collection -- **Product detail** — image gallery with per-colour filtering, variant selector, related products +- **Product detail** — image gallery with per-colour filtering, variant selector, related products, reviews +- **Reviews** — verified purchase reviews with photos, admin moderation, JSON-LD markup - **Cart** — drawer + full page, quantity controls, shipping estimate, cross-tab sync - **Checkout** — Stripe-hosted checkout with shipping costs, order confirmation - **Custom pages** — CMS pages at any URL with 26 block types @@ -34,7 +35,7 @@ Complete storefront with all the pages you need: - Image optimisation pipeline (AVIF/WebP/JPEG responsive variants via Oban) - ETS caching for CSS, pages, redirects, favicons - 99-100 PageSpeed mobile, no-JS support across all key flows -- 1679+ tests, CI with credo + dialyzer +- 1851+ tests, CI with credo + dialyzer ## Getting started diff --git a/ROADMAP.md b/ROADMAP.md index a864bc2..3b17ac2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ ## What's done -Tiers 1-3 are complete. The shop handles real orders end-to-end: browse products, add to cart, Stripe Checkout, order submission to Printify/Printful, fulfilment tracking, transactional emails. Full admin with theme editor, page builder, analytics, media library, activity log, URL redirects, and dead link monitoring. Tailwind-free CSS, 99-100 PageSpeed, 1679+ tests. +Tiers 1-3 are complete. The shop handles real orders end-to-end: browse products, add to cart, Stripe Checkout, order submission to Printify/Printful, fulfilment tracking, transactional emails. Full admin with theme editor, page builder, analytics, media library, activity log, URL redirects, and dead link monitoring. Product reviews with verified purchase badges, photo uploads, admin moderation, and JSON-LD markup. Tailwind-free CSS, 99-100 PageSpeed, 1851+ tests. See [PROGRESS.md](PROGRESS.md) for the full list. @@ -56,7 +56,14 @@ Support connecting multiple print providers simultaneously during setup and in g - Pre-checkout variant validation (verify provider availability) - Cost change monitoring/alerts (warn if provider costs increased) - Better image gallery (zoom, multiple angles) -- Product reviews system +- ~~Product reviews system~~ ✓ Done (all 6 phases complete: schema, submission, display, moderation, request emails, JSON-LD, rating cache) + +## Editable email templates + +Admin UI for customising transactional email copy. Currently hardcoded in notifier modules (order confirmation, shipping notification, cart recovery, review verification, newsletter). Would require: +- Database-backed template schema with placeholder support +- Admin editor with variable insertion +- Preview/test send functionality ## Platform vision diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index 4da415d..e02245f 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -6438,4 +6438,188 @@ } } +/* ── Review moderation ── */ + +.admin-review-list { + margin-top: 1rem; +} + +.admin-review-row { + border: 1px solid var(--admin-border); + border-radius: var(--radius-md); + margin-bottom: 0.5rem; + background: var(--admin-surface); + overflow: hidden; +} + +.admin-review-row-expanded { + box-shadow: 0 2px 8px oklch(0 0 0 / 0.08); +} + +.admin-review-header { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1rem; + width: 100%; + text-align: left; + background: transparent; + border: none; + cursor: pointer; + transition: background-color 0.1s ease; + + &:hover { + background: oklch(0.98 0 0); + } +} + +.admin-review-meta { + display: flex; + align-items: center; + gap: 0.75rem; + flex-shrink: 0; +} + +.admin-review-product { + font-weight: 500; + font-size: 0.875rem; + max-width: 14rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.admin-review-rating { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.875rem; + font-weight: 500; + color: var(--admin-text-muted); +} + +.admin-star { + color: #f59e0b; +} + +.admin-review-info { + flex: 1; + display: flex; + align-items: center; + gap: 0.75rem; + min-width: 0; + font-size: 0.8125rem; + color: var(--admin-text-muted); +} + +.admin-review-author { + font-weight: 500; + color: var(--admin-text-default); +} + +.admin-review-email { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.admin-review-date { + flex-shrink: 0; + color: var(--admin-text-faint); +} + +.admin-review-chevron { + flex-shrink: 0; + color: var(--admin-text-faint); +} + +.admin-review-detail { + padding: 1rem; + border-top: 1px solid var(--admin-border); + background: oklch(0.985 0 0); +} + +.admin-review-content { + margin-bottom: 1rem; +} + +.admin-review-title { + font-weight: 600; + font-size: 0.9375rem; + margin-bottom: 0.25rem; +} + +.admin-review-body { + font-size: 0.875rem; + line-height: 1.6; + color: var(--admin-text-muted); + white-space: pre-wrap; +} + +.admin-review-empty { + font-size: 0.875rem; + font-style: italic; + color: var(--admin-text-faint); +} + +.admin-review-photos { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.admin-review-photo { + display: block; + width: 5rem; + height: 5rem; + border-radius: var(--radius-sm); + overflow: hidden; + border: 1px solid var(--admin-border); + transition: border-color 0.1s ease; + + &:hover { + border-color: var(--t-accent); + } + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.admin-review-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +/* Status badges */ +.admin-status-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.75rem; + font-weight: 500; + padding: 0.125rem 0.5rem; + border-radius: 9999px; + text-transform: capitalize; +} + +.admin-status-pending { + background: oklch(0.96 0.04 80); + color: oklch(0.45 0.1 80); +} + +.admin-status-approved { + background: oklch(0.95 0.06 145); + color: oklch(0.35 0.12 145); +} + +.admin-status-rejected { + background: oklch(0.95 0.04 25); + color: oklch(0.45 0.12 25); +} + } /* @layer admin */ diff --git a/assets/css/shop/components.css b/assets/css/shop/components.css index 36a8d04..4b1fc2b 100644 --- a/assets/css/shop/components.css +++ b/assets/css/shop/components.css @@ -130,6 +130,18 @@ margin-top: 0.25rem; } + .product-card-rating { + display: flex; + align-items: center; + gap: 0.25rem; + margin-top: 0.25rem; + } + + .product-card-rating-count { + color: var(--t-text-tertiary); + font-size: var(--t-text-caption); + } + /* ── Product prices (shared between cards and PDP) ── */ .product-price--sale { @@ -2393,7 +2405,6 @@ .review-body { font-size: var(--t-text-small); - margin-bottom: 0.75rem; color: var(--t-text-secondary); line-height: 1.6; } @@ -2402,6 +2413,7 @@ display: flex; align-items: center; gap: 0.5rem; + margin-top: 0.75rem; } .review-author { @@ -2411,11 +2423,468 @@ } .review-verified { + display: inline-flex; + align-items: center; + gap: 0.25rem; font-size: var(--t-text-caption); padding: 0.125rem 0.5rem; border-radius: var(--t-radius-sm, 4px); background-color: var(--t-surface-sunken); color: var(--t-text-tertiary); + + svg { + flex-shrink: 0; + color: var(--t-accent); + } + } + + /* ── Review photos ── */ + + .review-photos { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 0.5rem; + } + + .review-photo-thumb { + width: 4rem; + height: 4rem; + padding: 0; + border: 1px solid var(--t-border); + border-radius: var(--t-radius-sm, 4px); + background: var(--t-surface-sunken); + cursor: pointer; + overflow: hidden; + transition: border-color 0.15s ease; + + &:hover { + border-color: var(--t-accent); + } + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + /* ── Review request form ── */ + + .review-request-form { + padding: 1.5rem; + margin-bottom: 1.5rem; + background-color: var(--t-surface-sunken); + border-radius: var(--t-radius-card); + } + + .review-request-title { + font-family: var(--t-font-heading); + font-weight: 600; + font-size: var(--t-text-large); + color: var(--t-text-primary); + margin-bottom: 0.75rem; + } + + .review-request-hint { + font-size: var(--t-text-small); + color: var(--t-text-secondary); + margin-bottom: 1rem; + line-height: 1.5; + } + + .review-email-form { + display: flex; + flex-direction: column; + gap: 1rem; + } + + .review-email-row { + display: flex; + gap: 0.75rem; + } + + .review-email-input { + flex: 1; + padding: 0.625rem 1rem; + font-size: var(--t-text-base); + font-family: var(--t-font-body); + color: var(--t-text-primary); + background-color: var(--t-surface-base); + border: 1px solid var(--t-border-default); + border-radius: var(--t-radius-input); + outline: none; + transition: border-color 0.15s ease; + + &:focus { + border-color: var(--t-accent); + } + + &::placeholder { + color: var(--t-text-tertiary); + } + } + + .review-email-submit { + padding: 0.625rem 1.25rem; + font-size: var(--t-text-base); + font-weight: 500; + font-family: var(--t-font-body); + color: var(--t-text-inverse); + background-color: var(--t-accent-button); + border: none; + border-radius: var(--t-radius-button); + cursor: pointer; + white-space: nowrap; + transition: opacity 0.15s ease; + + &:hover { + opacity: 0.9; + } + } + + /* ── Review status message ── */ + + .review-status { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.75rem 1rem; + margin-bottom: 1rem; + border-radius: var(--t-radius-sm, 4px); + font-size: var(--t-text-small); + + p { + margin: 0; + flex: 1; + } + } + + .review-status-icon { + flex-shrink: 0; + margin-top: 0.1em; + } + + .review-status-info { + background-color: color-mix(in srgb, var(--t-accent) 12%, transparent); + color: var(--t-accent); + } + + .review-status-error { + background-color: color-mix(in srgb, #ef4444 12%, transparent); + color: #b91c1c; + } + + /* ── Existing review notice ── */ + + .existing-review-notice { + padding: 1rem 1.25rem; + margin-bottom: 1.5rem; + background-color: var(--t-surface-sunken); + border-radius: var(--t-radius-card); + } + + .existing-review-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.5rem; + } + + .existing-review-label { + font-size: var(--t-text-small); + font-weight: 600; + color: var(--t-text-primary); + } + + .existing-review-edit { + font-size: var(--t-text-small); + color: var(--t-accent); + text-decoration: underline; + } + + .existing-review-content { + font-size: var(--t-text-small); + color: var(--t-text-secondary); + } + + .existing-review-rating { + margin-bottom: 0.25rem; + } + + /* ── Reviews empty state ── */ + + .reviews-empty { + color: var(--t-text-secondary); + font-size: var(--t-text-small); + padding: 1rem 0; + } + + /* ── Review form page ── */ + + .review-form-page { + max-width: 32rem; + margin: 0 auto; + padding: 2rem 1rem; + } + + .review-form-container { + padding: 2rem; + background-color: var(--t-surface-card); + border: 1px solid var(--t-border-default); + border-radius: var(--t-radius-card); + } + + .review-form-heading { + font-family: var(--t-font-heading); + font-weight: 700; + font-size: var(--t-text-2xl); + color: var(--t-text-primary); + margin-bottom: 0.5rem; + } + + .review-form-product { + font-size: var(--t-text-base); + color: var(--t-text-secondary); + margin-bottom: 2rem; + } + + .review-form { + display: flex; + flex-direction: column; + gap: 1.5rem; + } + + .review-form-field { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .review-form-label { + font-weight: 500; + color: var(--t-text-primary); + } + + .review-form-optional { + font-weight: normal; + color: var(--t-text-tertiary); + } + + .review-form-input, + .review-form-textarea { + padding: 0.75rem 1rem; + font-size: var(--t-text-base); + font-family: var(--t-font-body); + color: var(--t-text-primary); + background-color: var(--t-surface-base); + border: 1px solid var(--t-border-default); + border-radius: var(--t-radius-input); + outline: none; + transition: border-color 0.15s ease; + + &:focus { + border-color: var(--t-accent); + } + + &::placeholder { + color: var(--t-text-tertiary); + } + } + + .review-form-textarea { + resize: vertical; + min-height: 6rem; + } + + .review-form-error { + font-size: var(--t-text-small); + color: #b91c1c; + } + + .review-form-submit { + padding: 0.75rem 1.5rem; + font-size: var(--t-text-base); + font-weight: 600; + font-family: var(--t-font-body); + color: var(--t-text-inverse); + background-color: var(--t-accent-button); + border: none; + border-radius: var(--t-radius-button); + cursor: pointer; + transition: opacity 0.15s ease; + + &:hover { + opacity: 0.9; + } + } + + /* ── Star rating input ── */ + + .star-rating-input { + display: flex; + gap: 0.25rem; + } + + .star-rating-btn { + background: none; + border: none; + padding: 0.125rem; + cursor: pointer; + transition: transform 0.1s ease; + + &:hover { + transform: scale(1.1); + } + } + + .star-icon { + width: 1.75rem; + height: 1.75rem; + } + + .star-filled { + color: #f59e0b; + } + + .star-empty { + color: var(--t-border-default); + } + + /* ── Review success/error pages ── */ + + .review-success, + .review-error { + text-align: center; + padding: 3rem 1.5rem; + } + + .review-success-icon { + width: 4rem; + height: 4rem; + margin: 0 auto 1.5rem; + color: var(--t-accent); + } + + .review-success h1, + .review-error h1 { + font-family: var(--t-font-heading); + font-weight: 700; + font-size: var(--t-text-2xl); + color: var(--t-text-primary); + margin-bottom: 0.75rem; + } + + .review-success p, + .review-error p { + color: var(--t-text-secondary); + margin-bottom: 1.5rem; + } + + .review-back-link { + display: inline-block; + color: var(--t-accent); + text-decoration: underline; + } + + .review-edit-link { + display: block; + margin-top: 0.75rem; + color: var(--t-text-secondary); + text-decoration: underline; + font-size: var(--t-text-small); + } + + /* ── Review photo upload ── */ + + .review-photo-previews { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 0.5rem; + } + + .review-photo-preview { + position: relative; + width: 5rem; + height: 5rem; + border-radius: var(--t-radius-sm, 4px); + overflow: hidden; + background: var(--t-surface-sunken); + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + .review-photo-remove { + position: absolute; + top: 0.25rem; + right: 0.25rem; + width: 1.5rem; + height: 1.5rem; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.6); + border: none; + border-radius: 50%; + color: white; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s ease; + + .review-photo-preview:hover & { + opacity: 1; + } + } + + .review-photo-progress { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background: var(--t-surface-sunken); + } + + .review-photo-progress-bar { + height: 100%; + background: var(--t-accent); + transition: width 0.2s ease; + } + + .review-photo-add { + width: 5rem; + height: 5rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.25rem; + border: 2px dashed var(--t-border); + border-radius: var(--t-radius-sm, 4px); + color: var(--t-text-tertiary); + cursor: pointer; + transition: border-color 0.15s ease, color 0.15s ease; + + &:hover { + border-color: var(--t-accent); + color: var(--t-accent); + } + + span { + font-size: var(--t-text-caption); + } + } + + .review-form-hint { + font-size: var(--t-text-caption); + color: var(--t-text-tertiary); + margin: 0; } /* ── Rich text ── */ diff --git a/assets/js/app.js b/assets/js/app.js index 4beb0ef..6f50a6d 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -205,12 +205,13 @@ const CartDrawer = { } } -// Hook for PDP image lightbox +// Hook for image lightbox (used by product gallery and review photos) const Lightbox = { mounted() { const dialog = this.el - const lightboxImage = dialog.querySelector('#lightbox-image') - const lightboxCounter = dialog.querySelector('#lightbox-counter') + // Use class selectors so multiple lightboxes can coexist + const lightboxImage = dialog.querySelector('.lightbox-image') + const lightboxCounter = dialog.querySelector('.lightbox-counter') // Get images from data attribute const getImages = () => { @@ -258,11 +259,15 @@ const Lightbox = { dialog.close() } - // Event listeners for custom events dispatched from LiveView.JS + // Event listeners - support both legacy pdp: events and new lightbox: events dialog.addEventListener('pdp:open-lightbox', openLightbox) dialog.addEventListener('pdp:close-lightbox', closeLightbox) dialog.addEventListener('pdp:next-image', nextImage) dialog.addEventListener('pdp:prev-image', prevImage) + dialog.addEventListener('lightbox:open', openLightbox) + dialog.addEventListener('lightbox:close', closeLightbox) + dialog.addEventListener('lightbox:next', nextImage) + dialog.addEventListener('lightbox:prev', prevImage) // Close on clicking backdrop dialog.addEventListener('click', (e) => { @@ -288,6 +293,10 @@ const Lightbox = { dialog.removeEventListener('pdp:close-lightbox', closeLightbox) dialog.removeEventListener('pdp:next-image', nextImage) dialog.removeEventListener('pdp:prev-image', prevImage) + dialog.removeEventListener('lightbox:open', openLightbox) + dialog.removeEventListener('lightbox:close', closeLightbox) + dialog.removeEventListener('lightbox:next', nextImage) + dialog.removeEventListener('lightbox:prev', prevImage) } }, diff --git a/docs/plans/reviews-system.md b/docs/plans/reviews-system.md index ac18221..d4c0c91 100644 --- a/docs/plans/reviews-system.md +++ b/docs/plans/reviews-system.md @@ -1,6 +1,6 @@ # Product reviews system -Status: In Progress (Phase 1-3 complete) +Status: Complete ## Overview @@ -447,48 +447,48 @@ Only include if product has approved reviews. - "Write a review" / "Edit review" buttons - Link to review form -### Phase 4: Display and components (~2.5h) +### Phase 4: Display and components (~2.5h) ✓ -8. **Extract image_lightbox component** (0.5h) +8. **Extract image_lightbox component** (0.5h) ✓ - Generic lightbox from product_lightbox - Same JS hook, just cleaner component interface -9. **Review photos component** (0.5h) - - Inline thumbnails +9. **Review photos component** (0.5h) ✓ + - Inline thumbnails with URL building from Image structs - Opens image_lightbox on click -10. **Product page display** (1.5h) - - Update reviews_section to use real data - - Verified purchase badge - - Review photos display - - Pagination +10. **Product page display** (1.5h) ✓ + - Updated review_card to include photos and verified badge with icon + - Verified purchase badge with checkmark SVG + - Review photos display via review_photos component + - Pagination via existing load_more_reviews event -### Phase 5: Admin moderation (~2h) +### Phase 5: Admin moderation (~2h) ✓ -11. **Admin reviews list** (1.5h) +11. **Admin reviews list** (1.5h) ✓ - Reviews list with tabs (pending/approved/rejected) - Photo thumbnails in list - Expand to see full review + photos - Approve/reject actions -12. **Nav and notifications** (0.5h) +12. **Nav and notifications** (0.5h) ✓ - Pending count badge in admin nav - Optional: email to admin on new review -### Phase 6: Automation and SEO (~2h) +### Phase 6: Automation and SEO (~2h) ✓ -13. **Review request emails** (1h) +13. **Review request emails** (1h) ✓ - Oban job for post-delivery requests - Email template - - Admin setting for delay + - Admin setting for delay (defaults to 7 days) -14. **Schema markup** (0.5h) +14. **Schema markup** (0.5h) ✓ - AggregateRating on product pages - Individual Review markup -15. **Rating cache on products** (0.5h) +15. **Rating cache on products** (0.5h) ✓ - Add rating_avg, rating_count to products - - Update on review approval + - Update on review approval/rejection/deletion - Display on product cards --- diff --git a/lib/berrypod/images/optimizer.ex b/lib/berrypod/images/optimizer.ex index 19686b4..dcca5bf 100644 --- a/lib/berrypod/images/optimizer.ex +++ b/lib/berrypod/images/optimizer.ex @@ -69,6 +69,10 @@ defmodule Berrypod.Images.Optimizer do @doc """ Process image and generate all applicable variants. Called by Oban worker. + + Handles both: + - Pre-converted images (with source_width set) + - Raw uploaded images (need conversion to WebP first) """ def process_for_image(image_id) do # Load the image row and release the DB connection immediately, @@ -89,36 +93,73 @@ defmodule Berrypod.Images.Optimizer do %{data: data} when byte_size(data) < @min_image_bytes -> {:error, :too_small} + %{data: data, source_width: nil} -> + # Raw image that needs conversion first + process_raw_image(image, data) + %{data: data, source_width: width} -> - File.mkdir_p!(cache_dir()) + # Already converted to WebP with dimensions + process_converted_image(image, data, width) + end + end - # Write source WebP to disk so it can be served by Plug.Static - source_path = Path.join(cache_dir(), "#{image_id}.webp") - unless File.exists?(source_path), do: File.write!(source_path, data) - - with {:ok, vips_image} <- Image.from_binary(data) do - widths = applicable_widths(width) - - all_tasks = - [fn -> generate_thumbnail(vips_image, image_id) end] ++ - for w <- widths, fmt <- @pregenerated_formats do - fn -> generate_variant(vips_image, image_id, w, fmt) end - end - - # Cap concurrency to the number of CPU cores — keeps small - # machines from choking while still saturating bigger ones. - Task.async_stream(all_tasks, & &1.(), - max_concurrency: System.schedulers_online(), - timeout: :timer.seconds(120) + # Process a raw uploaded image: convert to WebP, update DB, then generate variants + defp process_raw_image(image, data) do + case to_optimized_webp(data) do + {:ok, webp_data, width, height} -> + # Update the image record with converted data and dimensions + updated = + Repo.update!( + ImageSchema.changeset(image, %{ + data: webp_data, + content_type: "image/webp", + file_size: byte_size(webp_data), + source_width: width, + source_height: height + }) ) - |> Stream.run() - # Extract dominant colors for header images (used for contrast checking) - maybe_extract_dominant_colors(image, vips_image) + # Now generate variants with the converted data + process_converted_image(updated, webp_data, width) - Repo.update!(ImageSchema.changeset(image, %{variants_status: "complete"})) - {:ok, widths} - end + {:error, reason} -> + Logger.warning("Failed to convert image #{image.id}: #{inspect(reason)}") + # Mark as complete anyway so we don't retry forever + Repo.update!(ImageSchema.changeset(image, %{variants_status: "complete"})) + {:error, reason} + end + end + + # Process an already-converted WebP image: generate variants + defp process_converted_image(image, data, width) do + File.mkdir_p!(cache_dir()) + + # Write source WebP to disk so it can be served by Plug.Static + source_path = Path.join(cache_dir(), "#{image.id}.webp") + unless File.exists?(source_path), do: File.write!(source_path, data) + + with {:ok, vips_image} <- Image.from_binary(data) do + widths = applicable_widths(width) + + all_tasks = + [fn -> generate_thumbnail(vips_image, image.id) end] ++ + for w <- widths, fmt <- @pregenerated_formats do + fn -> generate_variant(vips_image, image.id, w, fmt) end + end + + # Cap concurrency to the number of CPU cores — keeps small + # machines from choking while still saturating bigger ones. + Task.async_stream(all_tasks, & &1.(), + max_concurrency: System.schedulers_online(), + timeout: :timer.seconds(120) + ) + |> Stream.run() + + # Extract dominant colors for header images (used for contrast checking) + maybe_extract_dominant_colors(image, vips_image) + + Repo.update!(ImageSchema.changeset(image, %{variants_status: "complete"})) + {:ok, widths} end end diff --git a/lib/berrypod/media.ex b/lib/berrypod/media.ex index c79ba81..e610fce 100644 --- a/lib/berrypod/media.ex +++ b/lib/berrypod/media.ex @@ -118,6 +118,43 @@ defmodule Berrypod.Media do upload_image(Map.merge(base, extra_attrs)) end + @doc """ + Upload that stores raw image data without synchronous processing. + + Unlike `upload_from_entry/4`, this skips the WebP conversion step and + defers all image processing to the background Oban worker. This is + essential for multi-file uploads where synchronous processing would + cause upload channel timeouts. + + The trade-off: images won't have `source_width`/`source_height` set + until the background job runs, and won't display optimized variants + immediately. Fine for review photos where we show a `live_img_preview` + during upload and the final display can wait. + """ + def upload_from_entry_async(path, entry, image_type) do + file_binary = File.read!(path) + + attrs = %{ + image_type: image_type, + filename: entry.client_name, + content_type: entry.client_type, + file_size: byte_size(file_binary), + data: file_binary, + variants_status: "pending" + } + + case Repo.insert(ImageSchema.changeset(%ImageSchema{}, attrs)) do + {:ok, image} -> + # Enqueue background job for conversion and optimization + OptimizeWorker.enqueue(image.id) + invalidate_media_cache(image.image_type) + {:ok, image} + + error -> + error + end + end + @doc """ Gets a single image by ID. diff --git a/lib/berrypod/media/image.ex b/lib/berrypod/media/image.ex index 2e144a0..5d3a2a8 100644 --- a/lib/berrypod/media/image.ex +++ b/lib/berrypod/media/image.ex @@ -24,7 +24,7 @@ defmodule Berrypod.Media.Image do timestamps(type: :utc_datetime) end - @max_file_size 5_000_000 + @max_file_size 10_000_000 @doc false def changeset(image, attrs) do @@ -46,7 +46,7 @@ defmodule Berrypod.Media.Image do :dominant_colors ]) |> validate_required([:image_type, :filename, :content_type, :file_size, :data]) - |> validate_inclusion(:image_type, ~w(logo header product icon media)) + |> validate_inclusion(:image_type, ~w(logo header product icon media review)) |> validate_number(:file_size, less_than: @max_file_size) |> detect_svg() end diff --git a/lib/berrypod/orders.ex b/lib/berrypod/orders.ex index eeee672..0b54dda 100644 --- a/lib/berrypod/orders.ex +++ b/lib/berrypod/orders.ex @@ -272,6 +272,10 @@ defmodule Berrypod.Orders do OrderNotifier.deliver_shipping_notification(updated_order) end + if attrs[:fulfilment_status] == "delivered" and order.fulfilment_status != "delivered" do + Berrypod.Reviews.ReviewRequestWorker.enqueue(updated_order.id) + end + {:ok, updated_order} end end diff --git a/lib/berrypod/pages/block_types.ex b/lib/berrypod/pages/block_types.ex index 3b6b20d..e3194ea 100644 --- a/lib/berrypod/pages/block_types.ex +++ b/lib/berrypod/pages/block_types.ex @@ -490,7 +490,8 @@ defmodule Berrypod.Pages.BlockTypes do body: review.body, author: review.author_name, date: relative_time(review.inserted_at), - verified: not is_nil(review.order_id) + verified: not is_nil(review.order_id), + images: Berrypod.Reviews.get_review_images(review) } end diff --git a/lib/berrypod/products/product.ex b/lib/berrypod/products/product.ex index fc92008..7a32ce5 100644 --- a/lib/berrypod/products/product.ex +++ b/lib/berrypod/products/product.ex @@ -31,6 +31,10 @@ defmodule Berrypod.Products.Product do field :in_stock, :boolean, default: true field :on_sale, :boolean, default: false + # Denormalized from reviews — recomputed by Reviews.update_product_rating_cache/1 + field :rating_avg, :decimal + field :rating_count, :integer, default: 0 + belongs_to :provider_connection, Berrypod.Products.ProviderConnection has_many :images, Berrypod.Products.ProductImage has_many :variants, Berrypod.Products.ProductVariant diff --git a/lib/berrypod/reviews.ex b/lib/berrypod/reviews.ex index 7686b31..091b3e6 100644 --- a/lib/berrypod/reviews.ex +++ b/lib/berrypod/reviews.ex @@ -44,10 +44,16 @@ defmodule Berrypod.Reviews do @doc """ Lists approved reviews for a product, newest first. + + Options: + * `:status` - filter by status (default "approved") + * `:limit` - max number of reviews to return + * `:offset` - number of reviews to skip (for pagination) """ def list_reviews_for_product(product_id, opts \\ []) do status = Keyword.get(opts, :status, "approved") limit = Keyword.get(opts, :limit) + offset = Keyword.get(opts, :offset, 0) query = from r in Review, @@ -55,6 +61,7 @@ defmodule Berrypod.Reviews do order_by: [desc: r.inserted_at] query = if limit, do: limit(query, ^limit), else: query + query = if offset > 0, do: offset(query, ^offset), else: query Repo.all(query) end @@ -94,6 +101,57 @@ defmodule Berrypod.Reviews do Repo.one(from r in Review, where: r.status == "pending", select: count(r.id)) end + @doc """ + Returns counts of reviews by status for admin tabs. + """ + def count_reviews_by_status do + Repo.all( + from r in Review, + group_by: r.status, + select: {r.status, count(r.id)} + ) + |> Map.new() + end + + @doc """ + Lists reviews with pagination for admin. + + Options: + * `:status` - filter by status (nil for all) + * `:search` - search email or product title + * `:page` - page number + * `:per_page` - items per page (default 20) + """ + def list_reviews_paginated(opts \\ []) do + status = Keyword.get(opts, :status) + search = Keyword.get(opts, :search) + + query = + from r in Review, + join: p in assoc(r, :product), + order_by: [desc: r.inserted_at], + preload: [product: p] + + query = if status && status != "", do: where(query, [r], r.status == ^status), else: query + + query = + if search && search != "" do + pattern = "%#{String.downcase(search)}%" + + where( + query, + [r, p], + like(fragment("lower(?)", r.email), ^pattern) or + like(fragment("lower(?)", r.author_name), ^pattern) or + like(fragment("lower(?)", p.title), ^pattern) + ) + else + query + end + + Berrypod.Pagination.paginate(query, page: opts[:page], per_page: opts[:per_page] || 20) + end + # ── Aggregates ───────────────────────────────────────────────────── @doc """ @@ -169,7 +227,15 @@ defmodule Berrypod.Reviews do # Auto-link to matching order for "verified purchase" badge order = if email && product_id, do: find_matching_order(email, product_id), else: nil - attrs = if order, do: Map.put(attrs, :order_id, order.id), else: attrs + + attrs = + if order do + # Use same key type as the input map + key = if Map.has_key?(attrs, :email), do: :order_id, else: "order_id" + Map.put(attrs, key, order.id) + else + attrs + end %Review{} |> Review.changeset(attrs) @@ -188,27 +254,64 @@ defmodule Berrypod.Reviews do @doc """ Approves a review for public display. + Updates the product's rating cache. """ def approve_review(%Review{} = review) do - review - |> Review.moderation_changeset("approved") - |> Repo.update() + result = + review + |> Review.moderation_changeset("approved") + |> Repo.update() + + case result do + {:ok, updated} -> + update_product_rating_cache(updated.product_id) + {:ok, updated} + + error -> + error + end end @doc """ Rejects a review (won't be displayed). + Updates the product's rating cache if the review was previously approved. """ def reject_review(%Review{} = review) do - review - |> Review.moderation_changeset("rejected") - |> Repo.update() + was_approved = review.status == "approved" + + result = + review + |> Review.moderation_changeset("rejected") + |> Repo.update() + + case result do + {:ok, updated} -> + if was_approved, do: update_product_rating_cache(updated.product_id) + {:ok, updated} + + error -> + error + end end @doc """ Deletes a review. + Updates the product's rating cache if the review was approved. """ def delete_review(%Review{} = review) do - Repo.delete(review) + was_approved = review.status == "approved" + product_id = review.product_id + + result = Repo.delete(review) + + case result do + {:ok, _} -> + if was_approved, do: update_product_rating_cache(product_id) + result + + error -> + error + end end # ── Images ───────────────────────────────────────────────────────── @@ -223,6 +326,35 @@ defmodule Berrypod.Reviews do def get_review_images(_), do: [] + @doc """ + Batch loads images for multiple reviews, returning a map of review_id => [images]. + More efficient than calling get_review_images/1 for each review. + """ + def preload_review_images(reviews) when is_list(reviews) do + # Collect all image IDs across all reviews + all_image_ids = + reviews + |> Enum.flat_map(fn r -> r.image_ids || [] end) + |> Enum.uniq() + + if all_image_ids == [] do + Map.new(reviews, fn r -> {r.id, []} end) + else + # Fetch all images in one query + images_by_id = Media.get_images(all_image_ids) |> Map.new(&{&1.id, &1}) + + # Build map of review_id => [images] + Map.new(reviews, fn review -> + images = + (review.image_ids || []) + |> Enum.map(&Map.get(images_by_id, &1)) + |> Enum.reject(&is_nil/1) + + {review.id, images} + end) + end + end + # ── Review tokens ────────────────────────────────────────────────── @review_salt "review_verification_v1" @@ -293,6 +425,25 @@ defmodule Berrypod.Reviews do end end + # ── Rating cache ────────────────────────────────────────────────── + + @doc """ + Updates the cached rating_avg and rating_count on a product. + + Called after review approval, rejection, or deletion to keep the + denormalized values in sync. Uses approved reviews only. + """ + def update_product_rating_cache(product_id) do + {avg, count} = average_rating_for_product(product_id) + + Repo.update_all( + from(p in Berrypod.Products.Product, where: p.id == ^product_id), + set: [rating_avg: avg, rating_count: count] + ) + + :ok + end + # ── Helpers ──────────────────────────────────────────────────────── defp normalise_email(email) when is_binary(email) do diff --git a/lib/berrypod/reviews/review_notifier.ex b/lib/berrypod/reviews/review_notifier.ex index 629e50f..80f26bb 100644 --- a/lib/berrypod/reviews/review_notifier.ex +++ b/lib/berrypod/reviews/review_notifier.ex @@ -39,6 +39,52 @@ defmodule Berrypod.Reviews.ReviewNotifier do deliver(email, subject, body) end + @doc """ + Sends a review request email after order delivery. + + Includes links to review each product in the order that hasn't been + reviewed yet. Each link contains a signed token for that email+product. + """ + def deliver_review_request(order, unreviewable_products) do + shop_name = Berrypod.Settings.get_setting("shop_name", "Berrypod") + customer_name = extract_customer_name(order) + + product_links = + unreviewable_products + |> Enum.map(fn %{product_id: product_id, product_name: name} -> + token = Berrypod.Reviews.generate_review_token(order.customer_email, product_id) + link = BerrypodWeb.Endpoint.url() <> "/reviews/new?token=#{token}" + " #{name}\n #{link}" + end) + |> Enum.join("\n\n") + + body = """ + ============================== + + Hi#{if customer_name, do: " #{customer_name}", else: ""}, + + Your order #{order.order_number} was delivered recently. We'd love to hear what you think! + + #{product_links} + + Thanks for shopping with us. + + ============================== + """ + + deliver( + order.customer_email, + "How was your order from #{shop_name}?", + body + ) + end + + defp extract_customer_name(%{shipping_address: %{"name" => name}}) when is_binary(name) do + name |> String.split(" ") |> List.first() + end + + defp extract_customer_name(_), do: nil + # --- Private --- defp deliver(recipient, subject, body) do diff --git a/lib/berrypod/reviews/review_request_worker.ex b/lib/berrypod/reviews/review_request_worker.ex new file mode 100644 index 0000000..9606623 --- /dev/null +++ b/lib/berrypod/reviews/review_request_worker.ex @@ -0,0 +1,108 @@ +defmodule Berrypod.Reviews.ReviewRequestWorker do + @moduledoc """ + Sends review request emails after order delivery. + + Enqueued with a configurable delay (default 7 days) after an order is marked + as delivered. At send time, checks which products in the order haven't been + reviewed yet and sends a single email with links for each. + + Only one review request is sent per order. If the customer has already + reviewed all products, no email is sent. + """ + + use Oban.Worker, queue: :mailer, max_attempts: 3 + + alias Berrypod.{Orders, Reviews, Settings} + alias Berrypod.Reviews.ReviewNotifier + + require Logger + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"order_id" => order_id}}) do + case Orders.get_order(order_id) do + nil -> + Logger.warning("Review request: order #{order_id} not found") + {:cancel, :not_found} + + %{customer_email: nil} -> + Logger.info("Review request: no customer email for order #{order_id}") + :ok + + %{customer_email: ""} -> + Logger.info("Review request: no customer email for order #{order_id}") + :ok + + order -> + send_review_request(order) + end + end + + defp send_review_request(order) do + # Check suppression - review requests are marketing emails + case Orders.check_suppression(order.customer_email) do + :suppressed -> + Logger.info("Review request: email suppressed for order #{order.order_number}") + :ok + + :ok -> + do_send_review_request(order) + end + end + + defp do_send_review_request(order) do + # Find products in this order that haven't been reviewed yet + unreviewable_products = get_unreviewable_products(order) + + if unreviewable_products == [] do + Logger.info("Review request: all products already reviewed for order #{order.order_number}") + :ok + else + case ReviewNotifier.deliver_review_request(order, unreviewable_products) do + {:ok, _} -> + Logger.info( + "Review request sent to #{order.customer_email} for order #{order.order_number}" + ) + + :ok + + {:error, reason} -> + Logger.error( + "Review request failed for order #{order.order_number}: #{inspect(reason)}" + ) + + {:error, reason} + end + end + end + + defp get_unreviewable_products(order) do + order.items + |> Enum.map(& &1.product_id) + |> Enum.uniq() + |> Enum.reject(&is_nil/1) + |> Enum.reject(fn product_id -> + # Already reviewed? + not is_nil(Reviews.get_review_by_email_and_product(order.customer_email, product_id)) + end) + |> Enum.map(fn product_id -> + # Find the item details for this product + item = Enum.find(order.items, &(&1.product_id == product_id)) + %{product_id: product_id, product_name: item.product_name} + end) + end + + @doc """ + Enqueues a review request job for an order. + + Called when an order is marked as delivered. Schedules the job based on + the `review_request_delay_days` setting (default 7 days). + """ + def enqueue(order_id) do + delay_days = Settings.get_setting("review_request_delay_days", 7) + delay_seconds = delay_days * 24 * 60 * 60 + + %{order_id: order_id} + |> new(schedule_in: delay_seconds) + |> Oban.insert() + end +end diff --git a/lib/berrypod_web/admin_layout_hook.ex b/lib/berrypod_web/admin_layout_hook.ex index dc67d42..4925f41 100644 --- a/lib/berrypod_web/admin_layout_hook.ex +++ b/lib/berrypod_web/admin_layout_hook.ex @@ -5,7 +5,7 @@ defmodule BerrypodWeb.AdminLayoutHook do """ import Phoenix.Component - alias Berrypod.{ActivityLog, Settings} + alias Berrypod.{ActivityLog, Reviews, Settings} alias Berrypod.Theme.{CSSCache, CSSGenerator} def on_mount(:assign_current_path, _params, _session, socket) do @@ -33,6 +33,7 @@ defmodule BerrypodWeb.AdminLayoutHook do |> assign(:site_description, Settings.site_description()) |> assign(:generated_css, generated_css) |> assign(:attention_count, ActivityLog.count_needing_attention()) + |> assign(:pending_review_count, Reviews.count_pending_reviews()) |> Phoenix.LiveView.attach_hook(:set_current_path, :handle_params, fn _params, uri, socket -> diff --git a/lib/berrypod_web/components/layouts/admin.html.heex b/lib/berrypod_web/components/layouts/admin.html.heex index 16a4755..63c4dad 100644 --- a/lib/berrypod_web/components/layouts/admin.html.heex +++ b/lib/berrypod_web/components/layouts/admin.html.heex @@ -126,6 +126,20 @@ <.icon name="hero-photo" class="size-5" /> Media +
  • + <.link + navigate={~p"/admin/reviews"} + class={admin_nav_active?(@current_path, "/admin/reviews")} + > + <.icon name="hero-star" class="size-5" /> Reviews + 0} + class="admin-badge admin-badge-sm admin-badge-warning ml-auto" + > + {@pending_review_count} + + +
  • <.link navigate={~p"/admin/newsletter"} diff --git a/lib/berrypod_web/components/shop_components/content.ex b/lib/berrypod_web/components/shop_components/content.ex index 2e9e3a3..68bd0ee 100644 --- a/lib/berrypod_web/components/shop_components/content.ex +++ b/lib/berrypod_web/components/shop_components/content.ex @@ -951,18 +951,189 @@ defmodule BerrypodWeb.ShopComponents.Content do """ end + @doc """ + Renders a generic image lightbox dialog. + + Reusable by product galleries, review photos, or any image gallery. + Uses the Lightbox JS hook for keyboard navigation and modal behavior. + + ## Attributes + + * `id` - Required. Unique ID for the dialog element. + * `images` - Required. List of image URLs. + * `caption` - Optional. Caption text shown below the image. + + ## Examples + + <.image_lightbox id="review-123-lightbox" images={@image_urls} caption="Customer photo" /> + """ + attr :id, :string, required: true + attr :images, :list, required: true + attr :caption, :string, default: nil + + def image_lightbox(assigns) do + ~H""" + + + + """ + end + + @doc """ + Renders review photo thumbnails that open in a lightbox. + + ## Attributes + + * `images` - Required. List of Image structs with `id` and `variants_status`. + * `review_id` - Required. ID of the review (for unique lightbox ID). + + ## Examples + + <.review_photos images={@review.images} review_id={@review.id} /> + """ + attr :images, :list, required: true + attr :review_id, :string, required: true + + def review_photos(assigns) do + # Build URLs from image structs + thumb_urls = + Enum.map(assigns.images, fn img -> + if img.variants_status == "complete" do + "/image_cache/#{img.id}-thumb.jpg" + else + "/images/#{img.id}" + end + end) + + full_urls = + Enum.map(assigns.images, fn img -> + if img.variants_status == "complete" do + "/image_cache/#{img.id}-800.webp" + else + "/images/#{img.id}" + end + end) + + assigns = + assigns + |> assign(:thumb_urls, thumb_urls) + |> assign(:full_urls, full_urls) + + ~H""" +
    + + + <.image_lightbox + id={"review-#{@review_id}-lightbox"} + images={@full_urls} + caption="Customer photo" + /> +
    + """ + end + @doc """ Renders a customer reviews section with collapsible header and review cards. ## Attributes * `reviews` - Required. List of review maps with: + - `id` - Review ID - `rating` - Star rating (1-5) - `title` - Review title - `body` - Review text - `author` - Reviewer name - `date` - Relative date string (e.g., "2 weeks ago") - `verified` - Boolean, if true shows "Verified purchase" badge + - `images` - Optional. List of Image structs for review photos. * `average_rating` - Optional. Average rating to show in header. Defaults to nil. * `total_count` - Optional. Total number of reviews. Defaults to length of reviews list. * `open` - Optional. Whether section is expanded by default. Defaults to true. @@ -1038,7 +1209,11 @@ defmodule BerrypodWeb.ShopComponents.Content do <% end %> - <.shop_button_outline :if={length(@reviews) < @display_count} class="reviews-load-more"> + <.shop_button_outline + :if={length(@reviews) < @display_count} + class="reviews-load-more" + phx-click="load_more_reviews" + > Load more reviews <% else %> @@ -1121,7 +1296,37 @@ defmodule BerrypodWeb.ShopComponents.Content do ~H"""
    - {@message} + + +

    {@message}

    """ end @@ -1131,11 +1336,11 @@ defmodule BerrypodWeb.ShopComponents.Content do ## Attributes - * `review` - Required. Map with `rating`, `title`, `body`, `author`, `date`, `verified`. + * `review` - Required. Map with `id`, `rating`, `title`, `body`, `author`, `date`, `verified`, and optional `images`. ## Examples - <.review_card review={%{rating: 5, title: "Great!", body: "...", author: "Jane", date: "1 week ago", verified: true}} /> + <.review_card review={%{id: "abc", rating: 5, title: "Great!", body: "...", author: "Jane", date: "1 week ago", verified: true, images: []}} /> """ attr :review, :map, required: true @@ -1146,19 +1351,33 @@ defmodule BerrypodWeb.ShopComponents.Content do <.star_rating rating={@review.rating} /> {@review.date} -

    {@review.title}

    -

    +

    {@review.title}

    +

    {@review.body}

    + <.review_photos + :if={@review[:images] && @review.images != []} + images={@review.images} + review_id={@review.id} + /> """ diff --git a/lib/berrypod_web/components/shop_components/product.ex b/lib/berrypod_web/components/shop_components/product.ex index 2faf51b..ada3b00 100644 --- a/lib/berrypod_web/components/shop_components/product.ex +++ b/lib/berrypod_web/components/shop_components/product.ex @@ -2,7 +2,7 @@ defmodule BerrypodWeb.ShopComponents.Product do use Phoenix.Component import BerrypodWeb.ShopComponents.Base - import BerrypodWeb.ShopComponents.Content, only: [responsive_image: 1] + import BerrypodWeb.ShopComponents.Content, only: [responsive_image: 1, star_rating: 1] alias Berrypod.Products.{Product, ProductImage} alias BerrypodWeb.R @@ -191,6 +191,7 @@ defmodule BerrypodWeb.ShopComponents.Product do <%= if @theme_settings.show_prices do %> <.product_price product={@product} variant={@variant} /> <% end %> + <.product_card_rating product={@product} /> <%= if @show_delivery_text do %>

    Made to order @@ -356,6 +357,33 @@ defmodule BerrypodWeb.ShopComponents.Product do """ end + attr :product, :map, required: true + + defp product_card_rating(assigns) do + rating_count = Map.get(assigns.product, :rating_count, 0) + rating_avg = Map.get(assigns.product, :rating_avg) + + # Round to nearest integer for stars display + rating_rounded = + if rating_avg do + rating_avg |> Decimal.round(0) |> Decimal.to_integer() + else + 0 + end + + assigns = + assigns + |> assign(:rating_count, rating_count) + |> assign(:rating_rounded, rating_rounded) + + ~H""" +

    0} class="product-card-rating"> + <.star_rating rating={@rating_rounded} size={:sm} /> + ({@rating_count}) +
    + """ + end + defp variant_defaults(:default), do: %{show_category: true, show_badges: true, show_delivery_text: true, clickable: true} @@ -1260,7 +1288,6 @@ defmodule BerrypodWeb.ShopComponents.Product do """ diff --git a/lib/berrypod_web/live/admin/reviews.ex b/lib/berrypod_web/live/admin/reviews.ex new file mode 100644 index 0000000..0b7883f --- /dev/null +++ b/lib/berrypod_web/live/admin/reviews.ex @@ -0,0 +1,389 @@ +defmodule BerrypodWeb.Admin.Reviews do + @moduledoc """ + Admin interface for moderating product reviews. + """ + + use BerrypodWeb, :live_view + + import BerrypodWeb.ShopComponents.Content, only: [image_lightbox: 1] + + alias Berrypod.Reviews + + @valid_statuses ~w(pending approved rejected) + + @impl true + def mount(_params, _session, socket) do + socket = + socket + |> assign(:page_title, "Reviews") + |> assign(:status_counts, Reviews.count_reviews_by_status()) + |> assign(:expanded_id, nil) + + {:ok, socket} + end + + @impl true + def handle_params(params, _uri, socket) do + status = if params["status"] in @valid_statuses, do: params["status"], else: nil + search = params["search"] + page_num = Berrypod.Pagination.parse_page(params) + + page = + Reviews.list_reviews_paginated( + status: status, + search: search, + page: page_num + ) + + # Batch preload all images for the page in one query + images_by_review = Reviews.preload_review_images(page.items) + + socket = + socket + |> assign(:status, status) + |> assign(:search, search || "") + |> assign(:pagination, page) + |> assign(:reviews, page.items) + |> assign(:images_by_review, images_by_review) + + {:noreply, socket} + end + + @impl true + def handle_event("filter", %{"status" => status}, socket) do + status = if status == "", do: nil, else: status + params = build_params(status: status, search: socket.assigns.search) + {:noreply, push_patch(socket, to: ~p"/admin/reviews?#{params}")} + end + + def handle_event("search", %{"search" => %{"query" => query}}, socket) do + search = if query == "", do: nil, else: query + params = build_params(status: socket.assigns.status, search: search) + {:noreply, push_patch(socket, to: ~p"/admin/reviews?#{params}")} + end + + def handle_event("expand", %{"id" => id}, socket) do + # Toggle: click again to collapse + new_id = if socket.assigns.expanded_id == id, do: nil, else: id + {:noreply, assign(socket, :expanded_id, new_id)} + end + + def handle_event("approve", %{"id" => id}, socket) do + review = Reviews.get_review!(id) + + case Reviews.approve_review(review) do + {:ok, updated} -> + {:noreply, + socket + |> update_review_in_list(updated) + |> update_counts() + |> put_flash(:info, "Review approved")} + + {:error, _changeset} -> + {:noreply, put_flash(socket, :error, "Failed to approve review")} + end + end + + def handle_event("reject", %{"id" => id}, socket) do + review = Reviews.get_review!(id) + + case Reviews.reject_review(review) do + {:ok, updated} -> + {:noreply, + socket + |> update_review_in_list(updated) + |> update_counts() + |> put_flash(:info, "Review rejected")} + + {:error, _changeset} -> + {:noreply, put_flash(socket, :error, "Failed to reject review")} + end + end + + def handle_event("delete", %{"id" => id}, socket) do + review = Reviews.get_review!(id) + + case Reviews.delete_review(review) do + {:ok, _} -> + reviews = Enum.reject(socket.assigns.reviews, &(&1.id == id)) + images_by_review = Map.delete(socket.assigns.images_by_review, id) + + {:noreply, + socket + |> assign(:reviews, reviews) + |> assign(:images_by_review, images_by_review) + |> assign(:expanded_id, nil) + |> update_counts() + |> put_flash(:info, "Review deleted")} + + {:error, _changeset} -> + {:noreply, put_flash(socket, :error, "Failed to delete review")} + end + end + + @impl true + def render(assigns) do + ~H""" + <.header> + Reviews + + +
    + <.status_tab status={nil} label="All" count={total_count(@status_counts)} active={@status} /> + <.status_tab + status="pending" + label="Pending" + count={@status_counts["pending"]} + active={@status} + /> + <.status_tab + status="approved" + label="Approved" + count={@status_counts["approved"]} + active={@status} + /> + <.status_tab + status="rejected" + label="Rejected" + count={@status_counts["rejected"]} + active={@status} + /> +
    + +
    + <.form for={%{}} phx-submit="search" as={:search} class="admin-row"> + + + +
    + +
    +
    + No reviews to show. +
    + <.review_row + :for={review <- @reviews} + id={"review-#{review.id}"} + review={review} + images={@images_by_review[review.id] || []} + expanded={@expanded_id == review.id} + /> +
    + + <.admin_pagination + page={@pagination} + patch={~p"/admin/reviews"} + params={build_params(status: @status, search: @search)} + /> + """ + end + + # ── Components ── + + defp status_tab(assigns) do + active = assigns.status == assigns.active + count = assigns.count || 0 + show_badge = assigns.status == "pending" and count > 0 + + assigns = + assigns + |> assign(:is_active, active) + |> assign(:show_badge, show_badge) + |> assign(:count, count) + + ~H""" + + """ + end + + defp review_row(assigns) do + ~H""" +
    + + +
    +
    +

    {@review.title}

    +

    {@review.body}

    +

    + No written review — rating only. +

    + +
    + + + <.image_lightbox + id={"admin-review-#{@review.id}-lightbox"} + images={Enum.map(@images, &image_url/1)} + caption="Customer photo" + /> +
    +
    + +
    + <.link + navigate={~p"/p/#{@review.product.slug}"} + class="admin-btn admin-btn-sm admin-btn-ghost" + > + View product + + + + +
    +
    +
    + """ + end + + defp status_badge(assigns) do + {class, icon} = + case assigns.status do + "pending" -> {"admin-status-pending", "hero-clock-mini"} + "approved" -> {"admin-status-approved", "hero-check-circle-mini"} + "rejected" -> {"admin-status-rejected", "hero-x-circle-mini"} + _ -> {"", "hero-question-mark-circle-mini"} + end + + assigns = assign(assigns, class: class, icon: icon) + + ~H""" + + <.icon name={@icon} class="size-3.5" /> + {@status} + + """ + end + + # ── Helpers ── + + defp total_count(counts) do + (counts["pending"] || 0) + (counts["approved"] || 0) + (counts["rejected"] || 0) + end + + defp relative_time(datetime) do + now = DateTime.utc_now() + diff = DateTime.diff(now, datetime, :second) + + cond do + diff < 60 -> "just now" + diff < 3600 -> "#{div(diff, 60)}m ago" + diff < 86400 -> "#{div(diff, 3600)}h ago" + diff < 604_800 -> "#{div(diff, 86400)}d ago" + true -> Calendar.strftime(datetime, "%d %b %Y") + end + end + + defp build_params(opts) do + %{} + |> then(fn p -> if opts[:status], do: Map.put(p, "status", opts[:status]), else: p end) + |> then(fn p -> if opts[:search], do: Map.put(p, "search", opts[:search]), else: p end) + end + + defp update_counts(socket) do + assign(socket, :status_counts, Reviews.count_reviews_by_status()) + end + + defp update_review_in_list(socket, updated) do + reviews = + Enum.map(socket.assigns.reviews, fn review -> + if review.id == updated.id, do: %{updated | product: review.product}, else: review + end) + + assign(socket, :reviews, reviews) + end + + defp thumb_url(image) do + if image.variants_status == "complete" do + "/image_cache/#{image.id}-thumb.jpg" + else + "/images/#{image.id}" + end + end + + defp image_url(image) do + if image.variants_status == "complete" do + "/image_cache/#{image.id}-800.webp" + else + "/images/#{image.id}" + end + end +end diff --git a/lib/berrypod_web/live/shop/pages/product.ex b/lib/berrypod_web/live/shop/pages/product.ex index 3ef8fb7..2dc9d0c 100644 --- a/lib/berrypod_web/live/shop/pages/product.ex +++ b/lib/berrypod_web/live/shop/pages/product.ex @@ -47,10 +47,13 @@ defmodule BerrypodWeb.Shop.Pages.Product do ) end - base = BerrypodWeb.Endpoint.url() og_url = R.url(R.product(slug)) og_image = og_image_url(all_images) + # Load review aggregates for JSON-LD (SEO) + {avg_rating, review_count} = Reviews.average_rating_for_product(product.id) + seo_reviews = Reviews.list_reviews_for_product(product.id, limit: 5) + page = Pages.get_page("pdp") is_discontinued = product.status == "discontinued" @@ -61,7 +64,10 @@ defmodule BerrypodWeb.Shop.Pages.Product do |> assign(:og_type, "product") |> assign(:og_url, og_url) |> assign(:og_image, og_image) - |> assign(:json_ld, product_json_ld(product, og_url, og_image, base)) + |> assign( + :json_ld, + product_json_ld(product, og_url, og_image, avg_rating, review_count, seo_reviews) + ) |> assign(:product, product) |> assign(:all_images, all_images) |> assign(:quantity, 1) @@ -137,26 +143,23 @@ defmodule BerrypodWeb.Shop.Pages.Product do def handle_event("request_review", %{"email" => email}, socket) do product = socket.assigns.product + # Always show the same message to prevent email enumeration attacks. + # Attacker shouldn't be able to confirm whether an email has purchased a product. + generic_success = + {:info, "If you've purchased this product, we've sent a verification link to your email."} + case Reviews.request_review_verification(email, product.id, product.title) do {:ok, :sent} -> - {:noreply, - assign( - socket, - :review_status, - {:info, "Check your email for a link to leave your review."} - )} + {:noreply, assign(socket, :review_status, generic_success)} {:error, :no_purchase} -> - {:noreply, - assign( - socket, - :review_status, - {:error, "We couldn't find a matching order for this product."} - )} + # Don't reveal that this email hasn't purchased + {:noreply, assign(socket, :review_status, generic_success)} {:error, :already_reviewed} -> + # This one is safe to reveal - they already have a public review {:noreply, - assign(socket, :review_status, {:error, "You've already reviewed this product."})} + assign(socket, :review_status, {:info, "You've already reviewed this product."})} {:error, _reason} -> {:noreply, @@ -164,6 +167,21 @@ defmodule BerrypodWeb.Shop.Pages.Product do end end + def handle_event("load_more_reviews", _params, socket) do + product = socket.assigns.product + current_reviews = socket.assigns[:reviews] || [] + offset = length(current_reviews) + + # Load the next batch + more_reviews = + Reviews.list_reviews_for_product(product.id, limit: 10, offset: offset) + |> Enum.map(&format_review_for_display/1) + + all_reviews = current_reviews ++ more_reviews + + {:noreply, assign(socket, :reviews, all_reviews)} + end + def handle_event(_event, _params, _socket), do: :cont # ── Review helpers ─────────────────────────────────────────────────── @@ -333,7 +351,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do # ── JSON-LD and meta helpers ───────────────────────────────────────── - defp product_json_ld(product, url, image, _base) do + defp product_json_ld(product, url, image, avg_rating, review_count, reviews) do category_slug = if product.category, do: product.category |> String.downcase() |> String.replace(" ", "-"), @@ -358,27 +376,32 @@ defmodule BerrypodWeb.Shop.Pages.Product do ] |> Enum.reject(&is_nil/1) + product_data = + %{ + "@type" => "Product", + "name" => product.title, + "description" => plain_text(product.description), + "image" => Enum.reject([image], &is_nil/1), + "url" => url, + "offers" => %{ + "@type" => "Offer", + "price" => format_price(product.cheapest_price), + "priceCurrency" => "GBP", + "availability" => + if(product.in_stock, + do: "https://schema.org/InStock", + else: "https://schema.org/OutOfStock" + ), + "url" => url + } + } + |> maybe_add_rating(avg_rating, review_count) + |> maybe_add_reviews(reviews) + data = %{ "@context" => "https://schema.org", "@graph" => [ - %{ - "@type" => "Product", - "name" => product.title, - "description" => plain_text(product.description), - "image" => Enum.reject([image], &is_nil/1), - "url" => url, - "offers" => %{ - "@type" => "Offer", - "price" => format_price(product.cheapest_price), - "priceCurrency" => "GBP", - "availability" => - if(product.in_stock, - do: "https://schema.org/InStock", - else: "https://schema.org/OutOfStock" - ), - "url" => url - } - }, + product_data, %{ "@type" => "BreadcrumbList", "itemListElement" => breadcrumbs @@ -389,6 +412,53 @@ defmodule BerrypodWeb.Shop.Pages.Product do Jason.encode!(data, escape: :html_safe) end + defp maybe_add_rating(product_data, nil, _count), do: product_data + defp maybe_add_rating(product_data, _avg, 0), do: product_data + + defp maybe_add_rating(product_data, avg_rating, review_count) do + Map.put(product_data, "aggregateRating", %{ + "@type" => "AggregateRating", + "ratingValue" => Decimal.to_string(avg_rating), + "reviewCount" => to_string(review_count), + "bestRating" => "5", + "worstRating" => "1" + }) + end + + defp maybe_add_reviews(product_data, []), do: product_data + + defp maybe_add_reviews(product_data, reviews) do + review_data = + Enum.map(reviews, fn review -> + review_item = %{ + "@type" => "Review", + "author" => %{"@type" => "Person", "name" => review.author_name}, + "datePublished" => Date.to_iso8601(DateTime.to_date(review.inserted_at)), + "reviewRating" => %{ + "@type" => "Rating", + "ratingValue" => to_string(review.rating), + "bestRating" => "5", + "worstRating" => "1" + } + } + + review_item = + if review.body && review.body != "" do + Map.put(review_item, "reviewBody", review.body) + else + review_item + end + + if review.title && review.title != "" do + Map.put(review_item, "name", review.title) + else + review_item + end + end) + + Map.put(product_data, "review", review_data) + end + defp format_price(pence) when is_integer(pence) do "#{div(pence, 100)}.#{String.pad_leading(to_string(rem(pence, 100)), 2, "0")}" end @@ -417,4 +487,31 @@ defmodule BerrypodWeb.Shop.Pages.Product do |> Kernel.<>("…") end end + + defp format_review_for_display(review) do + %{ + id: review.id, + rating: review.rating, + title: review.title, + body: review.body, + author: review.author_name, + date: relative_time(review.inserted_at), + verified: not is_nil(review.order_id), + images: Reviews.get_review_images(review) + } + end + + defp relative_time(datetime) do + now = DateTime.utc_now() + diff = DateTime.diff(now, datetime, :second) + + cond do + diff < 60 -> "just now" + diff < 3600 -> "#{div(diff, 60)} minutes ago" + diff < 86400 -> "#{div(diff, 3600)} hours ago" + diff < 604_800 -> "#{div(diff, 86400)} days ago" + diff < 2_592_000 -> "#{div(diff, 604_800)} weeks ago" + true -> "#{div(diff, 2_592_000)} months ago" + end + end end diff --git a/lib/berrypod_web/live/shop/review_form.ex b/lib/berrypod_web/live/shop/review_form.ex index e8417e2..db96376 100644 --- a/lib/berrypod_web/live/shop/review_form.ex +++ b/lib/berrypod_web/live/shop/review_form.ex @@ -8,9 +8,12 @@ defmodule BerrypodWeb.Shop.ReviewForm do use BerrypodWeb, :live_view - alias Berrypod.{Products, Reviews} + alias Berrypod.{Media, Products, Reviews} alias Berrypod.Reviews.Review + @max_photos 3 + @max_file_size 10_000_000 + @impl true def mount(_params, session, socket) do email_session = session["email_session"] @@ -24,6 +27,13 @@ defmodule BerrypodWeb.Shop.ReviewForm do |> assign(:form, nil) |> assign(:submitted, false) |> assign(:error, nil) + |> assign(:existing_images, []) + |> assign(:removed_image_ids, []) + |> allow_upload(:photos, + accept: ~w(.jpg .jpeg .png .webp .heic), + max_entries: @max_photos, + max_file_size: @max_file_size + ) {:ok, socket} end @@ -98,11 +108,15 @@ defmodule BerrypodWeb.Shop.ReviewForm do changeset = Review.update_changeset(review, %{}) + # Load existing images for display + existing_images = Reviews.get_review_images(review) + socket |> assign(:review, review) |> assign(:product, product) |> assign(:form, to_form(changeset)) |> assign(:page_title, "Edit your review") + |> assign(:existing_images, existing_images) else assign(socket, :error, "You don't have permission to edit this review.") end @@ -144,11 +158,30 @@ defmodule BerrypodWeb.Shop.ReviewForm do {:noreply, assign(socket, :form, to_form(changeset))} end + def handle_event("cancel_upload", %{"ref" => ref}, socket) do + {:noreply, cancel_upload(socket, :photos, ref)} + end + + def handle_event("remove_image", %{"id" => image_id}, socket) do + removed = [image_id | socket.assigns.removed_image_ids] + existing = Enum.reject(socket.assigns.existing_images, &(&1.id == image_id)) + + {:noreply, + socket + |> assign(:removed_image_ids, removed) + |> assign(:existing_images, existing)} + end + defp create_review(socket, params) do + # Consume uploads at submit time - they're ready since auto_upload: true + # means files are uploaded as soon as selected + image_ids = consume_photo_uploads(socket) + params = params |> Map.put("product_id", socket.assigns.product.id) |> Map.put("email", socket.assigns.email) + |> Map.put("image_ids", image_ids) case Reviews.create_review(params) do {:ok, _review} -> @@ -163,6 +196,19 @@ defmodule BerrypodWeb.Shop.ReviewForm do end defp update_review(socket, params) do + # Consume uploads at submit time + new_image_ids = consume_photo_uploads(socket) + + # Keep existing images that weren't removed, plus new uploads + kept_ids = + socket.assigns.existing_images + |> Enum.map(& &1.id) + |> Enum.reject(&(&1 in socket.assigns.removed_image_ids)) + + all_image_ids = kept_ids ++ new_image_ids + + params = Map.put(params, "image_ids", all_image_ids) + case Reviews.update_review(socket.assigns.review, params) do {:ok, _review} -> {:noreply, @@ -175,6 +221,19 @@ defmodule BerrypodWeb.Shop.ReviewForm do end end + # Consume uploaded photos and save them to the database. + # Returns a list of image IDs for the successfully saved images. + # Uses async variant to avoid blocking - image processing happens in Oban. + defp consume_photo_uploads(socket) do + consume_uploaded_entries(socket, :photos, fn %{path: path}, entry -> + case Media.upload_from_entry_async(path, entry, "review") do + {:ok, image} -> {:ok, image.id} + {:error, _reason} -> {:ok, nil} + end + end) + |> Enum.reject(&is_nil/1) + end + @impl true def render(assigns) do ~H""" @@ -185,7 +244,13 @@ defmodule BerrypodWeb.Shop.ReviewForm do <% @error -> %> <.error_message error={@error} product={@product} review={@review} /> <% @form -> %> - <.review_form form={@form} product={@product} review={@review} /> + <.review_form + form={@form} + product={@product} + review={@review} + uploads={@uploads} + existing_images={@existing_images} + /> <% true -> %>

    Loading...

    <% end %> @@ -223,23 +288,19 @@ defmodule BerrypodWeb.Shop.ReviewForm do

    Unable to leave review

    {@error}

    - <%= if @product do %> - <.link href={BerrypodWeb.R.product(@product.slug)} class="review-back-link"> - Back to {@product.title} - - <%= if @review do %> - <.link - href={"/reviews/#{@review.id}/edit?product=#{@product.slug}"} - class="review-edit-link" - > - Edit your existing review - - <% end %> - <% else %> - <.link href={BerrypodWeb.R.home()} class="review-back-link"> - Back to shop - - <% end %> + <.link :if={@product} href={BerrypodWeb.R.product(@product.slug)} class="review-back-link"> + Back to {@product.title} + + <.link + :if={@product && @review} + href={"/reviews/#{@review.id}/edit?product=#{@product.slug}"} + class="review-edit-link" + > + Edit your existing review + + <.link :if={!@product} href={BerrypodWeb.R.home()} class="review-back-link"> + Back to shop +
    """ end @@ -255,6 +316,7 @@ defmodule BerrypodWeb.Shop.ReviewForm do <.form for={@form} phx-change="validate" phx-submit="save" class="review-form">
    + <.star_rating_input rating={@form[:rating].value} />

    + <.photo_upload_section uploads={@uploads} existing_images={@existing_images} /> + @@ -330,28 +394,140 @@ defmodule BerrypodWeb.Shop.ReviewForm do """ end - defp star_rating_input(assigns) do - rating = assigns.rating - rating = if is_binary(rating), do: String.to_integer(rating), else: rating + defp photo_upload_section(assigns) do + # Count total photos (existing + pending uploads) + total_photos = length(assigns.existing_images) + length(assigns.uploads.photos.entries) + can_add_more = total_photos < 3 - assigns = assign(assigns, :rating, rating) + assigns = assign(assigns, :can_add_more, can_add_more) ~H""" -
    - <%= for i <- 1..5 do %> - +
    + + <%!-- Pending upload entries (uploaded to server, consumed on submit) --%> +
    + <.live_img_preview entry={entry} /> + +
    0 and entry.progress < 100} class="review-photo-progress"> +
    +
    +
    + + <.live_file_input upload={@uploads.photos} class="sr-only" /> + + + +

    + {entry.client_name}: not a supported image type +

    +

    + {upload_error_to_string(err)} +

    + +

    You can add up to 3 photos (max 10MB each)

    """ end + + defp upload_error_to_string(:too_large), do: "File is too large (max 10MB)" + defp upload_error_to_string(:too_many_files), do: "Too many files (max 3)" + defp upload_error_to_string(:not_accepted), do: "File type not supported" + defp upload_error_to_string(_), do: "Upload error" + + defp image_thumb_url(image) do + if image.variants_status == "complete" do + "/image_cache/#{image.id}-thumb.jpg" + else + "/images/#{image.id}" + end + end + + defp star_rating_input(assigns) do + assigns = assign(assigns, :rating, parse_rating(assigns.rating)) + + ~H""" +
    + +
    + """ + end + + defp parse_rating(""), do: nil + defp parse_rating(s) when is_binary(s), do: String.to_integer(s) + defp parse_rating(rating), do: rating end diff --git a/lib/berrypod_web/router.ex b/lib/berrypod_web/router.ex index 08b4ceb..b690368 100644 --- a/lib/berrypod_web/router.ex +++ b/lib/berrypod_web/router.ex @@ -177,6 +177,7 @@ defmodule BerrypodWeb.Router do live "/pages/:slug/settings", Admin.Pages.CustomForm, :edit live "/pages/:slug", Admin.Pages.Editor, :edit live "/media", Admin.Media, :index + live "/reviews", Admin.Reviews, :index live "/newsletter", Admin.Newsletter, :index live "/newsletter/campaigns/new", Admin.Newsletter.CampaignForm, :new live "/newsletter/campaigns/:id", Admin.Newsletter.CampaignForm, :edit diff --git a/priv/repo/migrations/20260401221813_add_rating_cache_to_products.exs b/priv/repo/migrations/20260401221813_add_rating_cache_to_products.exs new file mode 100644 index 0000000..9a90b37 --- /dev/null +++ b/priv/repo/migrations/20260401221813_add_rating_cache_to_products.exs @@ -0,0 +1,10 @@ +defmodule Berrypod.Repo.Migrations.AddRatingCacheToProducts do + use Ecto.Migration + + def change do + alter table(:products) do + add :rating_avg, :decimal, precision: 2, scale: 1 + add :rating_count, :integer, default: 0 + end + end +end diff --git a/test/berrypod/media_test.exs b/test/berrypod/media_test.exs index 5da4d00..c67d622 100644 --- a/test/berrypod/media_test.exs +++ b/test/berrypod/media_test.exs @@ -53,9 +53,9 @@ defmodule Berrypod.MediaTest do end test "validates file size" do - attrs = Map.put(@valid_attrs, :file_size, 10_000_000) + attrs = Map.put(@valid_attrs, :file_size, 15_000_000) assert {:error, changeset} = Media.upload_image(attrs) - assert "must be less than 5000000" in errors_on(changeset).file_size + assert "must be less than 10000000" in errors_on(changeset).file_size end end