complete reviews system (phases 4-6)
All checks were successful
deploy / deploy (push) Successful in 1m4s

- 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 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-04-01 22:41:27 +01:00
parent 32eb0c6758
commit 6d2d0c9941
26 changed files with 2155 additions and 157 deletions

View File

@@ -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) - Fully Tailwind-free CSS (12 KB gzipped shop+theme, 95 KB gzipped admin total)
- CI pipeline (compile warnings, format, credo, dialyzer, tests) - CI pipeline (compile warnings, format, credo, dialyzer, tests)
- Deployed on Fly.io with observability (LiveDashboard, ErrorTracker, structured logging) - 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 ## Next up
@@ -120,11 +121,11 @@ Close critical gaps identified in the competitive analysis. Phased approach: cor
| 81 | PayPal SDK integration | — | 2h | planned | | 81 | PayPal SDK integration | — | 2h | planned |
| 82 | PayPal checkout flow | 81 | 3h | planned | | 82 | PayPal checkout flow | 81 | 3h | planned |
| 83 | PayPal webhooks | 82 | 1.5h | planned | | 83 | PayPal webhooks | 82 | 1.5h | planned |
| 84 | Reviews schema | — | 1.5h | planned | | 84 | Reviews schema | — | 1.5h | done |
| 85 | Review submission | 84 | 2h | planned | | 85 | Review submission | 84 | 2h | done |
| 86 | Review moderation | 84 | 1.5h | planned | | 86 | Review moderation | 84 | 1.5h | done |
| 87 | Reviews display | 84 | 1.5h | planned | | 87 | Reviews display | 84 | 1.5h | done |
| 88 | Review schema markup | 87 | 1h | planned | | 88 | Review schema markup + rating cache + request emails | 87 | 2h | done |
| 89 | Provider stock sync | — | — | done (already existed) | | 89 | Provider stock sync | — | — | done (already existed) |
| 90 | Availability display | 89 | 1h | done | | 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 | | [seo-enhancements.md](docs/plans/seo-enhancements.md) | Planned |
| [competitive-gap-analysis.md](docs/plans/competitive-gap-analysis.md) | Reference | | [competitive-gap-analysis.md](docs/plans/competitive-gap-analysis.md) | Reference |
| [competitive-gaps.md](docs/plans/competitive-gaps.md) | Planned | | [competitive-gaps.md](docs/plans/competitive-gaps.md) | Planned |
| [reviews-system.md](docs/plans/reviews-system.md) | Complete |

View File

@@ -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: Complete storefront with all the pages you need:
- **Home** — hero banner, category navigation, featured products, newsletter - **Home** — hero banner, category navigation, featured products, newsletter
- **Products** — grid layout with hover effects, sorting, filtering by collection - **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 - **Cart** — drawer + full page, quantity controls, shipping estimate, cross-tab sync
- **Checkout** — Stripe-hosted checkout with shipping costs, order confirmation - **Checkout** — Stripe-hosted checkout with shipping costs, order confirmation
- **Custom pages** — CMS pages at any URL with 26 block types - **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) - Image optimisation pipeline (AVIF/WebP/JPEG responsive variants via Oban)
- ETS caching for CSS, pages, redirects, favicons - ETS caching for CSS, pages, redirects, favicons
- 99-100 PageSpeed mobile, no-JS support across all key flows - 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 ## Getting started

View File

@@ -4,7 +4,7 @@
## What's done ## 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. 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) - Pre-checkout variant validation (verify provider availability)
- Cost change monitoring/alerts (warn if provider costs increased) - Cost change monitoring/alerts (warn if provider costs increased)
- Better image gallery (zoom, multiple angles) - 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 ## Platform vision

View File

@@ -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 */ } /* @layer admin */

View File

