Some checks failed
Add defer attribute to 145 <script> tags across 103 template files (PERF-067) and loading="lazy" to 22 <img> tags across 13 template files (PERF-058). Both improve page load performance. Validator totals: 0 errors, 2 warnings, 1360 info (down from 1527). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
460 lines
19 KiB
HTML
460 lines
19 KiB
HTML
{#
|
|
Review Components
|
|
=================
|
|
Product review display and submission components.
|
|
|
|
Usage:
|
|
{% from 'shared/macros/storefront/reviews.html' import review_list, review_card, review_form, review_summary %}
|
|
#}
|
|
|
|
{% from 'shared/macros/storefront/star-rating.html' import star_rating, rating_input, rating_summary %}
|
|
|
|
|
|
{#
|
|
Review Card
|
|
===========
|
|
Individual product review display.
|
|
|
|
Parameters:
|
|
- review: Static review object
|
|
- review_var: Alpine.js expression for review (dynamic)
|
|
- show_avatar: Show reviewer avatar (default: true)
|
|
- show_verified: Show verified purchase badge (default: true)
|
|
- show_helpful: Show helpful buttons (default: true)
|
|
- show_images: Show review images (default: true)
|
|
- on_helpful: Callback for helpful button click
|
|
|
|
Review structure:
|
|
{
|
|
id: 1,
|
|
author_name: 'John D.',
|
|
author_avatar: null,
|
|
rating: 5,
|
|
title: 'Great product!',
|
|
content: 'Really happy with this purchase...',
|
|
verified: true,
|
|
created_at: '2025-01-15',
|
|
helpful_count: 42,
|
|
images: []
|
|
}
|
|
|
|
Usage:
|
|
{{ review_card(review_var='review') }}
|
|
#}
|
|
{% macro review_card(
|
|
review=none,
|
|
review_var=none,
|
|
show_avatar=true,
|
|
show_verified=true,
|
|
show_helpful=true,
|
|
show_images=true,
|
|
on_helpful=none
|
|
) %}
|
|
{% if review_var %}
|
|
<article class="border-b border-gray-200 dark:border-gray-700 pb-6 last:border-0 last:pb-0">
|
|
{# Header #}
|
|
<div class="flex items-start justify-between mb-3">
|
|
<div class="flex items-center gap-3">
|
|
{% if show_avatar %}
|
|
<div class="w-10 h-10 rounded-full overflow-hidden bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center flex-shrink-0">
|
|
<template x-if="{{ review_var }}.author_avatar">
|
|
<img loading="lazy" :src="{{ review_var }}.author_avatar" :alt="{{ review_var }}.author_name" class="w-full h-full object-cover">
|
|
</template>
|
|
<template x-if="!{{ review_var }}.author_avatar">
|
|
<span class="text-sm font-medium text-purple-600 dark:text-purple-400" x-text="{{ review_var }}.author_name?.charAt(0)?.toUpperCase()"></span>
|
|
</template>
|
|
</div>
|
|
{% endif %}
|
|
<div>
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<span class="font-medium text-gray-900 dark:text-white" x-text="{{ review_var }}.author_name"></span>
|
|
{% if show_verified %}
|
|
<span
|
|
x-show="{{ review_var }}.verified"
|
|
class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-400 bg-green-100 dark:bg-green-900/30 rounded"
|
|
>
|
|
<span x-html="$icon('badge-check', 'w-3 h-3')"></span>
|
|
Verified Purchase
|
|
</span>
|
|
{% endif %}
|
|
</div>
|
|
<div class="text-xs text-gray-500 dark:text-gray-400" x-text="{{ review_var }}.created_at"></div>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-0.5">
|
|
<template x-for="i in 5" :key="i">
|
|
<span :class="i <= {{ review_var }}.rating ? 'text-yellow-400' : 'text-gray-300 dark:text-gray-600'">
|
|
<span x-html="$icon('star', 'w-4 h-4')"></span>
|
|
</span>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
{# Title #}
|
|
<h4
|
|
x-show="{{ review_var }}.title"
|
|
class="font-medium text-gray-900 dark:text-white mb-2"
|
|
x-text="{{ review_var }}.title"
|
|
></h4>
|
|
|
|
{# Content #}
|
|
<p class="text-gray-600 dark:text-gray-400 mb-4" x-text="{{ review_var }}.content"></p>
|
|
|
|
{% if show_images %}
|
|
{# Review Images #}
|
|
<div
|
|
x-show="{{ review_var }}.images?.length > 0"
|
|
class="flex flex-wrap gap-2 mb-4"
|
|
>
|
|
<template x-for="(image, index) in {{ review_var }}.images || []" :key="index">
|
|
<button
|
|
type="button"
|
|
class="w-16 h-16 rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 hover:border-purple-500 transition-colors"
|
|
>
|
|
<img loading="lazy" :src="image" alt="Review image" class="w-full h-full object-cover">
|
|
</button>
|
|
</template>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if show_helpful %}
|
|
{# Helpful Actions #}
|
|
<div class="flex items-center gap-4">
|
|
<span class="text-sm text-gray-500 dark:text-gray-400">Was this review helpful?</span>
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
{% if on_helpful %}@click="{{ on_helpful }}({{ review_var }}.id, true)"{% endif %}
|
|
class="inline-flex items-center gap-1 px-3 py-1 text-sm text-gray-600 dark:text-gray-400 hover:text-green-600 dark:hover:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/20 rounded transition-colors"
|
|
>
|
|
<span x-html="$icon('thumb-up', 'w-4 h-4')"></span>
|
|
<span>Yes</span>
|
|
<span x-show="{{ review_var }}.helpful_count > 0" class="text-gray-400" x-text="'(' + {{ review_var }}.helpful_count + ')'"></span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
{% if on_helpful %}@click="{{ on_helpful }}({{ review_var }}.id, false)"{% endif %}
|
|
class="inline-flex items-center gap-1 px-3 py-1 text-sm text-gray-600 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
|
|
>
|
|
<span x-html="$icon('thumb-down', 'w-4 h-4')"></span>
|
|
<span>No</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</article>
|
|
{% endif %}
|
|
{% endmacro %}
|
|
|
|
|
|
{#
|
|
Review List
|
|
===========
|
|
List of reviews with optional sorting and pagination.
|
|
|
|
Parameters:
|
|
- reviews_var: Alpine.js expression for reviews array
|
|
- loading_var: Alpine.js expression for loading state
|
|
- empty_message: Message when no reviews (default: 'No reviews yet')
|
|
- show_sort: Show sort dropdown (default: true)
|
|
- sort_var: Alpine.js var for sort value (default: 'reviewSort')
|
|
- on_sort: Callback when sort changes
|
|
|
|
Usage:
|
|
{{ review_list(reviews_var='reviews', loading_var='loadingReviews') }}
|
|
#}
|
|
{% macro review_list(
|
|
reviews_var='reviews',
|
|
loading_var='loading',
|
|
empty_message='No reviews yet. Be the first to review this product!',
|
|
show_sort=true,
|
|
sort_var='reviewSort',
|
|
on_sort=none
|
|
) %}
|
|
<div class="space-y-6">
|
|
{% if show_sort %}
|
|
{# Sort Controls #}
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-sm text-gray-600 dark:text-gray-400">
|
|
<span x-text="{{ reviews_var }}?.length || 0"></span> reviews
|
|
</span>
|
|
<div x-data="{ open: false }" class="relative" @click.away="open = false">
|
|
<button
|
|
type="button"
|
|
@click="open = !open"
|
|
class="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white border border-gray-300 dark:border-gray-600 rounded-lg"
|
|
>
|
|
<span>Sort by:</span>
|
|
<span class="font-medium" x-text="{
|
|
'newest': 'Newest',
|
|
'oldest': 'Oldest',
|
|
'highest': 'Highest Rated',
|
|
'lowest': 'Lowest Rated',
|
|
'helpful': 'Most Helpful'
|
|
}[{{ sort_var }}] || 'Newest'"></span>
|
|
<span x-html="$icon('chevron-down', 'w-4 h-4')"></span>
|
|
</button>
|
|
<div
|
|
x-show="open"
|
|
x-transition
|
|
class="absolute right-0 mt-2 w-40 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-50"
|
|
>
|
|
{% for opt in [
|
|
{'value': 'newest', 'label': 'Newest'},
|
|
{'value': 'oldest', 'label': 'Oldest'},
|
|
{'value': 'highest', 'label': 'Highest Rated'},
|
|
{'value': 'lowest', 'label': 'Lowest Rated'},
|
|
{'value': 'helpful', 'label': 'Most Helpful'}
|
|
] %}
|
|
<button
|
|
type="button"
|
|
@click="{{ sort_var }} = '{{ opt.value }}'; {% if on_sort %}{{ on_sort }};{% endif %} open = false"
|
|
class="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
:class="{{ sort_var }} === '{{ opt.value }}' ? 'text-purple-600 dark:text-purple-400 font-medium' : 'text-gray-700 dark:text-gray-300'"
|
|
>
|
|
{{ opt.label }}
|
|
</button>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{# Loading State #}
|
|
<template x-if="{{ loading_var }}">
|
|
<div class="space-y-6">
|
|
{% for i in range(3) %}
|
|
<div class="animate-pulse">
|
|
<div class="flex items-center gap-3 mb-3">
|
|
<div class="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
|
<div class="flex-1">
|
|
<div class="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded mb-1"></div>
|
|
<div class="h-3 w-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
|
</div>
|
|
<div class="flex gap-0.5">
|
|
{% for j in range(5) %}
|
|
<div class="w-4 h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
<div class="h-4 w-48 bg-gray-200 dark:bg-gray-700 rounded mb-2"></div>
|
|
<div class="space-y-1">
|
|
<div class="h-3 w-full bg-gray-200 dark:bg-gray-700 rounded"></div>
|
|
<div class="h-3 w-3/4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</template>
|
|
|
|
{# Empty State #}
|
|
<template x-if="!{{ loading_var }} && (!{{ reviews_var }} || {{ reviews_var }}.length === 0)">
|
|
<div class="text-center py-8">
|
|
<span x-html="$icon('chat-alt-2', 'w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto')"></span>
|
|
<p class="mt-2 text-gray-500 dark:text-gray-400">{{ empty_message }}</p>
|
|
</div>
|
|
</template>
|
|
|
|
{# Reviews #}
|
|
<template x-if="!{{ loading_var }} && {{ reviews_var }}?.length > 0">
|
|
<div class="space-y-6">
|
|
<template x-for="review in {{ reviews_var }}" :key="review.id">
|
|
{{ review_card(review_var='review') }}
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
{% endmacro %}
|
|
|
|
|
|
{#
|
|
Review Form
|
|
===========
|
|
Form for submitting a product review.
|
|
|
|
Parameters:
|
|
- rating_model: Alpine.js model for rating (default: 'newReview.rating')
|
|
- title_model: Alpine.js model for title (default: 'newReview.title')
|
|
- content_model: Alpine.js model for content (default: 'newReview.content')
|
|
- images_model: Alpine.js model for images (default: 'newReview.images')
|
|
- submitting_var: Alpine.js var for submitting state (default: 'submittingReview')
|
|
- on_submit: Submit handler
|
|
- show_images: Allow image upload (default: true)
|
|
- require_purchase: Show purchase requirement message (default: false)
|
|
|
|
Usage:
|
|
{{ review_form(on_submit='submitReview()') }}
|
|
#}
|
|
{% macro review_form(
|
|
rating_model='newReview.rating',
|
|
title_model='newReview.title',
|
|
content_model='newReview.content',
|
|
images_model='newReview.images',
|
|
submitting_var='submittingReview',
|
|
on_submit='submitReview()',
|
|
show_images=true,
|
|
require_purchase=false
|
|
) %}
|
|
<form
|
|
@submit.prevent="{{ on_submit }}"
|
|
class="space-y-6 bg-gray-50 dark:bg-gray-900 rounded-lg p-6"
|
|
>
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Write a Review</h3>
|
|
|
|
{% if require_purchase %}
|
|
<div class="flex items-start gap-2 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
|
<span x-html="$icon('information-circle', 'w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5')"></span>
|
|
<p class="text-sm text-blue-800 dark:text-blue-300">You must have purchased this product to leave a review.</p>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{# Rating #}
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Your Rating <span class="text-red-500">*</span>
|
|
</label>
|
|
{{ rating_input(model=rating_model, size='lg') }}
|
|
</div>
|
|
|
|
{# Title #}
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Review Title
|
|
</label>
|
|
<input
|
|
type="text"
|
|
x-model="{{ title_model }}"
|
|
placeholder="Sum up your experience"
|
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:border-purple-500 dark:focus:border-purple-400 focus:ring-2 focus:ring-purple-500/20 outline-none"
|
|
>
|
|
</div>
|
|
|
|
{# Content #}
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Your Review <span class="text-red-500">*</span>
|
|
</label>
|
|
<textarea
|
|
x-model="{{ content_model }}"
|
|
rows="4"
|
|
placeholder="Share your experience with this product..."
|
|
required
|
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:border-purple-500 dark:focus:border-purple-400 focus:ring-2 focus:ring-purple-500/20 outline-none resize-none"
|
|
></textarea>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Minimum 50 characters</p>
|
|
</div>
|
|
|
|
{% if show_images %}
|
|
{# Image Upload #}
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Add Photos (Optional)
|
|
</label>
|
|
<div class="flex flex-wrap gap-2">
|
|
<template x-for="(image, index) in {{ images_model }} || []" :key="index">
|
|
<div class="relative w-20 h-20 rounded-lg overflow-hidden group">
|
|
<img loading="lazy" :src="image" alt="Review image" class="w-full h-full object-cover">
|
|
<button
|
|
type="button"
|
|
@click="{{ images_model }}.splice(index, 1)"
|
|
class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity"
|
|
>
|
|
<span x-html="$icon('x', 'w-6 h-6 text-white')"></span>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
<label
|
|
x-show="!{{ images_model }} || {{ images_model }}.length < 5"
|
|
class="w-20 h-20 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg flex items-center justify-center cursor-pointer hover:border-purple-500 dark:hover:border-purple-400 transition-colors"
|
|
>
|
|
<input type="file" accept="image/*" class="hidden" @change="
|
|
const file = $event.target.files[0];
|
|
if (file) {
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
if (!{{ images_model }}) {{ images_model }} = [];
|
|
{{ images_model }}.push(e.target.result);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
">
|
|
<span x-html="$icon('camera', 'w-6 h-6 text-gray-400')"></span>
|
|
</label>
|
|
</div>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Up to 5 photos</p>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{# Submit Button #}
|
|
<div class="flex items-center gap-4">
|
|
<button
|
|
type="submit"
|
|
:disabled="{{ submitting_var }} || !{{ rating_model }} || !{{ content_model }}"
|
|
class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
|
|
>
|
|
<span x-show="!{{ submitting_var }}">Submit Review</span>
|
|
<span x-show="{{ submitting_var }}" class="flex items-center gap-2">
|
|
<span x-html="$icon('refresh', 'w-4 h-4 animate-spin')"></span>
|
|
Submitting...
|
|
</span>
|
|
</button>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
|
By submitting, you agree to our review guidelines.
|
|
</p>
|
|
</div>
|
|
</form>
|
|
{% endmacro %}
|
|
|
|
|
|
{#
|
|
Review Summary Section
|
|
======================
|
|
Complete review summary with rating distribution and write review button.
|
|
|
|
Parameters:
|
|
- rating_var: Alpine.js expression for average rating
|
|
- count_var: Alpine.js expression for total reviews
|
|
- distribution_var: Alpine.js expression for distribution
|
|
- show_write_button: Show write review button (default: true)
|
|
- on_write: Callback for write review button
|
|
|
|
Usage:
|
|
{{ review_summary_section(rating_var='product.rating', count_var='product.review_count', distribution_var='product.rating_distribution') }}
|
|
#}
|
|
{% macro review_summary_section(
|
|
rating_var='rating',
|
|
count_var='reviewCount',
|
|
distribution_var='ratingDistribution',
|
|
show_write_button=true,
|
|
on_write='showReviewForm = true'
|
|
) %}
|
|
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-6">
|
|
<div class="flex flex-col lg:flex-row lg:items-start gap-6">
|
|
{# Rating Summary #}
|
|
<div class="flex-1">
|
|
{{ rating_summary(rating_var=rating_var, count_var=count_var, distribution_var=distribution_var) }}
|
|
</div>
|
|
|
|
{% if show_write_button %}
|
|
{# Write Review CTA #}
|
|
<div class="lg:border-l lg:border-gray-200 dark:lg:border-gray-700 lg:pl-6">
|
|
<div class="text-center">
|
|
<h4 class="font-medium text-gray-900 dark:text-white mb-2">Share your thoughts</h4>
|
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
|
Help others by sharing your experience with this product.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
@click="{{ on_write }}"
|
|
class="inline-flex items-center gap-2 px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors"
|
|
>
|
|
<span x-html="$icon('pencil', 'w-5 h-5')"></span>
|
|
Write a Review
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endmacro %}
|