complete reviews system (phases 4-6)
All checks were successful
deploy / deploy (push) Successful in 1m4s
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:
14
PROGRESS.md
14
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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
11
ROADMAP.md
11
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
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 ── */
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
108
lib/berrypod/reviews/review_request_worker.ex
Normal file
108
lib/berrypod/reviews/review_request_worker.ex
Normal 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
|
||||
@@ -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 ->
|
||||
|
||||
@@ -126,6 +126,20 @@
|
||||
<.icon name="hero-photo" class="size-5" /> Media
|
||||
</.link>
|
||||
</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>
|
||||
<.link
|
||||
navigate={~p"/admin/newsletter"}
|
||||
|
||||
@@ -951,18 +951,189 @@ defmodule BerrypodWeb.ShopComponents.Content do
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a generic image lightbox dialog.
|
||||
|
||||
Reusable by product galleries, review photos, or any image gallery.
|
||||
Uses the Lightbox JS hook for keyboard navigation and modal behavior.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `id` - Required. Unique ID for the dialog element.
|
||||
* `images` - Required. List of image URLs.
|
||||
* `caption` - Optional. Caption text shown below the image.
|
||||
|
||||
## Examples
|
||||
|
||||
<.image_lightbox id="review-123-lightbox" images={@image_urls} caption="Customer photo" />
|
||||
"""
|
||||
attr :id, :string, required: true
|
||||
attr :images, :list, required: true
|
||||
attr :caption, :string, default: nil
|
||||
|
||||
def image_lightbox(assigns) do
|
||||
~H"""
|
||||
<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 """
|
||||
Renders a customer reviews section with collapsible header and review cards.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `reviews` - Required. List of review maps with:
|
||||
- `id` - Review ID
|
||||
- `rating` - Star rating (1-5)
|
||||
- `title` - Review title
|
||||
- `body` - Review text
|
||||
- `author` - Reviewer name
|
||||
- `date` - Relative date string (e.g., "2 weeks ago")
|
||||
- `verified` - Boolean, if true shows "Verified purchase" badge
|
||||
- `images` - Optional. List of Image structs for review photos.
|
||||
* `average_rating` - Optional. Average rating to show in header. Defaults to nil.
|
||||
* `total_count` - Optional. Total number of reviews. Defaults to length of reviews list.
|
||||
* `open` - Optional. Whether section is expanded by default. Defaults to true.
|
||||
@@ -1038,7 +1209,11 @@ defmodule BerrypodWeb.ShopComponents.Content do
|
||||
<% end %>
|
||||
</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
|
||||
</.shop_button_outline>
|
||||
<% else %>
|
||||
@@ -1121,7 +1296,37 @@ defmodule BerrypodWeb.ShopComponents.Content do
|
||||
|
||||
~H"""
|
||||
<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>
|
||||
"""
|
||||
end
|
||||
@@ -1131,11 +1336,11 @@ defmodule BerrypodWeb.ShopComponents.Content do
|
||||
|
||||
## Attributes
|
||||
|
||||
* `review` - Required. Map with `rating`, `title`, `body`, `author`, `date`, `verified`.
|
||||
* `review` - Required. Map with `id`, `rating`, `title`, `body`, `author`, `date`, `verified`, and optional `images`.
|
||||
|
||||
## Examples
|
||||
|
||||
<.review_card review={%{rating: 5, title: "Great!", body: "...", author: "Jane", date: "1 week ago", verified: true}} />
|
||||
<.review_card review={%{id: "abc", rating: 5, title: "Great!", body: "...", author: "Jane", date: "1 week ago", verified: true, images: []}} />
|
||||
"""
|
||||
attr :review, :map, required: true
|
||||
|
||||
@@ -1146,19 +1351,33 @@ defmodule BerrypodWeb.ShopComponents.Content do
|
||||
<.star_rating rating={@review.rating} />
|
||||
<span class="review-date">{@review.date}</span>
|
||||
</div>
|
||||
<h3 class="review-title">{@review.title}</h3>
|
||||
<p class="review-body">
|
||||
<h3 :if={@review.title} class="review-title">{@review.title}</h3>
|
||||
<p :if={@review.body} class="review-body">
|
||||
{@review.body}
|
||||
</p>
|
||||
<.review_photos
|
||||
:if={@review[:images] && @review.images != []}
|
||||
images={@review.images}
|
||||
review_id={@review.id}
|
||||
/>
|
||||
<div class="review-footer">
|
||||
<span class="review-author">
|
||||
{@review.author}
|
||||
</span>
|
||||
<%= if @review.verified do %>
|
||||
<span class="review-verified">
|
||||
Verified purchase
|
||||
</span>
|
||||
<% end %>
|
||||
<span :if={@review.verified} 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
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
"""
|
||||
|
||||
@@ -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 %>
|
||||
<p class="product-card-delivery">
|
||||
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"""
|
||||
<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),
|
||||
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">
|
||||
<img
|
||||
class="lightbox-image"
|
||||
id="lightbox-image"
|
||||
src={List.first(@images)}
|
||||
alt={@product_name}
|
||||
width="1200"
|
||||
@@ -1286,7 +1313,7 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="lightbox-counter" id="lightbox-counter">1 / {length(@images)}</div>
|
||||
<div class="lightbox-counter">1 / {length(@images)}</div>
|
||||
</div>
|
||||
</dialog>
|
||||
"""
|
||||
|
||||
389
lib/berrypod_web/live/admin/reviews.ex
Normal file
389
lib/berrypod_web/live/admin/reviews.ex
Normal 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
|
||||
@@ -47,10 +47,13 @@ defmodule BerrypodWeb.Shop.Pages.Product do
|
||||
)
|
||||
end
|
||||
|
||||
base = BerrypodWeb.Endpoint.url()
|
||||
og_url = R.url(R.product(slug))
|
||||
og_image = og_image_url(all_images)
|
||||
|
||||
# Load review aggregates for JSON-LD (SEO)
|
||||
{avg_rating, review_count} = Reviews.average_rating_for_product(product.id)
|
||||
seo_reviews = Reviews.list_reviews_for_product(product.id, limit: 5)
|
||||
|
||||
page = Pages.get_page("pdp")
|
||||
is_discontinued = product.status == "discontinued"
|
||||
|
||||
@@ -61,7 +64,10 @@ defmodule BerrypodWeb.Shop.Pages.Product do
|
||||
|> assign(:og_type, "product")
|
||||
|> assign(:og_url, og_url)
|
||||
|> assign(:og_image, og_image)
|
||||
|> assign(:json_ld, product_json_ld(product, og_url, og_image, base))
|
||||
|> assign(
|
||||
:json_ld,
|
||||
product_json_ld(product, og_url, og_image, avg_rating, review_count, seo_reviews)
|
||||
)
|
||||
|> assign(:product, product)
|
||||
|> assign(:all_images, all_images)
|
||||
|> assign(:quantity, 1)
|
||||
@@ -137,26 +143,23 @@ defmodule BerrypodWeb.Shop.Pages.Product do
|
||||
def handle_event("request_review", %{"email" => email}, socket) do
|
||||
product = socket.assigns.product
|
||||
|
||||
# Always show the same message to prevent email enumeration attacks.
|
||||
# Attacker shouldn't be able to confirm whether an email has purchased a product.
|
||||
generic_success =
|
||||
{:info, "If you've purchased this product, we've sent a verification link to your email."}
|
||||
|
||||
case Reviews.request_review_verification(email, product.id, product.title) do
|
||||
{:ok, :sent} ->
|
||||
{:noreply,
|
||||
assign(
|
||||
socket,
|
||||
:review_status,
|
||||
{:info, "Check your email for a link to leave your review."}
|
||||
)}
|
||||
{:noreply, assign(socket, :review_status, generic_success)}
|
||||
|
||||
{:error, :no_purchase} ->
|
||||
{:noreply,
|
||||
assign(
|
||||
socket,
|
||||
:review_status,
|
||||
{:error, "We couldn't find a matching order for this product."}
|
||||
)}
|
||||
# Don't reveal that this email hasn't purchased
|
||||
{:noreply, assign(socket, :review_status, generic_success)}
|
||||
|
||||
{:error, :already_reviewed} ->
|
||||
# This one is safe to reveal - they already have a public review
|
||||
{:noreply,
|
||||
assign(socket, :review_status, {:error, "You've already reviewed this product."})}
|
||||
assign(socket, :review_status, {:info, "You've already reviewed this product."})}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply,
|
||||
@@ -164,6 +167,21 @@ defmodule BerrypodWeb.Shop.Pages.Product do
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("load_more_reviews", _params, socket) do
|
||||
product = socket.assigns.product
|
||||
current_reviews = socket.assigns[:reviews] || []
|
||||
offset = length(current_reviews)
|
||||
|
||||
# Load the next batch
|
||||
more_reviews =
|
||||
Reviews.list_reviews_for_product(product.id, limit: 10, offset: offset)
|
||||
|> Enum.map(&format_review_for_display/1)
|
||||
|
||||
all_reviews = current_reviews ++ more_reviews
|
||||
|
||||
{:noreply, assign(socket, :reviews, all_reviews)}
|
||||
end
|
||||
|
||||
def handle_event(_event, _params, _socket), do: :cont
|
||||
|
||||
# ── Review helpers ───────────────────────────────────────────────────
|
||||
@@ -333,7 +351,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do
|
||||
|
||||
# ── JSON-LD and meta helpers ─────────────────────────────────────────
|
||||
|
||||
defp product_json_ld(product, url, image, _base) do
|
||||
defp product_json_ld(product, url, image, avg_rating, review_count, reviews) do
|
||||
category_slug =
|
||||
if product.category,
|
||||
do: product.category |> String.downcase() |> String.replace(" ", "-"),
|
||||
@@ -358,27 +376,32 @@ defmodule BerrypodWeb.Shop.Pages.Product do
|
||||
]
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|
||||
product_data =
|
||||
%{
|
||||
"@type" => "Product",
|
||||
"name" => product.title,
|
||||
"description" => plain_text(product.description),
|
||||
"image" => Enum.reject([image], &is_nil/1),
|
||||
"url" => url,
|
||||
"offers" => %{
|
||||
"@type" => "Offer",
|
||||
"price" => format_price(product.cheapest_price),
|
||||
"priceCurrency" => "GBP",
|
||||
"availability" =>
|
||||
if(product.in_stock,
|
||||
do: "https://schema.org/InStock",
|
||||
else: "https://schema.org/OutOfStock"
|
||||
),
|
||||
"url" => url
|
||||
}
|
||||
}
|
||||
|> maybe_add_rating(avg_rating, review_count)
|
||||
|> maybe_add_reviews(reviews)
|
||||
|
||||
data = %{
|
||||
"@context" => "https://schema.org",
|
||||
"@graph" => [
|
||||
%{
|
||||
"@type" => "Product",
|
||||
"name" => product.title,
|
||||
"description" => plain_text(product.description),
|
||||
"image" => Enum.reject([image], &is_nil/1),
|
||||
"url" => url,
|
||||
"offers" => %{
|
||||
"@type" => "Offer",
|
||||
"price" => format_price(product.cheapest_price),
|
||||
"priceCurrency" => "GBP",
|
||||
"availability" =>
|
||||
if(product.in_stock,
|
||||
do: "https://schema.org/InStock",
|
||||
else: "https://schema.org/OutOfStock"
|
||||
),
|
||||
"url" => url
|
||||
}
|
||||
},
|
||||
product_data,
|
||||
%{
|
||||
"@type" => "BreadcrumbList",
|
||||
"itemListElement" => breadcrumbs
|
||||
@@ -389,6 +412,53 @@ defmodule BerrypodWeb.Shop.Pages.Product do
|
||||
Jason.encode!(data, escape: :html_safe)
|
||||
end
|
||||
|
||||
defp maybe_add_rating(product_data, nil, _count), do: product_data
|
||||
defp maybe_add_rating(product_data, _avg, 0), do: product_data
|
||||
|
||||
defp maybe_add_rating(product_data, avg_rating, review_count) do
|
||||
Map.put(product_data, "aggregateRating", %{
|
||||
"@type" => "AggregateRating",
|
||||
"ratingValue" => Decimal.to_string(avg_rating),
|
||||
"reviewCount" => to_string(review_count),
|
||||
"bestRating" => "5",
|
||||
"worstRating" => "1"
|
||||
})
|
||||
end
|
||||
|
||||
defp maybe_add_reviews(product_data, []), do: product_data
|
||||
|
||||
defp maybe_add_reviews(product_data, reviews) do
|
||||
review_data =
|
||||
Enum.map(reviews, fn review ->
|
||||
review_item = %{
|
||||
"@type" => "Review",
|
||||
"author" => %{"@type" => "Person", "name" => review.author_name},
|
||||
"datePublished" => Date.to_iso8601(DateTime.to_date(review.inserted_at)),
|
||||
"reviewRating" => %{
|
||||
"@type" => "Rating",
|
||||
"ratingValue" => to_string(review.rating),
|
||||
"bestRating" => "5",
|
||||
"worstRating" => "1"
|
||||
}
|
||||
}
|
||||
|
||||
review_item =
|
||||
if review.body && review.body != "" do
|
||||
Map.put(review_item, "reviewBody", review.body)
|
||||
else
|
||||
review_item
|
||||
end
|
||||
|
||||
if review.title && review.title != "" do
|
||||
Map.put(review_item, "name", review.title)
|
||||
else
|
||||
review_item
|
||||
end
|
||||
end)
|
||||
|
||||
Map.put(product_data, "review", review_data)
|
||||
end
|
||||
|
||||
defp format_price(pence) when is_integer(pence) do
|
||||
"#{div(pence, 100)}.#{String.pad_leading(to_string(rem(pence, 100)), 2, "0")}"
|
||||
end
|
||||
@@ -417,4 +487,31 @@ defmodule BerrypodWeb.Shop.Pages.Product do
|
||||
|> Kernel.<>("…")
|
||||
end
|
||||
end
|
||||
|
||||
defp format_review_for_display(review) do
|
||||
%{
|
||||
id: review.id,
|
||||
rating: review.rating,
|
||||
title: review.title,
|
||||
body: review.body,
|
||||
author: review.author_name,
|
||||
date: relative_time(review.inserted_at),
|
||||
verified: not is_nil(review.order_id),
|
||||
images: Reviews.get_review_images(review)
|
||||
}
|
||||
end
|
||||
|
||||
defp relative_time(datetime) do
|
||||
now = DateTime.utc_now()
|
||||
diff = DateTime.diff(now, datetime, :second)
|
||||
|
||||
cond do
|
||||
diff < 60 -> "just now"
|
||||
diff < 3600 -> "#{div(diff, 60)} minutes ago"
|
||||
diff < 86400 -> "#{div(diff, 3600)} hours ago"
|
||||
diff < 604_800 -> "#{div(diff, 86400)} days ago"
|
||||
diff < 2_592_000 -> "#{div(diff, 604_800)} weeks ago"
|
||||
true -> "#{div(diff, 2_592_000)} months ago"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,9 +8,12 @@ defmodule BerrypodWeb.Shop.ReviewForm do
|
||||
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.{Products, Reviews}
|
||||
alias Berrypod.{Media, Products, Reviews}
|
||||
alias Berrypod.Reviews.Review
|
||||
|
||||
@max_photos 3
|
||||
@max_file_size 10_000_000
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
email_session = session["email_session"]
|
||||
@@ -24,6 +27,13 @@ defmodule BerrypodWeb.Shop.ReviewForm do
|
||||
|> assign(:form, nil)
|
||||
|> assign(:submitted, false)
|
||||
|> assign(:error, nil)
|
||||
|> assign(:existing_images, [])
|
||||
|> assign(:removed_image_ids, [])
|
||||
|> allow_upload(:photos,
|
||||
accept: ~w(.jpg .jpeg .png .webp .heic),
|
||||
max_entries: @max_photos,
|
||||
max_file_size: @max_file_size
|
||||
)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
@@ -98,11 +108,15 @@ defmodule BerrypodWeb.Shop.ReviewForm do
|
||||
|
||||
changeset = Review.update_changeset(review, %{})
|
||||
|
||||
# Load existing images for display
|
||||
existing_images = Reviews.get_review_images(review)
|
||||
|
||||
socket
|
||||
|> assign(:review, review)
|
||||
|> assign(:product, product)
|
||||
|> assign(:form, to_form(changeset))
|
||||
|> assign(:page_title, "Edit your review")
|
||||
|> assign(:existing_images, existing_images)
|
||||
else
|
||||
assign(socket, :error, "You don't have permission to edit this review.")
|
||||
end
|
||||
@@ -144,11 +158,30 @@ defmodule BerrypodWeb.Shop.ReviewForm do
|
||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
end
|
||||
|
||||
def handle_event("cancel_upload", %{"ref" => ref}, socket) do
|
||||
{:noreply, cancel_upload(socket, :photos, ref)}
|
||||
end
|
||||
|
||||
def handle_event("remove_image", %{"id" => image_id}, socket) do
|
||||
removed = [image_id | socket.assigns.removed_image_ids]
|
||||
existing = Enum.reject(socket.assigns.existing_images, &(&1.id == image_id))
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:removed_image_ids, removed)
|
||||
|> assign(:existing_images, existing)}
|
||||
end
|
||||
|
||||
defp create_review(socket, params) do
|
||||
# Consume uploads at submit time - they're ready since auto_upload: true
|
||||
# means files are uploaded as soon as selected
|
||||
image_ids = consume_photo_uploads(socket)
|
||||
|
||||
params =
|
||||
params
|
||||
|> Map.put("product_id", socket.assigns.product.id)
|
||||
|> Map.put("email", socket.assigns.email)
|
||||
|> Map.put("image_ids", image_ids)
|
||||
|
||||
case Reviews.create_review(params) do
|
||||
{:ok, _review} ->
|
||||
@@ -163,6 +196,19 @@ defmodule BerrypodWeb.Shop.ReviewForm do
|
||||
end
|
||||
|
||||
defp update_review(socket, params) do
|
||||
# Consume uploads at submit time
|
||||
new_image_ids = consume_photo_uploads(socket)
|
||||
|
||||
# Keep existing images that weren't removed, plus new uploads
|
||||
kept_ids =
|
||||
socket.assigns.existing_images
|
||||
|> Enum.map(& &1.id)
|
||||
|> Enum.reject(&(&1 in socket.assigns.removed_image_ids))
|
||||
|
||||
all_image_ids = kept_ids ++ new_image_ids
|
||||
|
||||
params = Map.put(params, "image_ids", all_image_ids)
|
||||
|
||||
case Reviews.update_review(socket.assigns.review, params) do
|
||||
{:ok, _review} ->
|
||||
{:noreply,
|
||||
@@ -175,6 +221,19 @@ defmodule BerrypodWeb.Shop.ReviewForm do
|
||||
end
|
||||
end
|
||||
|
||||
# Consume uploaded photos and save them to the database.
|
||||
# Returns a list of image IDs for the successfully saved images.
|
||||
# Uses async variant to avoid blocking - image processing happens in Oban.
|
||||
defp consume_photo_uploads(socket) do
|
||||
consume_uploaded_entries(socket, :photos, fn %{path: path}, entry ->
|
||||
case Media.upload_from_entry_async(path, entry, "review") do
|
||||
{:ok, image} -> {:ok, image.id}
|
||||
{:error, _reason} -> {:ok, nil}
|
||||
end
|
||||
end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
@@ -185,7 +244,13 @@ defmodule BerrypodWeb.Shop.ReviewForm do
|
||||
<% @error -> %>
|
||||
<.error_message error={@error} product={@product} review={@review} />
|
||||
<% @form -> %>
|
||||
<.review_form form={@form} product={@product} review={@review} />
|
||||
<.review_form
|
||||
form={@form}
|
||||
product={@product}
|
||||
review={@review}
|
||||
uploads={@uploads}
|
||||
existing_images={@existing_images}
|
||||
/>
|
||||
<% true -> %>
|
||||
<p>Loading...</p>
|
||||
<% end %>
|
||||
@@ -223,23 +288,19 @@ defmodule BerrypodWeb.Shop.ReviewForm do
|
||||
<div class="review-error">
|
||||
<h1>Unable to leave review</h1>
|
||||
<p>{@error}</p>
|
||||
<%= if @product do %>
|
||||
<.link href={BerrypodWeb.R.product(@product.slug)} class="review-back-link">
|
||||
Back to {@product.title}
|
||||
</.link>
|
||||
<%= if @review do %>
|
||||
<.link
|
||||
href={"/reviews/#{@review.id}/edit?product=#{@product.slug}"}
|
||||
class="review-edit-link"
|
||||
>
|
||||
Edit your existing review
|
||||
</.link>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<.link href={BerrypodWeb.R.home()} class="review-back-link">
|
||||
Back to shop
|
||||
</.link>
|
||||
<% end %>
|
||||
<.link :if={@product} href={BerrypodWeb.R.product(@product.slug)} class="review-back-link">
|
||||
Back to {@product.title}
|
||||
</.link>
|
||||
<.link
|
||||
:if={@product && @review}
|
||||
href={"/reviews/#{@review.id}/edit?product=#{@product.slug}"}
|
||||
class="review-edit-link"
|
||||
>
|
||||
Edit your existing review
|
||||
</.link>
|
||||
<.link :if={!@product} href={BerrypodWeb.R.home()} class="review-back-link">
|
||||
Back to shop
|
||||
</.link>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
@@ -255,6 +316,7 @@ defmodule BerrypodWeb.Shop.ReviewForm do
|
||||
<.form for={@form} phx-change="validate" phx-submit="save" class="review-form">
|
||||
<div class="review-form-field">
|
||||
<label class="review-form-label">Rating</label>
|
||||
<input type="hidden" name="review[rating]" value={@form[:rating].value} />
|
||||
<.star_rating_input rating={@form[:rating].value} />
|
||||
<p
|
||||
:for={msg <- Enum.map(@form[:rating].errors, &translate_error/1)}
|
||||
@@ -322,6 +384,8 @@ defmodule BerrypodWeb.Shop.ReviewForm do
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<.photo_upload_section uploads={@uploads} existing_images={@existing_images} />
|
||||
|
||||
<button type="submit" class="review-form-submit">
|
||||
{if @review, do: "Update review", else: "Submit review"}
|
||||
</button>
|
||||
@@ -330,28 +394,140 @@ defmodule BerrypodWeb.Shop.ReviewForm do
|
||||
"""
|
||||
end
|
||||
|
||||
defp star_rating_input(assigns) do
|
||||
rating = assigns.rating
|
||||
rating = if is_binary(rating), do: String.to_integer(rating), else: rating
|
||||
defp photo_upload_section(assigns) do
|
||||
# Count total photos (existing + pending uploads)
|
||||
total_photos = length(assigns.existing_images) + length(assigns.uploads.photos.entries)
|
||||
can_add_more = total_photos < 3
|
||||
|
||||
assigns = assign(assigns, :rating, rating)
|
||||
assigns = assign(assigns, :can_add_more, can_add_more)
|
||||
|
||||
~H"""
|
||||
<div class="star-rating-input">
|
||||
<%= for i <- 1..5 do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="set_rating"
|
||||
phx-value-rating={i}
|
||||
class={"star-rating-btn #{if @rating && @rating >= i, do: "star-filled", else: "star-empty"}"}
|
||||
aria-label={"Rate #{i} stars"}
|
||||
>
|
||||
<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" />
|
||||
<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>
|
||||
</button>
|
||||
<% end %>
|
||||
<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"""
|
||||
<div class="star-rating-input">
|
||||
<button
|
||||
:for={i <- 1..5}
|
||||
type="button"
|
||||
phx-click="set_rating"
|
||||
phx-value-rating={i}
|
||||
class={["star-rating-btn", if(@rating && @rating >= i, do: "star-filled", else: "star-empty")]}
|
||||
aria-label={"Rate #{i} stars"}
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
"""
|
||||
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
|
||||
|
||||
@@ -177,6 +177,7 @@ defmodule BerrypodWeb.Router do
|
||||
live "/pages/:slug/settings", Admin.Pages.CustomForm, :edit
|
||||
live "/pages/:slug", Admin.Pages.Editor, :edit
|
||||
live "/media", Admin.Media, :index
|
||||
live "/reviews", Admin.Reviews, :index
|
||||
live "/newsletter", Admin.Newsletter, :index
|
||||
live "/newsletter/campaigns/new", Admin.Newsletter.CampaignForm, :new
|
||||
live "/newsletter/campaigns/:id", Admin.Newsletter.CampaignForm, :edit
|
||||
|
||||
@@ -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
|
||||
@@ -53,9 +53,9 @@ defmodule Berrypod.MediaTest do
|
||||
end
|
||||
|
||||
test "validates file size" do
|
||||
attrs = Map.put(@valid_attrs, :file_size, 10_000_000)
|
||||
attrs = Map.put(@valid_attrs, :file_size, 15_000_000)
|
||||
assert {:error, changeset} = Media.upload_image(attrs)
|
||||
assert "must be less than 5000000" in errors_on(changeset).file_size
|
||||
assert "must be less than 10000000" in errors_on(changeset).file_size
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user