@@ -130,6 +130,18 @@
margin-top: 0.25rem; 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 prices (shared between cards and PDP) ── */
.product-price--sale { .product-price--sale {
@@ -2393,7 +2405,6 @@
.review-body { .review-body {
font-size: var(--t-text-small); font-size: var(--t-text-small);
margin-bottom: 0.75rem;
color: var(--t-text-secondary); color: var(--t-text-secondary);
line-height: 1.6; line-height: 1.6;
} }
@@ -2402,6 +2413,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
margin-top: 0.75rem;
} }
.review-author { .review-author {
@@ -2411,11 +2423,468 @@
} }
.review-verified { .review-verified {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: var(--t-text-caption); font-size: var(--t-text-caption);
padding: 0.125rem 0.5rem; padding: 0.125rem 0.5rem;
border-radius: var(--t-radius-sm, 4px); border-radius: var(--t-radius-sm, 4px);
background-color: var(--t-surface-sunken); background-color: var(--t-surface-sunken);
color: var(--t-text-tertiary); 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 ── */ /* ── Rich text ── */

View File

@@ -205,12 +205,13 @@ const CartDrawer = {
} }
} }
// Hook for PDP image lightbox // Hook for image lightbox (used by product gallery and review photos)
const Lightbox = { const Lightbox = {
mounted() { mounted() {
const dialog = this.el const dialog = this.el
const lightboxImage = dialog.querySelector('#lightbox-image') // Use class selectors so multiple lightboxes can coexist
const lightboxCounter = dialog.querySelector('#lightbox-counter') const lightboxImage = dialog.querySelector('.lightbox-image')
const lightboxCounter = dialog.querySelector('.lightbox-counter')
// Get images from data attribute // Get images from data attribute
const getImages = () => { const getImages = () => {
@@ -258,11 +259,15 @@ const Lightbox = {
dialog.close() 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:open-lightbox', openLightbox)
dialog.addEventListener('pdp:close-lightbox', closeLightbox) dialog.addEventListener('pdp:close-lightbox', closeLightbox)
dialog.addEventListener('pdp:next-image', nextImage) dialog.addEventListener('pdp:next-image', nextImage)
dialog.addEventListener('pdp:prev-image', prevImage) 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 // Close on clicking backdrop
dialog.addEventListener('click', (e) => { dialog.addEventListener('click', (e) => {
@@ -288,6 +293,10 @@ const Lightbox = {
dialog.removeEventListener('pdp:close-lightbox', closeLightbox) dialog.removeEventListener('pdp:close-lightbox', closeLightbox)
dialog.removeEventListener('pdp:next-image', nextImage) dialog.removeEventListener('pdp:next-image', nextImage)
dialog.removeEventListener('pdp:prev-image', prevImage) 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)
} }
}, },

View File

@@ -1,6 +1,6 @@
# Product reviews system # Product reviews system
Status: In Progress (Phase 1-3 complete) Status: Complete
## Overview ## Overview
@@ -447,48 +447,48 @@ Only include if product has approved reviews.
- "Write a review" / "Edit review" buttons - "Write a review" / "Edit review" buttons
- Link to review form - 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 - Generic lightbox from product_lightbox
- Same JS hook, just cleaner component interface - Same JS hook, just cleaner component interface
9. **Review photos component** (0.5h) 9. **Review photos component** (0.5h)
- Inline thumbnails - Inline thumbnails with URL building from Image structs
- Opens image_lightbox on click - Opens image_lightbox on click
10. **Product page display** (1.5h) 10. **Product page display** (1.5h)
- Update reviews_section to use real data - Updated review_card to include photos and verified badge with icon
- Verified purchase badge - Verified purchase badge with checkmark SVG
- Review photos display - Review photos display via review_photos component
- Pagination - 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) - Reviews list with tabs (pending/approved/rejected)
- Photo thumbnails in list - Photo thumbnails in list
- Expand to see full review + photos - Expand to see full review + photos
- Approve/reject actions - Approve/reject actions
12. **Nav and notifications** (0.5h) 12. **Nav and notifications** (0.5h)
- Pending count badge in admin nav - Pending count badge in admin nav
- Optional: email to admin on new review - 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 - Oban job for post-delivery requests
- Email template - 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 - AggregateRating on product pages
- Individual Review markup - Individual Review markup
15. **Rating cache on products** (0.5h) 15. **Rating cache on products** (0.5h)
- Add rating_avg, rating_count to products - Add rating_avg, rating_count to products
- Update on review approval - Update on review approval/rejection/deletion
- Display on product cards - Display on product cards
--- ---

View File

@@ -69,6 +69,10 @@ defmodule Berrypod.Images.Optimizer do
@doc """ @doc """
Process image and generate all applicable variants. Process image and generate all applicable variants.
Called by Oban worker. 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 def process_for_image(image_id) do
# Load the image row and release the DB connection immediately, # Load the image row and release the DB connection immediately,
@@ -89,20 +93,58 @@ defmodule Berrypod.Images.Optimizer do
%{data: data} when byte_size(data) < @min_image_bytes -> %{data: data} when byte_size(data) < @min_image_bytes ->
{:error, :too_small} {:error, :too_small}
%{data: data, source_width: nil} ->
# Raw image that needs conversion first
process_raw_image(image, data)
%{data: data, source_width: width} -> %{data: data, source_width: width} ->
# Already converted to WebP with dimensions
process_converted_image(image, data, width)
end
end
# 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
})
)
# Now generate variants with the converted data
process_converted_image(updated, webp_data, width)
{: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()) File.mkdir_p!(cache_dir())
# Write source WebP to disk so it can be served by Plug.Static # Write source WebP to disk so it can be served by Plug.Static
source_path = Path.join(cache_dir(), "#{image_id}.webp") source_path = Path.join(cache_dir(), "#{image.id}.webp")
unless File.exists?(source_path), do: File.write!(source_path, data) unless File.exists?(source_path), do: File.write!(source_path, data)
with {:ok, vips_image} <- Image.from_binary(data) do with {:ok, vips_image} <- Image.from_binary(data) do
widths = applicable_widths(width) widths = applicable_widths(width)
all_tasks = all_tasks =
[fn -> generate_thumbnail(vips_image, image_id) end] ++ [fn -> generate_thumbnail(vips_image, image.id) end] ++
for w <- widths, fmt <- @pregenerated_formats do for w <- widths, fmt <- @pregenerated_formats do
fn -> generate_variant(vips_image, image_id, w, fmt) end fn -> generate_variant(vips_image, image.id, w, fmt) end
end end
# Cap concurrency to the number of CPU cores — keeps small # Cap concurrency to the number of CPU cores — keeps small
@@ -120,7 +162,6 @@ defmodule Berrypod.Images.Optimizer do
{:ok, widths} {:ok, widths}
end end
end end
end
# Extract and store dominant colors for header images. # Extract and store dominant colors for header images.
# Used to calculate text contrast warnings in the theme editor. # Used to calculate text contrast warnings in the theme editor.

View File

@@ -118,6 +118,43 @@ defmodule Berrypod.Media do
upload_image(Map.merge(base, extra_attrs)) upload_image(Map.merge(base, extra_attrs))
end 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 """ @doc """
Gets a single image by ID. Gets a single image by ID.

View File

@@ -24,7 +24,7 @@ defmodule Berrypod.Media.Image do
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@max_file_size 5_000_000 @max_file_size 10_000_000
@doc false @doc false
def changeset(image, attrs) do def changeset(image, attrs) do
@@ -46,7 +46,7 @@ defmodule Berrypod.Media.Image do
:dominant_colors :dominant_colors
]) ])
|> validate_required([:image_type, :filename, :content_type, :file_size, :data]) |> 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) |> validate_number(:file_size, less_than: @max_file_size)
|> detect_svg() |> detect_svg()
end end

