spree_product_reviews 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/app/controllers/spree/admin/product_reviews_controller.rb +71 -0
- data/app/controllers/spree/home_controller.rb +7 -0
- data/app/controllers/spree/product_reviews_controller.rb +72 -0
- data/app/models/spree/page_blocks/product_review_form.rb +13 -0
- data/app/models/spree/page_blocks/products/reviews.rb +37 -0
- data/app/models/spree/page_sections/add_a_review.rb +80 -0
- data/app/models/spree/page_sections/product_details_decorator.rb +10 -0
- data/app/models/spree/product_review.rb +73 -0
- data/app/models/spree/product_reviews_ability.rb +25 -0
- data/app/models/spree_product_reviews/spree/product_decorator.rb +13 -0
- data/app/models/spree_product_reviews/spree/product_review_decorator.rb +12 -0
- data/app/models/spree_product_reviews/spree/user_decorator.rb +22 -0
- data/app/views/spree/admin/page_blocks/forms/_product_review_form.html.erb +22 -0
- data/app/views/spree/admin/page_blocks/forms/_reviews.html.erb +52 -0
- data/app/views/spree/admin/page_sections/forms/_add_a_review.html.erb +112 -0
- data/app/views/spree/admin/product_reviews/_product_review.html.erb +89 -0
- data/app/views/spree/admin/product_reviews/_table.html.erb +31 -0
- data/app/views/spree/admin/product_reviews/edit.html.erb +103 -0
- data/app/views/spree/admin/product_reviews/index.html.erb +15 -0
- data/app/views/spree/admin/product_reviews/update.turbo_stream.erb +1 -0
- data/app/views/spree/page_blocks/products/reviews/_reviews.html.erb +42 -0
- data/app/views/spree/page_sections/_add_a_review.html.erb +75 -0
- data/app/views/spree/page_sections/_product_reviews.html.erb +95 -0
- data/app/views/spree/product_reviews/_form.html.erb +275 -0
- data/app/views/spree/product_reviews/_images.html.erb +80 -0
- data/app/views/spree/product_reviews/_pending.html.erb +15 -0
- data/app/views/spree/product_reviews/_review.html.erb +38 -0
- data/app/views/spree/products/_json_ld.html.erb +97 -0
- data/app/views/spree_product_reviews/admin/_product_reviews_dropdown.html.erb +6 -0
- data/app/views/themes/default/spree/page_sections/_product_details.html.erb +89 -0
- data/config/initializers/spree_product_reviews.rb +9 -0
- data/config/locales/en.yml +59 -0
- data/config/routes.rb +20 -0
- data/db/migrate/20250501173852_add_reviews.rb +27 -0
- data/db/migrate/20250501174531_add_review_info_to_products.rb +13 -0
- data/lib/generators/spree_product_reviews/install/install_generator.rb +44 -0
- data/lib/spree_product_reviews/configuration.rb +13 -0
- data/lib/spree_product_reviews/engine.rb +24 -0
- data/lib/spree_product_reviews/factories.rb +6 -0
- data/lib/spree_product_reviews/version.rb +8 -0
- data/lib/spree_product_reviews.rb +5 -0
- metadata +156 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
<% section ||= local_assigns[:section] || @page_section %>
|
|
2
|
+
|
|
3
|
+
<script>
|
|
4
|
+
const MAX_FILES = <%= section&.preferred_max_images_upload || 5 %>;
|
|
5
|
+
const MAX_FILE_SIZE = (<%= section&.preferred_max_image_size_mb || 5 %>) * 1024 * 1024;
|
|
6
|
+
const THUMB_SIZE = <%= section&.preferred_thumbnail_size || 72 %>;
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<div class="review-form-container mb-8 p-4 border border-gray-200 rounded-md shadow-sm bg-white">
|
|
10
|
+
<%= form_for [:product, product_review],
|
|
11
|
+
url: spree.product_product_reviews_path(@product),
|
|
12
|
+
html: { multipart: true, data: { turbo: false } } do |f| %>
|
|
13
|
+
<% if product_review.errors.any? %>
|
|
14
|
+
<div class="error-messages bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
|
15
|
+
<h2 class="font-bold mb-2"><%= pluralize(product_review.errors.count, "error") %> prohibited this review from being saved:</h2>
|
|
16
|
+
<ul class="list-disc list-inside">
|
|
17
|
+
<% product_review.errors.full_messages.each do |message| %>
|
|
18
|
+
<li><%= message %></li>
|
|
19
|
+
<% end %>
|
|
20
|
+
</ul>
|
|
21
|
+
</div>
|
|
22
|
+
<% end %>
|
|
23
|
+
|
|
24
|
+
<style>
|
|
25
|
+
.rating-group {
|
|
26
|
+
display: inline-flex;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.rating__icon {
|
|
30
|
+
pointer-events: none;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.rating__input {
|
|
34
|
+
position: absolute !important;
|
|
35
|
+
left: -9999px !important;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.rating__input--none {
|
|
39
|
+
display: none;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.rating__label {
|
|
43
|
+
cursor: pointer;
|
|
44
|
+
padding: 0 0.1em;
|
|
45
|
+
font-size: 2rem;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.rating__icon--star {
|
|
49
|
+
color: orange;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.rating__input:checked ~ .rating__label .rating__icon--star {
|
|
53
|
+
color: #ddd;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.rating-group:hover .rating__label .rating__icon--star {
|
|
57
|
+
color: orange;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.rating__input:hover ~ .rating__label .rating__icon--star {
|
|
61
|
+
color: #ddd;
|
|
62
|
+
}
|
|
63
|
+
</style>
|
|
64
|
+
|
|
65
|
+
<div class="form-group mb-4">
|
|
66
|
+
<%= f.label :rating, Spree.t("product_review_rating"), class: "block mb-2 font-medium text-gray-700" %>
|
|
67
|
+
<div class="rating-group">
|
|
68
|
+
<%# Hidden input to ensure no default selection - user must choose a rating %>
|
|
69
|
+
<input class="rating__input rating__input--none" disabled checked name="<%= f.object_name %>[rating]" id="<%= f.object_name %>_rating_none" value="0" type="radio">
|
|
70
|
+
|
|
71
|
+
<% (1..5).each do |i| %>
|
|
72
|
+
<label aria-label="<%= i %> <%= 'star'.pluralize(i) %>" class="rating__label" for="<%= f.object_name %>_rating_<%= i %>">
|
|
73
|
+
<span class="rating__icon rating__icon--star">★</span>
|
|
74
|
+
</label>
|
|
75
|
+
<%= f.radio_button :rating, i, id: "#{f.object_name}_rating_#{i}", required: true, class: "rating__input" %>
|
|
76
|
+
<% end %>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div class="form-group mb-4">
|
|
81
|
+
<%= f.label :title, Spree.t("review_title"), class: "block mb-2 font-medium" %>
|
|
82
|
+
<%= f.text_field :title, required: true, class: "focus:border-primary focus:ring-primary text-base bg-accent rounded-md border-accent py-2 px-4 w-full" %>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<div class="form-group mb-4">
|
|
86
|
+
<%= f.label :review, Spree.t("page_blocks.product_review_form.placeholder_default"), class: "block mb-2 font-medium" %>
|
|
87
|
+
<%= f.text_area :review, required: true, class: "focus:border-primary focus:ring-primary text-base bg-accent rounded-md border-accent py-2 px-4 w-full", rows: 4, placeholder: block.preferred_placeholder %>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<div class="form-group mb-4">
|
|
91
|
+
<%= f.label :images, "Upload images (optional)", class: "block mb-2 font-medium text-gray-700" %>
|
|
92
|
+
|
|
93
|
+
<%= f.file_field :images,
|
|
94
|
+
multiple: true,
|
|
95
|
+
accept: "image/*",
|
|
96
|
+
direct_upload: true,
|
|
97
|
+
class: "hidden",
|
|
98
|
+
id: "review-image-upload" %>
|
|
99
|
+
|
|
100
|
+
<label
|
|
101
|
+
for="review-image-upload"
|
|
102
|
+
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium
|
|
103
|
+
border border-gray-300 rounded-md cursor-pointer
|
|
104
|
+
bg-white text-gray-700
|
|
105
|
+
hover:bg-gray-50 hover:text-gray-900
|
|
106
|
+
transition select-none">
|
|
107
|
+
|
|
108
|
+
<!-- Upload icon -->
|
|
109
|
+
<svg
|
|
110
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
111
|
+
viewBox="0 0 16 16"
|
|
112
|
+
class="w-4 h-4 flex-shrink-0"
|
|
113
|
+
fill="currentColor"
|
|
114
|
+
aria-hidden="true">
|
|
115
|
+
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5"/>
|
|
116
|
+
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708z"/>
|
|
117
|
+
</svg>
|
|
118
|
+
|
|
119
|
+
<!-- Text -->
|
|
120
|
+
<span>Upload images</span>
|
|
121
|
+
</label>
|
|
122
|
+
|
|
123
|
+
<p class="mt-1 text-xs text-gray-400">
|
|
124
|
+
Max <%= section&.preferred_max_images_upload || 5 %> images · JPG / PNG / GIF · Max <%= section&.preferred_max_image_size_mb || 5 %>MB each
|
|
125
|
+
</p>
|
|
126
|
+
|
|
127
|
+
<!-- Validation errors -->
|
|
128
|
+
<div id="review-image-errors"
|
|
129
|
+
class="hidden mt-2 text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<!-- Preview grid -->
|
|
133
|
+
<div id="review-image-preview"
|
|
134
|
+
class="mt-3 grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3">
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<!-- Upload progress -->
|
|
138
|
+
<div id="review-upload-progress" class="hidden mt-3">
|
|
139
|
+
<div class="h-1.5 w-full bg-gray-200 rounded">
|
|
140
|
+
<div id="review-upload-bar"
|
|
141
|
+
class="h-1.5 bg-primary-600 rounded transition-all"
|
|
142
|
+
style="width:0%">
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
<p class="text-xs text-gray-500 mt-1">Uploading images…</p>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<div class="form-group mb-4">
|
|
150
|
+
<div class="flex items-center">
|
|
151
|
+
<%= f.check_box :show_identifier, class: "mr-2" %>
|
|
152
|
+
<%= f.label :show_identifier, "Show my name in the review", class: "text-sm text-gray-600" %>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
<% button_class = "btn-primary" %>
|
|
157
|
+
<% button_class = "btn-secondary" if block.preferred_button_style == "secondary" %>
|
|
158
|
+
<%= f.submit block.preferred_button_text, class: "#{button_class} py-2 px-6 rounded-md text-white font-medium" %>
|
|
159
|
+
<% end %>
|
|
160
|
+
</div>
|
|
161
|
+
<script>
|
|
162
|
+
(() => {
|
|
163
|
+
const MAX_FILES = <%= section&.preferred_max_images_upload || 5 %>
|
|
164
|
+
const MAX_FILE_SIZE = (<%= section&.preferred_max_image_size_mb || 5 %>) * 1024 * 1024
|
|
165
|
+
|
|
166
|
+
const input = document.getElementById("review-image-upload")
|
|
167
|
+
const preview = document.getElementById("review-image-preview")
|
|
168
|
+
const errors = document.getElementById("review-image-errors")
|
|
169
|
+
const progressWrapper = document.getElementById("review-upload-progress")
|
|
170
|
+
const progressBar = document.getElementById("review-upload-bar")
|
|
171
|
+
|
|
172
|
+
if (!input) return
|
|
173
|
+
|
|
174
|
+
let filesStore = []
|
|
175
|
+
|
|
176
|
+
function showError(message) {
|
|
177
|
+
errors.textContent = message
|
|
178
|
+
errors.classList.remove("hidden")
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function clearError() {
|
|
182
|
+
errors.textContent = ""
|
|
183
|
+
errors.classList.add("hidden")
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function syncInputFiles() {
|
|
187
|
+
const dt = new DataTransfer()
|
|
188
|
+
filesStore.forEach(f => dt.items.add(f))
|
|
189
|
+
input.files = dt.files
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function renderPreviews() {
|
|
193
|
+
preview.innerHTML = ""
|
|
194
|
+
|
|
195
|
+
filesStore.forEach((file, index) => {
|
|
196
|
+
const wrapper = document.createElement("div")
|
|
197
|
+
wrapper.className =
|
|
198
|
+
"relative group overflow-hidden h-24 " +
|
|
199
|
+
"flex items-center justify-center rounded border bg-gray-100"
|
|
200
|
+
|
|
201
|
+
const img = document.createElement("img")
|
|
202
|
+
img.src = URL.createObjectURL(file)
|
|
203
|
+
img.className =
|
|
204
|
+
"w-full h-full object-cover"
|
|
205
|
+
img.loading = "lazy"
|
|
206
|
+
|
|
207
|
+
const removeBtn = document.createElement("button")
|
|
208
|
+
removeBtn.type = "button"
|
|
209
|
+
removeBtn.innerHTML = "×"
|
|
210
|
+
|
|
211
|
+
removeBtn.className =
|
|
212
|
+
"absolute top-1 right-1 z-50 pointer-events-auto " +
|
|
213
|
+
"w-8 h-8 flex items-center justify-center " +
|
|
214
|
+
"rounded-full text-gray-900 shadow-md " +
|
|
215
|
+
"hover:bg-white transition"
|
|
216
|
+
|
|
217
|
+
removeBtn.style.background = "rgba(255,255,255,0.45)"
|
|
218
|
+
removeBtn.style.backdropFilter = "blur(8px)"
|
|
219
|
+
removeBtn.style.webkitBackdropFilter = "blur(8px)"
|
|
220
|
+
|
|
221
|
+
removeBtn.addEventListener("click", () => {
|
|
222
|
+
filesStore.splice(index, 1)
|
|
223
|
+
syncInputFiles()
|
|
224
|
+
renderPreviews()
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
wrapper.appendChild(img)
|
|
228
|
+
wrapper.appendChild(removeBtn)
|
|
229
|
+
preview.appendChild(wrapper)
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
input.addEventListener("change", () => {
|
|
234
|
+
clearError()
|
|
235
|
+
|
|
236
|
+
const newFiles = Array.from(input.files)
|
|
237
|
+
|
|
238
|
+
if (filesStore.length + newFiles.length > MAX_FILES) {
|
|
239
|
+
showError(`You can upload a maximum of ${MAX_FILES} images.`)
|
|
240
|
+
input.value = ""
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
for (const file of newFiles) {
|
|
245
|
+
if (file.size > MAX_FILE_SIZE) {
|
|
246
|
+
showError(`"${file.name}" exceeds 5MB.`)
|
|
247
|
+
input.value = ""
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
filesStore = filesStore.concat(newFiles)
|
|
253
|
+
syncInputFiles()
|
|
254
|
+
renderPreviews()
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
/* ActiveStorage progress */
|
|
258
|
+
document.addEventListener("direct-upload:initialize", () => {
|
|
259
|
+
progressWrapper.classList.remove("hidden")
|
|
260
|
+
progressBar.style.width = "0%"
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
document.addEventListener("direct-upload:progress", e => {
|
|
264
|
+
progressBar.style.width = `${e.detail.progress}%`
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
document.addEventListener("direct-upload:end", () => {
|
|
268
|
+
progressBar.style.width = "100%"
|
|
269
|
+
setTimeout(() => {
|
|
270
|
+
progressWrapper.classList.add("hidden")
|
|
271
|
+
progressBar.style.width = "0%"
|
|
272
|
+
}, 400)
|
|
273
|
+
})
|
|
274
|
+
})()
|
|
275
|
+
</script>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<%
|
|
2
|
+
section ||= local_assigns[:section]
|
|
3
|
+
max_images = section&.preferred_max_images_upload || 5
|
|
4
|
+
thumb_size = section&.preferred_thumbnail_size || 72
|
|
5
|
+
images = review.images.select(&:blob).first(max_images)
|
|
6
|
+
return if images.empty?
|
|
7
|
+
many_images = images.size > 4
|
|
8
|
+
cols =
|
|
9
|
+
images.size == 1 ? 'grid-cols-1' :
|
|
10
|
+
images.size == 2 ? 'grid-cols-2' :
|
|
11
|
+
images.size == 3 ? 'grid-cols-3' :
|
|
12
|
+
'grid-cols-3 sm:grid-cols-4'
|
|
13
|
+
%>
|
|
14
|
+
|
|
15
|
+
<div class="mt-2 w-full">
|
|
16
|
+
|
|
17
|
+
<%# --- Swiper slider for medium+ screens --- %>
|
|
18
|
+
<div
|
|
19
|
+
class="hidden md:block"
|
|
20
|
+
data-controller="swiper lightbox"
|
|
21
|
+
data-swiper-options-value='{"slidesPerView":"auto","spaceBetween":4,"freeMode":true}'
|
|
22
|
+
>
|
|
23
|
+
<div class="swiper h-[70px] w-full overflow-hidden cursor-grab active:cursor-grabbing">
|
|
24
|
+
<div class="swiper-wrapper flex gap-1 pr-6">
|
|
25
|
+
<% images.each do |image| %>
|
|
26
|
+
<% full_w = image.blob.metadata[:width] || 2000 %>
|
|
27
|
+
<% full_h = image.blob.metadata[:height] || 2000 %>
|
|
28
|
+
<% thumb = image.variant(
|
|
29
|
+
resize_to_fill: [thumb_size, thumb_size],
|
|
30
|
+
saver: { quality: 70 }
|
|
31
|
+
) %>
|
|
32
|
+
|
|
33
|
+
<div class="swiper-slide !h-[<%= thumb_size %>px]" style="width:<%= thumb_size %>px; border-radius:10%; overflow:hidden;">
|
|
34
|
+
<%= link_to(
|
|
35
|
+
main_app.rails_blob_path(image, only_path: true),
|
|
36
|
+
data: { pswp_width: full_w, pswp_height: full_h },
|
|
37
|
+
class: "block w-full h-full overflow-hidden rounded-xl border border-gray-200"
|
|
38
|
+
) do %>
|
|
39
|
+
<%= image_tag(
|
|
40
|
+
main_app.rails_representation_path(thumb, only_path: true),
|
|
41
|
+
width: thumb_size,
|
|
42
|
+
height: thumb_size,
|
|
43
|
+
loading: "lazy",
|
|
44
|
+
decoding: "async",
|
|
45
|
+
class: "w-full h-full object-cover rounded-xl"
|
|
46
|
+
) %>
|
|
47
|
+
<% end %>
|
|
48
|
+
</div>
|
|
49
|
+
<% end %>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<%# --- Grid for mobile / small screens --- %>
|
|
55
|
+
<div class="md:hidden grid gap-1 <%= cols %>" data-controller="lightbox">
|
|
56
|
+
<% images.each do |image| %>
|
|
57
|
+
<% full_w = image.blob.metadata[:width] || 2000 %>
|
|
58
|
+
<% full_h = image.blob.metadata[:height] || 2000 %>
|
|
59
|
+
<% thumb = image.variant(
|
|
60
|
+
resize_to_fill: [thumb_size, thumb_size],
|
|
61
|
+
saver: { quality: 70 }
|
|
62
|
+
) %>
|
|
63
|
+
|
|
64
|
+
<%= link_to(
|
|
65
|
+
main_app.rails_blob_path(image, only_path: true),
|
|
66
|
+
data: { pswp_width: full_w, pswp_height: full_h },
|
|
67
|
+
class: "aspect-square block overflow-hidden rounded-xl border border-gray-200",
|
|
68
|
+
style: "border-radius: 10px;"
|
|
69
|
+
) do %>
|
|
70
|
+
<%= image_tag(
|
|
71
|
+
main_app.rails_representation_path(thumb, only_path: true),
|
|
72
|
+
loading: "lazy",
|
|
73
|
+
decoding: "async",
|
|
74
|
+
class: "w-full h-full object-cover rounded-xl"
|
|
75
|
+
) %>
|
|
76
|
+
<% end %>
|
|
77
|
+
<% end %>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
</div>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<% return unless defined?(product_review) && product_review.present? %>
|
|
2
|
+
<div class="mx-auto py-8">
|
|
3
|
+
<div class="review-form-container mb-8 p-4 border border-gray-200 rounded-md shadow-sm">
|
|
4
|
+
<% if product_review.approved? %>
|
|
5
|
+
<p class="text-sm text-green-600 mb-4">
|
|
6
|
+
<%= section&.preferred_heading_review_approved.presence || "Thanks for your review!" %>
|
|
7
|
+
</p>
|
|
8
|
+
<% else %>
|
|
9
|
+
<p class="text-sm text-gray-600 mb-4">
|
|
10
|
+
<%= section&.preferred_heading_pending_review.presence || "Thanks for your review! It is currently pending approval." %>
|
|
11
|
+
</p>
|
|
12
|
+
<% end %>
|
|
13
|
+
<%= render "spree/product_reviews/review", product_review: product_review %>
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<% return unless defined?(product_review) && product_review.present? %>
|
|
2
|
+
|
|
3
|
+
<hr class="my-6 border-gray-200 dark:border-gray-700" />
|
|
4
|
+
|
|
5
|
+
<div class="gap-3 p-4 sm:flex sm:items-start">
|
|
6
|
+
<div class="shrink-0 space-y-2 sm:w-48 md:w-72">
|
|
7
|
+
<div class="flex items-center gap-0.5">
|
|
8
|
+
<% product_review.rating.times do %>
|
|
9
|
+
<%#= icon("star", class: "h-4 w-4 text-yellow-300") %>
|
|
10
|
+
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="#FFA500" class="h-4 w-4 text-yellow-300 icon icon-tabler icons-tabler-filled icon-tabler-star"><path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
|
11
|
+
<path d="M8.243 7.34l-6.38 .925l-.113 .023a1 1 0 0 0 -.44 1.684l4.622 4.499l-1.09 6.355l-.013 .11a1 1 0 0 0 1.464 .944l5.706 -3l5.693 3l.1 .046a1 1 0 0 0 1.352 -1.1l-1.091 -6.355l4.624 -4.5l.078 -.085a1 1 0 0 0 -.633 -1.62l-6.38 -.926l-2.852 -5.78a1 1 0 0 0 -1.794 0l-2.853 5.78z" />
|
|
12
|
+
</svg>
|
|
13
|
+
<% end %>
|
|
14
|
+
<span class="sr-only">Rating: <%= product_review.rating %> out of 5</span>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<div class="space-y-0.5">
|
|
18
|
+
<p class="text-base font-semibold text-gray-900 dark:text-white"><%= product_review.reviewer_name %></p>
|
|
19
|
+
<p class="text-sm font-normal text-gray-500 dark:text-gray-400"><%= product_review.review_date %></p>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<% if product_review.purchase_date.present? %>
|
|
23
|
+
<div class="inline-flex items-center gap-1">
|
|
24
|
+
<%#= icon("rosette-discount-check", class: "h-5 w-5 text-primary-700 dark:text-primary-500") %>
|
|
25
|
+
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="oklch(62.7% .194 149.214)" class="h-5 w-5 text-primary-700 dark:text-primary-500 icon icon-tabler icons-tabler-filled icon-tabler-rosette-discount-check">
|
|
26
|
+
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
|
27
|
+
<path d="M12.01 2.011a3.2 3.2 0 0 1 2.113 .797l.154 .145l.698 .698a1.2 1.2 0 0 0 .71 .341l.135 .008h1a3.2 3.2 0 0 1 3.195 3.018l.005 .182v1c0 .27 .092 .533 .258 .743l.09 .1l.697 .698a3.2 3.2 0 0 1 .147 4.382l-.145 .154l-.698 .698a1.2 1.2 0 0 0 -.341 .71l-.008 .135v1a3.2 3.2 0 0 1 -3.018 3.195l-.182 .005h-1a1.2 1.2 0 0 0 -.743 .258l-.1 .09l-.698 .697a3.2 3.2 0 0 1 -4.382 .147l-.154 -.145l-.698 -.698a1.2 1.2 0 0 0 -.71 -.341l-.135 -.008h-1a3.2 3.2 0 0 1 -3.195 -3.018l-.005 -.182v-1a1.2 1.2 0 0 0 -.258 -.743l-.09 -.1l-.697 -.698a3.2 3.2 0 0 1 -.147 -4.382l.145 -.154l.698 -.698a1.2 1.2 0 0 0 .341 -.71l.008 -.135v-1l.005 -.182a3.2 3.2 0 0 1 3.013 -3.013l.182 -.005h1a1.2 1.2 0 0 0 .743 -.258l.1 -.09l.698 -.697a3.2 3.2 0 0 1 2.269 -.944zm3.697 7.282a1 1 0 0 0 -1.414 0l-3.293 3.292l-1.293 -1.292l-.094 -.083a1 1 0 0 0 -1.32 1.497l2 2l.094 .083a1 1 0 0 0 1.32 -.083l4 -4l.083 -.094a1 1 0 0 0 -.083 -1.32z" />
|
|
28
|
+
</svg>
|
|
29
|
+
<p class="text-sm font-medium text-green-600 dark:text-white">Verified purchase</p>
|
|
30
|
+
</div>
|
|
31
|
+
<% end %>
|
|
32
|
+
</div>
|
|
33
|
+
<div class="mt-4 min-w-0 flex-1 space-y-4 sm:mt-0">
|
|
34
|
+
<h3 class="text-lg font-semibold"><%= product_review.title %></h3>
|
|
35
|
+
<p class="text-base font-normal"><%= product_review.review %></p>
|
|
36
|
+
<%= render "spree/product_reviews/images", review: product_review %>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<script type="application/ld+json" data-test-id="product-json-ld">
|
|
2
|
+
<% first_or_default_variant = product.first_or_default_variant(current_currency) %>
|
|
3
|
+
<% approved_reviews = product.product_reviews.where(approved: true) %>
|
|
4
|
+
|
|
5
|
+
{
|
|
6
|
+
"@context": "https://schema.org/",
|
|
7
|
+
"@type": "Product",
|
|
8
|
+
"name": <%= product.name.to_json.html_safe %>,
|
|
9
|
+
"url": <%= spree.product_url(product, host: current_store.url_or_custom_domain).to_json.html_safe %>,
|
|
10
|
+
|
|
11
|
+
<% if product.featured_image %>
|
|
12
|
+
"image": [
|
|
13
|
+
<%= spree_image_url(product.featured_image, width: 630, height: 630).to_json.html_safe %>
|
|
14
|
+
],
|
|
15
|
+
<% end %>
|
|
16
|
+
|
|
17
|
+
<% if product.description.present? %>
|
|
18
|
+
"description": <%= strip_tags(product.description).to_json.html_safe %>,
|
|
19
|
+
<% end %>
|
|
20
|
+
|
|
21
|
+
<% if !product.has_variants? %>
|
|
22
|
+
<% if first_or_default_variant.sku.present? %>
|
|
23
|
+
"sku": <%= first_or_default_variant.sku.to_json.html_safe %>,
|
|
24
|
+
<% end %>
|
|
25
|
+
<% elsif selected_variant %>
|
|
26
|
+
<% if selected_variant.sku.present? %>
|
|
27
|
+
"sku": <%= selected_variant.sku.to_json.html_safe %>,
|
|
28
|
+
<% end %>
|
|
29
|
+
<% end %>
|
|
30
|
+
|
|
31
|
+
<% if product.brand %>
|
|
32
|
+
"brand": {
|
|
33
|
+
"@type": "Brand",
|
|
34
|
+
"name": <%= product.brand_name.to_json.html_safe %>
|
|
35
|
+
},
|
|
36
|
+
<% end %>
|
|
37
|
+
|
|
38
|
+
<%# --- Inject Aggregate Rating & Reviews if available --- %>
|
|
39
|
+
<% if approved_reviews.any? %>
|
|
40
|
+
"aggregateRating": {
|
|
41
|
+
"@type": "AggregateRating",
|
|
42
|
+
"ratingValue": <%= approved_reviews.average(:rating).to_f.round(1) %>,
|
|
43
|
+
"reviewCount": <%= approved_reviews.count %>
|
|
44
|
+
},
|
|
45
|
+
"review": <%= raw(
|
|
46
|
+
approved_reviews.limit(5).map do |review|
|
|
47
|
+
{
|
|
48
|
+
"@type": "Review",
|
|
49
|
+
author: {
|
|
50
|
+
"@type": "Person",
|
|
51
|
+
name: review.user&.email&.split('@')&.first || "Anonymous"
|
|
52
|
+
},
|
|
53
|
+
reviewRating: {
|
|
54
|
+
"@type": "Rating",
|
|
55
|
+
ratingValue: review.rating,
|
|
56
|
+
bestRating: 5
|
|
57
|
+
},
|
|
58
|
+
reviewBody: review.review,
|
|
59
|
+
datePublished: review.created_at.strftime("%Y-%m-%d")
|
|
60
|
+
}
|
|
61
|
+
end.to_json
|
|
62
|
+
) %>,
|
|
63
|
+
<% end %>
|
|
64
|
+
|
|
65
|
+
<% if product.has_variants? %>
|
|
66
|
+
"offers": {
|
|
67
|
+
"@type": "AggregateOffer",
|
|
68
|
+
"lowPrice": <%= product.variants.map(&:price).min.to_f.round(2) %>,
|
|
69
|
+
"highPrice": <%= product.variants.map(&:price).max.to_f.round(2) %>,
|
|
70
|
+
"offerCount": <%= product.variants.count %>,
|
|
71
|
+
"priceCurrency": <%= current_currency.to_json.html_safe %>,
|
|
72
|
+
"offers": [
|
|
73
|
+
<%= raw(
|
|
74
|
+
product.variants.map { |variant|
|
|
75
|
+
render(
|
|
76
|
+
partial: "spree/products/json_ld_variant",
|
|
77
|
+
locals: { product: product, variant: variant }
|
|
78
|
+
).strip
|
|
79
|
+
}.join(",")
|
|
80
|
+
) %>
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
<% else %>
|
|
84
|
+
"offers": {
|
|
85
|
+
"@type": "Offer",
|
|
86
|
+
"price": <%= first_or_default_variant.price.to_f.round(2) %>,
|
|
87
|
+
"priceCurrency": <%= current_currency.to_json.html_safe %>,
|
|
88
|
+
"availability": "https://schema.org/<%= first_or_default_variant.in_stock? ? 'InStock' : 'OutOfStock' %>",
|
|
89
|
+
"url": <%= spree.product_url(product, host: current_store.url_or_custom_domain).to_json.html_safe %>
|
|
90
|
+
}
|
|
91
|
+
<% end %>
|
|
92
|
+
}
|
|
93
|
+
</script>
|
|
94
|
+
|
|
95
|
+
<script type="application/ld+json">
|
|
96
|
+
<%= product_json_ld_breadcrumbs(product).to_json.html_safe %>
|
|
97
|
+
</script>
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<turbo-frame id="main-product-<%= product.id %>" target="_top">
|
|
2
|
+
<% current_variant = @selected_variant || @variant_from_options || product.first_or_default_variant(current_currency) %>
|
|
3
|
+
<div class="main-product-container" style="<%= section_styles(section) %>">
|
|
4
|
+
<div
|
|
5
|
+
class="page-container lg:mb-16"
|
|
6
|
+
<%= 'data-controller=product-form' %>
|
|
7
|
+
data-product-form-required-options-value='<%= product.option_type_ids.map(&:to_s).to_json %>'
|
|
8
|
+
data-product-form-selected-variant-disabled-value='<%= !@selected_variant&.in_stock? %>'
|
|
9
|
+
data-product-form-variant-from-options-disabled-value='<%= !@variant_from_options&.in_stock? %>'
|
|
10
|
+
data-product-form-frame-name-value="main-product-<%= product.id %>"
|
|
11
|
+
data-product-form-url-value="<%= spree.product_url(product) %>">
|
|
12
|
+
<template data-product-form-target="spinnerTemplate">
|
|
13
|
+
<%= render "spree/shared/icons/spinner" %>
|
|
14
|
+
</template>
|
|
15
|
+
|
|
16
|
+
<div id="product-details-page" class="grid grid-cols-1 lg:grid-cols-12 gap-x-14">
|
|
17
|
+
<% images = product_media_gallery_images(product, selected_variant: @selected_variant, variant_from_options: @variant_from_options) %>
|
|
18
|
+
|
|
19
|
+
<div class="lg:col-span-7 relative">
|
|
20
|
+
<div class="lg:hidden mb-6">
|
|
21
|
+
<%= render 'spree/products/media_gallery', images: images, product: product %>
|
|
22
|
+
</div>
|
|
23
|
+
<div class="hidden lg:block" data-product-form-target="desktopMediaGallery">
|
|
24
|
+
<%= render 'spree/products/media_gallery', images: images, desktop: true, product: product %>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<div class="lg:col-span-5 lg:col-start-8">
|
|
29
|
+
<% show_waitlist_modal = spree.respond_to?(:waitlists_path) && current_variant.present? %>
|
|
30
|
+
<div
|
|
31
|
+
<% if show_waitlist_modal %>
|
|
32
|
+
data-controller="modal"
|
|
33
|
+
<% end %>
|
|
34
|
+
data-modal-allow-background-close="true"
|
|
35
|
+
class="h-full w-full waitlist-modal"
|
|
36
|
+
data-modal-backdrop-color-value="rgba(0,0,0,0.32)">
|
|
37
|
+
<%= form_with(url: spree.line_items_path, method: :post, data: { controller: "turbo-stream-form", product_form_target: "form" }) do |f| %>
|
|
38
|
+
<%= hidden_field_tag :variant_id, current_variant&.id %>
|
|
39
|
+
|
|
40
|
+
<div data-product-form-target="productDetails">
|
|
41
|
+
<% section.blocks.each do |block| %>
|
|
42
|
+
<div <%= block_attributes(block) %>>
|
|
43
|
+
<% case block.class.name %>
|
|
44
|
+
<% when 'Spree::PageBlocks::Products::Title' %>
|
|
45
|
+
<h1 class="text-2xl uppercase tracking-tight font-medium">
|
|
46
|
+
<%= product.name %>
|
|
47
|
+
</h1>
|
|
48
|
+
<% when 'Spree::PageBlocks::Products::Brand' %>
|
|
49
|
+
<% if product.brand_taxon %>
|
|
50
|
+
<%= link_to spree.nested_taxons_path(product.brand_taxon), title: product.brand_name do %>
|
|
51
|
+
<h3 class="text-sm lg:mt-0 inline-block mb-1">
|
|
52
|
+
<%= product.brand_name %>
|
|
53
|
+
</h3>
|
|
54
|
+
<% end %>
|
|
55
|
+
<% end %>
|
|
56
|
+
<% when 'Spree::PageBlocks::Products::Price' %>
|
|
57
|
+
<%= render 'spree/products/price', product: product, use_variant: true, selected_variant: @selected_variant, price_class: "lg:text-lg lg:font-medium", price_container_class: "w-full" %>
|
|
58
|
+
<% when 'Spree::PageBlocks::Products::VariantPicker' %>
|
|
59
|
+
<%= render 'spree/products/variant_picker', product: product, selected_variant: @selected_variant %>
|
|
60
|
+
<% when 'Spree::PageBlocks::Products::QuantitySelector' %>
|
|
61
|
+
<%= render 'spree/products/quantity_selector', product: product, selected_variant: @selected_variant %>
|
|
62
|
+
<% when 'Spree::PageBlocks::Products::BuyButtons' %>
|
|
63
|
+
<div class="flex w-full" data-controller='sticky-button'>
|
|
64
|
+
<%= render 'spree/products/add_to_cart_button', product: product, selected_variant: @selected_variant, sticky_button_classes: "w-full" %>
|
|
65
|
+
<%= render 'spree/products/add_to_wishlist', variant: current_variant, css_classes: 'btn-secondary ml-5 h-12 !py-0 !px-3 border-default', icon_size: 24 %>
|
|
66
|
+
</div>
|
|
67
|
+
<% when 'Spree::PageBlocks::Products::Description' %>
|
|
68
|
+
<%= render 'spree/products/description', product: product, block: block, section: section %>
|
|
69
|
+
<% when 'Spree::PageBlocks::Metafields' %>
|
|
70
|
+
<%= render 'spree/products/metafields', product: product, block: block, section: section %>
|
|
71
|
+
<% when 'Spree::PageBlocks::Products::RazorpayAffordability' %>
|
|
72
|
+
<%= block.render(self, product: product) %>
|
|
73
|
+
|
|
74
|
+
<% when 'Spree::PageBlocks::Products::Reviews' %>
|
|
75
|
+
<%= block.render(self, product: product) %>
|
|
76
|
+
<% end %>
|
|
77
|
+
</div>
|
|
78
|
+
<% end %>
|
|
79
|
+
</div>
|
|
80
|
+
<% end %>
|
|
81
|
+
|
|
82
|
+
<%= render 'spree/products/add_to_waitlist', variant: current_variant if show_waitlist_modal %>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
<%= render 'spree/products/json_ld', product: product, selected_variant: @selected_variant %>
|
|
89
|
+
</turbo-frame>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Rails.application.config.after_initialize do
|
|
2
|
+
Rails.application.config.spree_admin.product_dropdown_partials << "spree_product_reviews/admin/product_reviews_dropdown"
|
|
3
|
+
Spree::Ability.register_ability(Spree::ProductReviewsAbility)
|
|
4
|
+
Spree::Ability.register_ability(Spree::ProductReviewsAbility)
|
|
5
|
+
|
|
6
|
+
Rails.application.config.spree.page_sections << Spree::PageSections::AddAReview
|
|
7
|
+
Rails.application.config.spree.page_blocks << Spree::PageBlocks::ProductReviewForm
|
|
8
|
+
end
|
|
9
|
+
|