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.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/app/controllers/spree/admin/product_reviews_controller.rb +71 -0
  3. data/app/controllers/spree/home_controller.rb +7 -0
  4. data/app/controllers/spree/product_reviews_controller.rb +72 -0
  5. data/app/models/spree/page_blocks/product_review_form.rb +13 -0
  6. data/app/models/spree/page_blocks/products/reviews.rb +37 -0
  7. data/app/models/spree/page_sections/add_a_review.rb +80 -0
  8. data/app/models/spree/page_sections/product_details_decorator.rb +10 -0
  9. data/app/models/spree/product_review.rb +73 -0
  10. data/app/models/spree/product_reviews_ability.rb +25 -0
  11. data/app/models/spree_product_reviews/spree/product_decorator.rb +13 -0
  12. data/app/models/spree_product_reviews/spree/product_review_decorator.rb +12 -0
  13. data/app/models/spree_product_reviews/spree/user_decorator.rb +22 -0
  14. data/app/views/spree/admin/page_blocks/forms/_product_review_form.html.erb +22 -0
  15. data/app/views/spree/admin/page_blocks/forms/_reviews.html.erb +52 -0
  16. data/app/views/spree/admin/page_sections/forms/_add_a_review.html.erb +112 -0
  17. data/app/views/spree/admin/product_reviews/_product_review.html.erb +89 -0
  18. data/app/views/spree/admin/product_reviews/_table.html.erb +31 -0
  19. data/app/views/spree/admin/product_reviews/edit.html.erb +103 -0
  20. data/app/views/spree/admin/product_reviews/index.html.erb +15 -0
  21. data/app/views/spree/admin/product_reviews/update.turbo_stream.erb +1 -0
  22. data/app/views/spree/page_blocks/products/reviews/_reviews.html.erb +42 -0
  23. data/app/views/spree/page_sections/_add_a_review.html.erb +75 -0
  24. data/app/views/spree/page_sections/_product_reviews.html.erb +95 -0
  25. data/app/views/spree/product_reviews/_form.html.erb +275 -0
  26. data/app/views/spree/product_reviews/_images.html.erb +80 -0
  27. data/app/views/spree/product_reviews/_pending.html.erb +15 -0
  28. data/app/views/spree/product_reviews/_review.html.erb +38 -0
  29. data/app/views/spree/products/_json_ld.html.erb +97 -0
  30. data/app/views/spree_product_reviews/admin/_product_reviews_dropdown.html.erb +6 -0
  31. data/app/views/themes/default/spree/page_sections/_product_details.html.erb +89 -0
  32. data/config/initializers/spree_product_reviews.rb +9 -0
  33. data/config/locales/en.yml +59 -0
  34. data/config/routes.rb +20 -0
  35. data/db/migrate/20250501173852_add_reviews.rb +27 -0
  36. data/db/migrate/20250501174531_add_review_info_to_products.rb +13 -0
  37. data/lib/generators/spree_product_reviews/install/install_generator.rb +44 -0
  38. data/lib/spree_product_reviews/configuration.rb +13 -0
  39. data/lib/spree_product_reviews/engine.rb +24 -0
  40. data/lib/spree_product_reviews/factories.rb +6 -0
  41. data/lib/spree_product_reviews/version.rb +8 -0
  42. data/lib/spree_product_reviews.rb +5 -0
  43. 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 = "&times;"
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,6 @@
1
+ <%= link_to_with_icon(
2
+ "bubble-text",
3
+ "Reviews",
4
+ spree.admin_product_product_reviews_path(@product),
5
+ class: "dropdown-item",
6
+ ) %>
@@ -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
+