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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user