View File

@@ -272,6 +272,10 @@ defmodule Berrypod.Orders do
OrderNotifier.deliver_shipping_notification(updated_order) OrderNotifier.deliver_shipping_notification(updated_order)
end end
if attrs[:fulfilment_status] == "delivered" and order.fulfilment_status != "delivered" do
Berrypod.Reviews.ReviewRequestWorker.enqueue(updated_order.id)
end
{:ok, updated_order} {:ok, updated_order}
end end
end end

View File

@@ -490,7 +490,8 @@ defmodule Berrypod.Pages.BlockTypes do
body: review.body, body: review.body,
author: review.author_name, author: review.author_name,
date: relative_time(review.inserted_at), 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 end

View File

@@ -31,6 +31,10 @@ defmodule Berrypod.Products.Product do
field :in_stock, :boolean, default: true field :in_stock, :boolean, default: true
field :on_sale, :boolean, default: false 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 belongs_to :provider_connection, Berrypod.Products.ProviderConnection
has_many :images, Berrypod.Products.ProductImage has_many :images, Berrypod.Products.ProductImage
has_many :variants, Berrypod.Products.ProductVariant has_many :variants, Berrypod.Products.ProductVariant

View File

@@ -44,10 +44,16 @@ defmodule Berrypod.Reviews do
@doc """ @doc """
Lists approved reviews for a product, newest first. 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 def list_reviews_for_product(product_id, opts \\ []) do
status = Keyword.get(opts, :status, "approved") status = Keyword.get(opts, :status, "approved")
limit = Keyword.get(opts, :limit) limit = Keyword.get(opts, :limit)
offset = Keyword.get(opts, :offset, 0)
query = query =
from r in Review, from r in Review,
@@ -55,6 +61,7 @@ defmodule Berrypod.Reviews do
order_by: [desc: r.inserted_at] order_by: [desc: r.inserted_at]
query = if limit, do: limit(query, ^limit), else: query query = if limit, do: limit(query, ^limit), else: query
query = if offset > 0, do: offset(query, ^offset), else: query
Repo.all(query) Repo.all(query)
end end
@@ -94,6 +101,57 @@ defmodule Berrypod.Reviews do
Repo.one(from r in Review, where: r.status == "pending", select: count(r.id)) Repo.one(from r in Review, where: r.status == "pending", select: count(r.id))
end 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 ───────────────────────────────────────────────────── # ── Aggregates ─────────────────────────────────────────────────────
@doc """ @doc """
@@ -169,7 +227,15 @@ defmodule Berrypod.Reviews do
# Auto-link to matching order for "verified purchase" badge # Auto-link to matching order for "verified purchase" badge
order = if email && product_id, do: find_matching_order(email, product_id), else: nil 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{}
|> Review.changeset(attrs) |> Review.changeset(attrs)
@@ -188,27 +254,64 @@ defmodule Berrypod.Reviews do
@doc """ @doc """
Approves a review for public display. Approves a review for public display.
Updates the product's rating cache.
""" """
def approve_review(%Review{} = review) do def approve_review(%Review{} = review) do
result =
review review
|> Review.moderation_changeset("approved") |> Review.moderation_changeset("approved")
|> Repo.update() |> Repo.update()
case result do
{:ok, updated} ->
update_product_rating_cache(updated.product_id)
{:ok, updated}
error ->
error
end
end end
@doc """ @doc """
Rejects a review (won't be displayed). 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 def reject_review(%Review{} = review) do
was_approved = review.status == "approved"
result =
review review
|> Review.moderation_changeset("rejected") |> Review.moderation_changeset("rejected")
|> Repo.update() |> Repo.update()
case result do
{:ok, updated} ->
if was_approved, do: update_product_rating_cache(updated.product_id)
{:ok, updated}
error ->
error
end
end end
@doc """ @doc """
Deletes a review. Deletes a review.
Updates the product's rating cache if the review was approved.
""" """
def delete_review(%Review{} = review) do 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 end
# ── Images ───────────────────────────────────────────────────────── # ── Images ─────────────────────────────────────────────────────────
@@ -223,6 +326,35 @@ defmodule Berrypod.Reviews do
def get_review_images(_), 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 tokens ──────────────────────────────────────────────────
@review_salt "review_verification_v1" @review_salt "review_verification_v1"
@@ -293,6 +425,25 @@ defmodule Berrypod.Reviews do
end end
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 ──────────────────────────────────────────────────────── # ── Helpers ────────────────────────────────────────────────────────
defp normalise_email(email) when is_binary(email) do defp normalise_email(email) when is_binary(email) do

View File

@@ -39,6 +39,52 @@ defmodule Berrypod.Reviews.ReviewNotifier do
deliver(email, subject, body) deliver(email, subject, body)
end 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 --- # --- Private ---
defp deliver(recipient, subject, body) do defp deliver(recipient, subject, body) do

View File

@@ -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

View File

@@ -5,7 +5,7 @@ defmodule BerrypodWeb.AdminLayoutHook do
""" """
import Phoenix.Component import Phoenix.Component
alias Berrypod.{ActivityLog, Settings} alias Berrypod.{ActivityLog, Reviews, Settings}
alias Berrypod.Theme.{CSSCache, CSSGenerator} alias Berrypod.Theme.{CSSCache, CSSGenerator}
def on_mount(:assign_current_path, _params, _session, socket) do 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(:site_description, Settings.site_description())
|> assign(:generated_css, generated_css) |> assign(:generated_css, generated_css)
|> assign(:attention_count, ActivityLog.count_needing_attention()) |> 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, |> Phoenix.LiveView.attach_hook(:set_current_path, :handle_params, fn _params,
uri, uri,
socket -> socket ->

View File

@@ -126,6 +126,20 @@
<.icon name="hero-photo" class="size-5" /> Media <.icon name="hero-photo" class="size-5" /> Media
</.link> </.link>
</li> </li>
<li>
<.link
navigate={~p"/admin/reviews"}
class={admin_nav_active?(@current_path, "/admin/reviews")}
>
<.icon name="hero-star" class="size-5" /> Reviews
<span
:if={@pending_review_count > 0}
class="admin-badge admin-badge-sm admin-badge-warning ml-auto"
>
{@pending_review_count}
</span>
</.link>
</li>
<li> <li>
<.link <.link
navigate={~p"/admin/newsletter"} navigate={~p"/admin/newsletter"}

View File

@@ -951,18 +951,189 @@ defmodule BerrypodWeb.ShopComponents.Content do
""" """
end 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"""
<dialog
class="lightbox"
id={@id}
aria-label="Image gallery"
data-current-index="0"
data-images={Jason.encode!(@images)}
data-show={Phoenix.LiveView.JS.exec("phx-show-lightbox", to: "##{@id}")}
phx-show-lightbox={Phoenix.LiveView.JS.dispatch("lightbox:open", to: "##{@id}")}
phx-hook="Lightbox"
>
<div class="lightbox-content">
<button
type="button"
class="lightbox-close"
aria-label="Close gallery"
phx-click={Phoenix.LiveView.JS.dispatch("lightbox:close", to: "##{@id}")}
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
<button
type="button"
class="lightbox-nav lightbox-prev"
aria-label="Previous image"
phx-click={Phoenix.LiveView.JS.dispatch("lightbox:prev", to: "##{@id}")}
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<figure class="lightbox-figure">
<div class="lightbox-image-container">
<img
class="lightbox-image"
src={List.first(@images)}
alt={@caption || "Image"}
width="1200"
height="1200"
/>
</div>
<figcaption :if={@caption} class="lightbox-caption">{@caption}</figcaption>
</figure>
<button
type="button"
class="lightbox-nav lightbox-next"
aria-label="Next image"
phx-click={Phoenix.LiveView.JS.dispatch("lightbox:next", to: "##{@id}")}
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
<div :if={length(@images) > 1} class="lightbox-counter">1 / {length(@images)}</div>
</div>
</dialog>
"""
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"""
<div :if={@images != []} class="review-photos">
<button
:for={{thumb, idx} <- Enum.with_index(@thumb_urls)}
type="button"
class="review-photo-thumb"
phx-click={
Phoenix.LiveView.JS.exec("data-show", to: "#review-#{@review_id}-lightbox")
|> Phoenix.LiveView.JS.set_attribute(
{"data-current-index", to_string(idx)},
to: "#review-#{@review_id}-lightbox"
)
}
>
<img src={thumb} alt="Customer photo" loading="lazy" />
</button>
<.image_lightbox
id={"review-#{@review_id}-lightbox"}
images={@full_urls}
caption="Customer photo"
/>
</div>
"""
end
@doc """ @doc """
Renders a customer reviews section with collapsible header and review cards. Renders a customer reviews section with collapsible header and review cards.
## Attributes ## Attributes
* `reviews` - Required. List of review maps with: * `reviews` - Required. List of review maps with:
- `id` - Review ID
- `rating` - Star rating (1-5) - `rating` - Star rating (1-5)
- `title` - Review title - `title` - Review title
- `body` - Review text - `body` - Review text
- `author` - Reviewer name - `author` - Reviewer name
- `date` - Relative date string (e.g., "2 weeks ago") - `date` - Relative date string (e.g., "2 weeks ago")
- `verified` - Boolean, if true shows "Verified purchase" badge - `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. * `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. * `total_count` - Optional. Total number of reviews. Defaults to length of reviews list.
* `open` - Optional. Whether section is expanded by default. Defaults to true. * `open` - Optional. Whether section is expanded by default. Defaults to true.
@@ -1038,7 +1209,11 @@ defmodule BerrypodWeb.ShopComponents.Content do
<% end %> <% end %>
</div> </div>
<.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 Load more reviews
</.shop_button_outline> </.shop_button_outline>
<% else %> <% else %>
@@ -1121,7 +1296,37 @@ defmodule BerrypodWeb.ShopComponents.Content do
~H""" ~H"""
<div class={"review-status review-status-#{@type}"}> <div class={"review-status review-status-#{@type}"}>
{@message} <svg
:if={@type == :info}
class="review-status-icon"
width="20"
height="20"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
<svg
:if={@type == :error}
class="review-status-icon"
width="20"
height="20"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"
/>
</svg>
<p>{@message}</p>
</div> </div>
""" """
end end
@@ -1131,11 +1336,11 @@ defmodule BerrypodWeb.ShopComponents.Content do
## Attributes ## 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 ## 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 attr :review, :map, required: true
@@ -1146,19 +1351,33 @@ defmodule BerrypodWeb.ShopComponents.Content do
<.star_rating rating={@review.rating} /> <.star_rating rating={@review.rating} />
<span class="review-date">{@review.date}</span> <span class="review-date">{@review.date}</span>
</div> </div>
<h3 class="review-title">{@review.title}</h3> <h3 :if={@review.title} class="review-title">{@review.title}</h3>
<p class="review-body"> <p :if={@review.body} class="review-body">
{@review.body} {@review.body}
</p> </p>
<.review_photos
:if={@review[:images] && @review.images != []}
images={@review.images}
review_id={@review.id}
/>
<div class="review-footer"> <div class="review-footer">
<span class="review-author"> <span class="review-author">
{@review.author} {@review.author}
</span> </span>
<%= if @review.verified do %> <span :if={@review.verified} class="review-verified">
<span class="review-verified"> <svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
Verified purchase Verified purchase
</span> </span>
<% end %>
</div> </div>
</article> </article>
""" """

View File

@@ -2,7 +2,7 @@ defmodule BerrypodWeb.ShopComponents.Product do
use Phoenix.Component use Phoenix.Component
import BerrypodWeb.ShopComponents.Base 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 Berrypod.Products.{Product, ProductImage}
alias BerrypodWeb.R alias BerrypodWeb.R
@@ -191,6 +191,7 @@ defmodule BerrypodWeb.ShopComponents.Product do
<%= if @theme_settings.show_prices do %> <%= if @theme_settings.show_prices do %>
<.product_price product={@product} variant={@variant} /> <.product_price product={@product} variant={@variant} />
<% end %> <% end %>
<.product_card_rating product={@product} />
<%= if @show_delivery_text do %> <%= if @show_delivery_text do %>
<p class="product-card-delivery"> <p class="product-card-delivery">
Made to order Made to order
@@ -356,6 +357,33 @@ defmodule BerrypodWeb.ShopComponents.Product do
""" """
end 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"""
<div :if={@rating_count > 0} class="product-card-rating">
<.star_rating rating={@rating_rounded} size={:sm} />
<span class="product-card-rating-count">({@rating_count})</span>
</div>
"""
end
defp variant_defaults(:default), defp variant_defaults(:default),
do: %{show_category: true, show_badges: true, show_delivery_text: true, clickable: true} do: %{show_category: true, show_badges: true, show_delivery_text: true, clickable: true}
@@ -1260,7 +1288,6 @@ defmodule BerrypodWeb.ShopComponents.Product do
<div class="lightbox-image-container"> <div class="lightbox-image-container">
<img <img
class="lightbox-image" class="lightbox-image"
id="lightbox-image"
src={List.first(@images)} src={List.first(@images)}
alt={@product_name} alt={@product_name}
width="1200" width="1200"
@@ -1286,7 +1313,7 @@ defmodule BerrypodWeb.ShopComponents.Product do
<polyline points="9 18 15 12 9 6"></polyline> <polyline points="9 18 15 12 9 6"></polyline>
</svg> </svg>
</button> </button>
<div class="lightbox-counter" id="lightbox-counter">1 / {length(@images)}</div> <div class="lightbox-counter">1 / {length(@images)}</div>
</div> </div>
</dialog> </dialog>
""" """

View File

@@ -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
</.header>
<div class="admin-filter-row">
<.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}
/>
</div>
<div class="admin-filter-row admin-filter-row-end">
<.form for={%{}} phx-submit="search" as={:search} class="admin-row">
<input
type="text"
name="search[query]"
value={@search}
placeholder="Search reviews"
class="admin-input admin-input-sm"
/>
<button type="submit" class="admin-btn admin-btn-sm admin-btn-ghost">
<.icon name="hero-magnifying-glass-mini" class="size-4" />
</button>
</.form>
</div>
<div id="reviews-list" class="admin-review-list">
<div :if={@reviews == []} class="admin-stream-empty">
No reviews to show.
</div>
<.review_row
:for={review <- @reviews}
id={"review-#{review.id}"}
review={review}
images={@images_by_review[review.id] || []}
expanded={@expanded_id == review.id}
/>
</div>
<.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"""
<button
phx-click="filter"
phx-value-status={@status || ""}
class={[
"admin-btn admin-btn-sm",
@is_active && "admin-btn-primary",
!@is_active && "admin-btn-ghost"
]}
>
{@label}
<span
:if={@show_badge}
class="admin-badge admin-badge-sm admin-badge-warning admin-badge-count"
>
{@count}
</span>
</button>
"""
end
defp review_row(assigns) do
~H"""
<article id={@id} class={["admin-review-row", @expanded && "admin-review-row-expanded"]}>
<button type="button" class="admin-review-header" phx-click="expand" phx-value-id={@review.id}>
<div class="admin-review-meta">
<.status_badge status={@review.status} />
<span class="admin-review-product">{@review.product.title}</span>
<span class="admin-review-rating">
<.icon name="hero-star-solid" class="size-4 admin-star" />
{@review.rating}
</span>
</div>
<div class="admin-review-info">
<span class="admin-review-author">{@review.author_name}</span>
<span class="admin-review-email">{@review.email}</span>
<time class="admin-review-date" datetime={DateTime.to_iso8601(@review.inserted_at)}>
{relative_time(@review.inserted_at)}
</time>
</div>
<.icon
name={if @expanded, do: "hero-chevron-up-mini", else: "hero-chevron-down-mini"}
class="size-5 admin-review-chevron"
/>
</button>
<div :if={@expanded} class="admin-review-detail">
<div class="admin-review-content">
<h4 :if={@review.title} class="admin-review-title">{@review.title}</h4>
<p :if={@review.body} class="admin-review-body">{@review.body}</p>
<p :if={!@review.title && !@review.body} class="admin-review-empty">
No written review — rating only.
</p>
<div :if={@images != []} class="admin-review-photos">
<button
:for={{image, idx} <- Enum.with_index(@images)}
type="button"
class="admin-review-photo"
phx-click={
Phoenix.LiveView.JS.exec("data-show", to: "#admin-review-#{@review.id}-lightbox")
|> Phoenix.LiveView.JS.set_attribute(
{"data-current-index", to_string(idx)},
to: "#admin-review-#{@review.id}-lightbox"
)
}
>
<img src={thumb_url(image)} alt="Customer photo" loading="lazy" />
</button>
<.image_lightbox
id={"admin-review-#{@review.id}-lightbox"}
images={Enum.map(@images, &image_url/1)}
caption="Customer photo"
/>
</div>
</div>
<div class="admin-review-actions">
<.link
navigate={~p"/p/#{@review.product.slug}"}
class="admin-btn admin-btn-sm admin-btn-ghost"
>
View product
</.link>
<button
:if={@review.status != "approved"}
phx-click="approve"
phx-value-id={@review.id}
class="admin-btn admin-btn-sm admin-btn-primary"
>
<.icon name="hero-check-mini" class="size-4" /> Approve
</button>
<button
:if={@review.status != "rejected"}
phx-click="reject"
phx-value-id={@review.id}
class="admin-btn admin-btn-sm admin-btn-ghost"
>
<.icon name="hero-x-mark-mini" class="size-4" /> Reject
</button>
<button
phx-click="delete"
phx-value-id={@review.id}
data-confirm="Delete this review permanently?"
class="admin-btn admin-btn-sm admin-btn-danger"
>
<.icon name="hero-trash-mini" class="size-4" /> Delete
</button>
</div>
</div>
</article>
"""
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"""
<span class={["admin-status-badge", @class]}>
<.icon name={@icon} class="size-3.5" />
{@status}
</span>
"""
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

View File

@@ -47,10 +47,13 @@ defmodule BerrypodWeb.Shop.Pages.Product do
) )
end end
base = BerrypodWeb.Endpoint.url()
og_url = R.url(R.product(slug)) og_url = R.url(R.product(slug))
og_image = og_image_url(all_images) 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") page = Pages.get_page("pdp")
is_discontinued = product.status == "discontinued" is_discontinued = product.status == "discontinued"
@@ -61,7 +64,10 @@ defmodule BerrypodWeb.Shop.Pages.Product do
|> assign(:og_type, "product") |> assign(:og_type, "product")
|> assign(:og_url, og_url) |> assign(:og_url, og_url)
|> assign(:og_image, og_image) |> 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(:product, product)
|> assign(:all_images, all_images) |> assign(:all_images, all_images)
|> assign(:quantity, 1) |> assign(:quantity, 1)
@@ -137,26 +143,23 @@ defmodule BerrypodWeb.Shop.Pages.Product do
def handle_event("request_review", %{"email" => email}, socket) do def handle_event("request_review", %{"email" => email}, socket) do
product = socket.assigns.product 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 case Reviews.request_review_verification(email, product.id, product.title) do
{:ok, :sent} -> {:ok, :sent} ->
{:noreply, {:noreply, assign(socket, :review_status, generic_success)}
assign(
socket,
:review_status,
{:info, "Check your email for a link to leave your review."}
)}
{:error, :no_purchase} -> {:error, :no_purchase} ->
{:noreply, # Don't reveal that this email hasn't purchased
assign( {:noreply, assign(socket, :review_status, generic_success)}
socket,
:review_status,
{:error, "We couldn't find a matching order for this product."}
)}
{:error, :already_reviewed} -> {:error, :already_reviewed} ->
# This one is safe to reveal - they already have a public review
{:noreply, {: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} -> {:error, _reason} ->
{:noreply, {:noreply,
@@ -164,6 +167,21 @@ defmodule BerrypodWeb.Shop.Pages.Product do
end end
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 def handle_event(_event, _params, _socket), do: :cont
# ── Review helpers ─────────────────────────────────────────────────── # ── Review helpers ───────────────────────────────────────────────────
@@ -333,7 +351,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do
# ── JSON-LD and meta helpers ───────────────────────────────────────── # ── 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 = category_slug =
if product.category, if product.category,
do: product.category |> String.downcase() |> String.replace(" ", "-"), do: product.category |> String.downcase() |> String.replace(" ", "-"),
@@ -358,9 +376,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do
] ]
|> Enum.reject(&is_nil/1) |> Enum.reject(&is_nil/1)
data = %{ product_data =
"@context" => "https://schema.org",
"@graph" => [
%{ %{
"@type" => "Product", "@type" => "Product",
"name" => product.title, "name" => product.title,
@@ -378,7 +394,14 @@ defmodule BerrypodWeb.Shop.Pages.Product do
), ),
"url" => url "url" => url
} }
}, }
|> maybe_add_rating(avg_rating, review_count)
|> maybe_add_reviews(reviews)
data = %{
"@context" => "https://schema.org",
"@graph" => [
product_data,
%{ %{
"@type" => "BreadcrumbList", "@type" => "BreadcrumbList",
"itemListElement" => breadcrumbs "itemListElement" => breadcrumbs
@@ -389,6 +412,53 @@ defmodule BerrypodWeb.Shop.Pages.Product do
Jason.encode!(data, escape: :html_safe) Jason.encode!(data, escape: :html_safe)
end 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 defp format_price(pence) when is_integer(pence) do
"#{div(pence, 100)}.#{String.pad_leading(to_string(rem(pence, 100)), 2, "0")}" "#{div(pence, 100)}.#{String.pad_leading(to_string(rem(pence, 100)), 2, "0")}"
end end
@@ -417,4 +487,31 @@ defmodule BerrypodWeb.Shop.Pages.Product do
|> Kernel.<>("") |> Kernel.<>("")
end end
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 end

View File

@@ -8,9 +8,12 @@ defmodule BerrypodWeb.Shop.ReviewForm do
use BerrypodWeb, :live_view use BerrypodWeb, :live_view
alias Berrypod.{Products, Reviews} alias Berrypod.{Media, Products, Reviews}
alias Berrypod.Reviews.Review alias Berrypod.Reviews.Review
@max_photos 3
@max_file_size 10_000_000
@impl true @impl true
def mount(_params, session, socket) do def mount(_params, session, socket) do
email_session = session["email_session"] email_session = session["email_session"]
@@ -24,6 +27,13 @@ defmodule BerrypodWeb.Shop.ReviewForm do
|> assign(:form, nil) |> assign(:form, nil)
|> assign(:submitted, false) |> assign(:submitted, false)
|> assign(:error, nil) |> 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} {:ok, socket}
end end
@@ -98,11 +108,15 @@ defmodule BerrypodWeb.Shop.ReviewForm do
changeset = Review.update_changeset(review, %{}) changeset = Review.update_changeset(review, %{})
# Load existing images for display
existing_images = Reviews.get_review_images(review)
socket socket
|> assign(:review, review) |> assign(:review, review)
|> assign(:product, product) |> assign(:product, product)
|> assign(:form, to_form(changeset)) |> assign(:form, to_form(changeset))
|> assign(:page_title, "Edit your review") |> assign(:page_title, "Edit your review")
|> assign(:existing_images, existing_images)
else else
assign(socket, :error, "You don't have permission to edit this review.") assign(socket, :error, "You don't have permission to edit this review.")
end end
@@ -144,11 +158,30 @@ defmodule BerrypodWeb.Shop.ReviewForm do
{:noreply, assign(socket, :form, to_form(changeset))} {:noreply, assign(socket, :form, to_form(changeset))}
end 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 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 =
params params
|> Map.put("product_id", socket.assigns.product.id) |> Map.put("product_id", socket.assigns.product.id)
|> Map.put("email", socket.assigns.email) |> Map.put("email", socket.assigns.email)
|> Map.put("image_ids", image_ids)
case Reviews.create_review(params) do case Reviews.create_review(params) do
{:ok, _review} -> {:ok, _review} ->
@@ -163,6 +196,19 @@ defmodule BerrypodWeb.Shop.ReviewForm do
end end
defp update_review(socket, params) do 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 case Reviews.update_review(socket.assigns.review, params) do
{:ok, _review} -> {:ok, _review} ->
{:noreply, {:noreply,
@@ -175,6 +221,19 @@ defmodule BerrypodWeb.Shop.ReviewForm do
end end
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 @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@@ -185,7 +244,13 @@ defmodule BerrypodWeb.Shop.ReviewForm do
<% @error -> %> <% @error -> %>
<.error_message error={@error} product={@product} review={@review} /> <.error_message error={@error} product={@product} review={@review} />
<% @form -> %> <% @form -> %>
<.review_form form={@form} product={@product} review={@review} /> <.review_form
form={@form}
product={@product}
review={@review}
uploads={@uploads}
existing_images={@existing_images}
/>
<% true -> %> <% true -> %>
<p>Loading...</p> <p>Loading...</p>
<% end %> <% end %>
@@ -223,23 +288,19 @@ defmodule BerrypodWeb.Shop.ReviewForm do
<div class="review-error"> <div class="review-error">
<h1>Unable to leave review</h1> <h1>Unable to leave review</h1>
<p>{@error}</p> <p>{@error}</p>
<%= if @product do %> <.link :if={@product} href={BerrypodWeb.R.product(@product.slug)} class="review-back-link">
<.link href={BerrypodWeb.R.product(@product.slug)} class="review-back-link">
Back to {@product.title} Back to {@product.title}
</.link> </.link>
<%= if @review do %>
<.link <.link
:if={@product && @review}
href={"/reviews/#{@review.id}/edit?product=#{@product.slug}"} href={"/reviews/#{@review.id}/edit?product=#{@product.slug}"}
class="review-edit-link" class="review-edit-link"
> >
Edit your existing review Edit your existing review
</.link> </.link>
<% end %> <.link :if={!@product} href={BerrypodWeb.R.home()} class="review-back-link">
<% else %>
<.link href={BerrypodWeb.R.home()} class="review-back-link">
Back to shop Back to shop
</.link> </.link>
<% end %>
</div> </div>
""" """
end end
@@ -255,6 +316,7 @@ defmodule BerrypodWeb.Shop.ReviewForm do
<.form for={@form} phx-change="validate" phx-submit="save" class="review-form"> <.form for={@form} phx-change="validate" phx-submit="save" class="review-form">
<div class="review-form-field"> <div class="review-form-field">
<label class="review-form-label">Rating</label> <label class="review-form-label">Rating</label>
<input type="hidden" name="review[rating]" value={@form[:rating].value} />
<.star_rating_input rating={@form[:rating].value} /> <.star_rating_input rating={@form[:rating].value} />
<p <p
:for={msg <- Enum.map(@form[:rating].errors, &translate_error/1)} :for={msg <- Enum.map(@form[:rating].errors, &translate_error/1)}
@@ -322,6 +384,8 @@ defmodule BerrypodWeb.Shop.ReviewForm do
</p> </p>
</div> </div>
<.photo_upload_section uploads={@uploads} existing_images={@existing_images} />
<button type="submit" class="review-form-submit"> <button type="submit" class="review-form-submit">
{if @review, do: "Update review", else: "Submit review"} {if @review, do: "Update review", else: "Submit review"}
</button> </button>
@@ -330,28 +394,140 @@ defmodule BerrypodWeb.Shop.ReviewForm do
""" """
end end
defp star_rating_input(assigns) do defp photo_upload_section(assigns) do
rating = assigns.rating # Count total photos (existing + pending uploads)
rating = if is_binary(rating), do: String.to_integer(rating), else: rating 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"""
<div class="review-form-field">
<label class="review-form-label">
Photos <span class="review-form-optional">(optional)</span>
</label>
<div class="review-photo-previews">
<%!-- Existing images (from editing a review) --%>
<div :for={image <- @existing_images} class="review-photo-preview">
<img src={image_thumb_url(image)} alt="Review photo" />
<button
type="button"
class="review-photo-remove"
phx-click="remove_image"
phx-value-id={image.id}
aria-label="Remove photo"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<%!-- Pending upload entries (uploaded to server, consumed on submit) --%>
<div :for={entry <- @uploads.photos.entries} class="review-photo-preview">
<.live_img_preview entry={entry} />
<button
type="button"
class="review-photo-remove"
phx-click="cancel_upload"
phx-value-ref={entry.ref}
aria-label="Remove photo"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
<div :if={entry.progress > 0 and entry.progress < 100} class="review-photo-progress">
<div class="review-photo-progress-bar" style={"width: #{entry.progress}%"}></div>
</div>
</div>
<.live_file_input upload={@uploads.photos} class="sr-only" />
<label :if={@can_add_more} class="review-photo-add" for={@uploads.photos.ref}>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
<span>Add photo</span>
</label>
</div>
<p
:for={entry <- @uploads.photos.entries}
:if={entry.client_type not in ~w(image/jpeg image/png image/webp image/heic)}
class="review-form-error"
>
{entry.client_name}: not a supported image type
</p>
<p :for={err <- upload_errors(@uploads.photos)} class="review-form-error">
{upload_error_to_string(err)}
</p>
<p class="review-form-hint">You can add up to 3 photos (max 10MB each)</p>
</div>
"""
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""" ~H"""
<div class="star-rating-input"> <div class="star-rating-input">
<%= for i <- 1..5 do %>
<button <button
:for={i <- 1..5}
type="button" type="button"
phx-click="set_rating" phx-click="set_rating"
phx-value-rating={i} phx-value-rating={i}
class={"star-rating-btn #{if @rating && @rating >= i, do: "star-filled", else: "star-empty"}"} class={["star-rating-btn", if(@rating && @rating >= i, do: "star-filled", else: "star-empty")]}
aria-label={"Rate #{i} stars"} aria-label={"Rate #{i} stars"}
> >
<svg viewBox="0 0 24 24" fill="currentColor" class="star-icon"> <svg viewBox="0 0 24 24" fill="currentColor" class="star-icon">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" /> <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg> </svg>
</button> </button>
<% end %>
</div> </div>
""" """
end 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 end

View File

@@ -177,6 +177,7 @@ defmodule BerrypodWeb.Router do
live "/pages/:slug/settings", Admin.Pages.CustomForm, :edit live "/pages/:slug/settings", Admin.Pages.CustomForm, :edit
live "/pages/:slug", Admin.Pages.Editor, :edit live "/pages/:slug", Admin.Pages.Editor, :edit
live "/media", Admin.Media, :index live "/media", Admin.Media, :index
live "/reviews", Admin.Reviews, :index
live "/newsletter", Admin.Newsletter, :index live "/newsletter", Admin.Newsletter, :index
live "/newsletter/campaigns/new", Admin.Newsletter.CampaignForm, :new live "/newsletter/campaigns/new", Admin.Newsletter.CampaignForm, :new
live "/newsletter/campaigns/:id", Admin.Newsletter.CampaignForm, :edit live "/newsletter/campaigns/:id", Admin.Newsletter.CampaignForm, :edit

View File

@@ -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

View File

@@ -53,9 +53,9 @@ defmodule Berrypod.MediaTest do
end end
test "validates file size" do 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 {: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
end end