{@review.title}
+{@review.body}
++ No written review — rating only. +
+ +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 +
{@message}
+
{@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""" +
{@review.body}
++ No written review — rating only. +
+ +Loading...
<% end %> @@ -223,23 +288,19 @@ defmodule BerrypodWeb.Shop.ReviewForm do{@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 +