Customer Reviews
blockcommerceDedicated customer reviews section with ratings breakdown, interactive distribution bars, photo gallery with thumbnail overlay, filter chips, sort options, detailed verified buyer cards with attached photos, reactive helpful counter, and review submission form.
Installation
$pnpm dlx shadcn@latest add https://uipkge.dev/r/lit/customer-reviews.json$npx shadcn@latest add https://uipkge.dev/r/lit/customer-reviews.json$yarn dlx shadcn@latest add https://uipkge.dev/r/lit/customer-reviews.json$bunx shadcn@latest add https://uipkge.dev/r/lit/customer-reviews.jsonManifest:
https://uipkge.dev/r/lit/customer-reviews.jsonInstalls to:src/uip/blocks/customer-reviews/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
ReviewIteminterface ReviewItem {
id: string
author: string
initials: string
avatar?: string
rating: number
date: string
title: string
body: string
variant: string
verified: boolean
helpfulCount: number
isHelpful?: boolean
photos?: string[]
}RatingBarinterface RatingBar {
stars: number
percentage: number
count: number
}PhotoGalleryIteminterface PhotoGalleryItem {
id: string
url: string
alt: string
author: string
}HTMLElementTagNameMapinterface HTMLElementTagNameMap {
'uip-customer-reviews': UipCustomerReviews
}
}Files installed (1)
src/uip/blocks/customer-reviews/customer-reviews.ts29.8 kB
import { LitElement, css, html, nothing } from 'lit' import { CheckCircle2, Image as ImageIcon, PenLine, Star, ThumbsUp, X } from 'lucide' import { cn } from '../../lib/utils' import { tailwind } from '../../lib/styles' import { ThemeController } from '../../lib/theme' import { icon } from '../../lib/icon' // Side-effect imports: register the composed elements for standalone use // (consumers who install only this block still get working primitives). import '../../components/avatar/avatar' import '../../components/badge/badge' import '../../components/button/button' import '../../components/card/card' import '../../components/progress/progress' import '../../components/rating/rating' import '../../components/select/select' import type { UipSelect } from '../../components/select/select' import '../../components/input/input' import type { UipInput } from '../../components/input/input' import '../../components/textarea/textarea' export interface ReviewItem { id: string author: string initials: string avatar?: string rating: number date: string title: string body: string variant: string verified: boolean helpfulCount: number isHelpful?: boolean photos?: string[] } interface RatingBar { stars: number percentage: number count: number } interface PhotoGalleryItem { id: string url: string alt: string author: string } const ratingBreakdowns: RatingBar[] = [ { stars: 5, percentage: 78, count: 267 }, { stars: 4, percentage: 14, count: 48 }, { stars: 3, percentage: 5, count: 17 }, { stars: 2, percentage: 2, count: 7 }, { stars: 1, percentage: 1, count: 3 }, ] const customerPhotos: PhotoGalleryItem[] = [ { id: 'p1', url: 'https://images.unsplash.com/photo-1546435770-a3e426bf472b?w=400&auto=format&fit=crop&q=80', alt: 'Customer photo of matte navy headphones on wood desk', author: 'Marcus V.', }, { id: 'p2', url: 'https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=400&auto=format&fit=crop&q=80', alt: 'Customer photo of headphones studio unboxing', author: 'Elena R.', }, { id: 'p3', url: 'https://images.unsplash.com/photo-1484704849700-f032a568e944?w=400&auto=format&fit=crop&q=80', alt: 'Customer photo of cushioned earpads close up', author: 'Devon C.', }, { id: 'p4', url: 'https://images.unsplash.com/photo-1583394838336-acd977736f90?w=400&auto=format&fit=crop&q=80', alt: 'Customer photo wearing headphones in coffee shop', author: 'Amara D.', }, { id: 'p5', url: 'https://images.unsplash.com/photo-1590658268037-6bf12165a8df?w=400&auto=format&fit=crop&q=80', alt: 'Customer photo of travel case and cable accessories', author: 'Liam G.', }, { id: 'p6', url: 'https://images.unsplash.com/photo-1545127398-14699f92334b?w=400&auto=format&fit=crop&q=80', alt: 'Customer photo wearing silver headphones while working', author: 'Sophia M.', }, ] const initialReviews: ReviewItem[] = [ { id: 'rev-1', author: 'Marcus Vance', initials: 'MV', avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=160&auto=format&fit=crop&q=80', rating: 5, date: '2 days ago', title: 'Exceeded all my expectations — best studio acoustic fidelity', body: 'The soundstage is remarkably wide and bass response is tight without overpowering the mids. Build quality is aerospace-grade, and the memory foam ear cushions remain comfortable even through 8-hour mixing sessions. Multi-device Bluetooth pairing switches directly between MacBook and phone.', variant: 'Color: Matte Navy · Size: L', verified: true, helpfulCount: 24, isHelpful: false, photos: [ 'https://images.unsplash.com/photo-1546435770-a3e426bf472b?w=400&auto=format&fit=crop&q=80', 'https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=400&auto=format&fit=crop&q=80', ], }, { id: 'rev-2', author: 'Elena Rostova', initials: 'ER', avatar: 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=160&auto=format&fit=crop&q=80', rating: 5, date: '1 week ago', title: 'ANC handles subway roar with zero pressure sensation', body: 'Active noise cancellation on these is sublime. Unlike other flagships that create an uncomfortable eardrum suction pressure, this feels completely natural. Battery life easily stretches across 4 days of heavy commuting without a recharge.', variant: 'Color: Silver Frost · Size: M', verified: true, helpfulCount: 19, isHelpful: false, photos: ['https://images.unsplash.com/photo-1583394838336-acd977736f90?w=400&auto=format&fit=crop&q=80'], }, { id: 'rev-3', author: 'Devon Chen', initials: 'DC', avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=160&auto=format&fit=crop&q=80', rating: 4, date: '2 weeks ago', title: 'Fantastic sound and ergonomics, case could be slightly slimmer', body: 'Everything about audio reproduction is 10/10. The companion app EQ presets are actually tuned well instead of gimmicky. Only minor critique is the hard-shell travel case is a bit bulky for slim laptop bags, but the protection is undisputed.', variant: 'Color: Matte Navy · Size: L', verified: true, helpfulCount: 12, isHelpful: false, photos: ['https://images.unsplash.com/photo-1484704849700-f032a568e944?w=400&auto=format&fit=crop&q=80'], }, { id: 'rev-4', author: 'Amara Diallo', initials: 'AD', avatar: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=160&auto=format&fit=crop&q=80', rating: 5, date: '3 weeks ago', title: 'Crystal-clear microphone quality for all-day client calls', body: 'I purchased these primarily for conference calls in a busy open-plan studio. The beamforming microphones filter out nearby background conversations so callers only hear my voice. Lightweight headband design does not induce fatigue.', variant: 'Color: Space Black · Size: L', verified: true, helpfulCount: 8, isHelpful: false, }, ] const extraReviews: ReviewItem[] = [ { id: 'rev-5', author: 'Liam Gallagher', initials: 'LG', avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=160&auto=format&fit=crop&q=80', rating: 5, date: '1 month ago', title: 'Premium finish that justifies every single penny', body: 'The anodized aluminum arms and tactile physical dials feel so much better than finicky touch controls in cold weather. Fast USB-C charging gave me 6 hours of playback from a 15-minute coffee break charge.', variant: 'Color: Space Black · Size: L', verified: true, helpfulCount: 15, isHelpful: false, photos: ['https://images.unsplash.com/photo-1590658268037-6bf12165a8df?w=400&auto=format&fit=crop&q=80'], }, { id: 'rev-6', author: 'Sophia Martinez', initials: 'SM', avatar: 'https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=160&auto=format&fit=crop&q=80', rating: 4, date: '1 month ago', title: 'Great balanced frequency response for classical and jazz', body: 'Instruments have pinpoint positional separation in spatial mode. The high frequencies are crisp without ever becoming sibilant or harsh at high volume.', variant: 'Color: Silver Frost · Size: M', verified: false, helpfulCount: 6, isHelpful: false, }, ] type SortBy = 'recent' | 'highest' | 'lowest' | 'helpful' /** * <uip-customer-reviews> — the registry CustomerReviews block as a web component. * * Class strings and sample data are React's verbatim. React state becomes * { state: true } fields. The block takes no props and fires no events; all * interaction (rating filter, photo filter, verified filter, sort select, * helpful votes, load-more, review composer, photo lightbox) is internal. * The sort dropdown uses <uip-select> with native <option> children. */ export class UipCustomerReviews extends LitElement { static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true } // :host display can't be a utility class (the host has no template of its own). static styles = [tailwind, css`:host { display: block; }`] static properties = { reviews: { state: true }, selectedRating: { state: true }, withPhotosOnly: { state: true }, verifiedOnly: { state: true }, sortBy: { state: true }, isWritingReview: { state: true }, hasLoadedMore: { state: true }, activePhotoUrl: { state: true }, newRating: { state: true }, newAuthor: { state: true }, newVariant: { state: true }, newTitle: { state: true }, newBody: { state: true }, } private reviews: ReviewItem[] = initialReviews.map((r) => ({ ...r })) private selectedRating: number | null = null private withPhotosOnly = false private verifiedOnly = false private sortBy: SortBy = 'recent' private isWritingReview = false private hasLoadedMore = false private activePhotoUrl: string | null = null private newRating = 5 private newAuthor = '' private newVariant = 'Color: Matte Navy · Size: L' private newTitle = '' private newBody = '' constructor() { super() new ThemeController(this) } connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'customer-reviews') } private toggleRatingFilter(stars: number) { this.selectedRating = this.selectedRating === stars ? null : stars } private resetFilters() { this.selectedRating = null this.withPhotosOnly = false this.verifiedOnly = false } private toggleHelpful(reviewId: string) { this.reviews = this.reviews.map((rev) => { if (rev.id !== reviewId) return rev const nextIsHelpful = !rev.isHelpful return { ...rev, isHelpful: nextIsHelpful, helpfulCount: nextIsHelpful ? rev.helpfulCount + 1 : rev.helpfulCount - 1, } }) } private loadMore() { this.reviews = [...this.reviews, ...extraReviews.map((r) => ({ ...r }))] this.hasLoadedMore = true } private submitReview() { if (!this.newTitle.trim() || !this.newBody.trim() || this.newRating === 0) return const authorName = this.newAuthor.trim() || 'Anonymous User' const initials = authorName .split(' ') .map((n) => n[0]) .join('') .toUpperCase() .slice(0, 2) const created: ReviewItem = { id: `rev-${Date.now()}`, author: authorName, initials, rating: this.newRating, date: 'Just now', title: this.newTitle.trim(), body: this.newBody.trim(), variant: this.newVariant.trim() || 'Color: Matte Navy · Size: L', verified: true, helpfulCount: 0, isHelpful: false, } this.reviews = [created, ...this.reviews] this.newTitle = '' this.newBody = '' this.newAuthor = '' this.newRating = 5 this.isWritingReview = false } private get filteredReviews(): ReviewItem[] { const result = [...this.reviews] const filtered = result.filter((r) => { if (this.selectedRating !== null && r.rating !== this.selectedRating) return false if (this.withPhotosOnly && !(r.photos && r.photos.length > 0)) return false if (this.verifiedOnly && !r.verified) return false return true }) if (this.sortBy === 'highest') filtered.sort((a, b) => b.rating - a.rating) else if (this.sortBy === 'lowest') filtered.sort((a, b) => a.rating - b.rating) else if (this.sortBy === 'helpful') filtered.sort((a, b) => b.helpfulCount - a.helpfulCount) return filtered } render() { const filteredReviews = this.filteredReviews return html` <section data-slot="customer-reviews" class="w-full space-y-8"> <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div> <h2 class="text-foreground text-2xl font-bold tracking-tight sm:text-3xl">Customer Reviews</h2> <p class="text-muted-foreground mt-1 text-sm">Real feedback from verified purchasers</p> </div> <uip-button aria-label="Close review form" type="button" class="h-9 gap-2 text-xs font-medium sm:text-sm" @click=${() => { this.isWritingReview = !this.isWritingReview }} > ${icon(PenLine, 'pen-line', 'size-4')} Write a Review </uip-button> </div> <uip-card class="border-border bg-card p-6 shadow-xs"> <div class="grid grid-cols-1 gap-6 md:grid-cols-12 md:items-center lg:gap-8"> <div class="border-border flex flex-col items-center justify-center space-y-2 border-b pb-6 text-center md:col-span-4 md:items-start md:border-r md:border-b-0 md:pr-6 md:pb-0 md:text-left lg:col-span-3"> <div class="flex items-baseline gap-2"> <span class="text-foreground text-4xl font-bold tracking-tight">4.8</span> <span class="text-muted-foreground text-sm font-medium">out of 5</span> </div> <uip-rating value="4.8" readonly half-increments size="medium"></uip-rating> <p class="text-muted-foreground text-xs">Based on 342 reviews</p> <uip-badge variant="secondary" class="border-success/20 bg-success/10 text-success mt-1 gap-1.5 font-medium"> ${icon(CheckCircle2, 'circle-check', 'size-3.5')} 96% recommend </uip-badge> </div> <div class="border-border space-y-2 border-b pb-6 md:col-span-5 md:border-r md:border-b-0 md:pr-6 md:pb-0 lg:col-span-5"> ${ratingBreakdowns.map( (bar) => html`<button type="button" class=${cn( 'group hover:bg-muted/60 focus-visible:ring-ring flex w-full items-center gap-3 rounded-md px-2 py-1 text-left text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none', this.selectedRating === bar.stars && 'bg-muted ring-primary/30 font-semibold ring-1', )} @click=${() => this.toggleRatingFilter(bar.stars)} > <span class="text-foreground flex w-12 items-center gap-1 font-medium"> ${bar.stars} ${icon(Star, 'star', 'fill-warning text-warning size-3')} </span> <uip-progress value=${bar.percentage} class="h-2 flex-1"></uip-progress> <span class="text-muted-foreground group-hover:text-foreground w-10 text-right font-mono">${bar.percentage}%</span> </button>`, )} </div> <div class="space-y-3 md:col-span-3 lg:col-span-4"> <div class="flex items-center justify-between"> <h4 class="text-foreground text-sm font-semibold">Customer Photos (48)</h4> <span class="text-muted-foreground text-xs">Recent</span> </div> <div class="grid grid-cols-3 gap-2"> ${customerPhotos.map( (photo, index) => html`<button type="button" class="group border-border bg-muted focus-visible:ring-ring relative aspect-square cursor-pointer overflow-hidden rounded-lg border focus-visible:ring-2 focus-visible:outline-none" aria-label=${photo.alt} @click=${() => { this.activePhotoUrl = photo.url }} > <img src=${photo.url} alt=${photo.alt} class="size-full object-cover transition-transform duration-300 group-hover:scale-105" /> ${index === 5 ? html`<div class="absolute inset-0 flex items-center justify-center bg-black/60 text-xs font-semibold text-white transition-colors group-hover:bg-black/70" > +43 more </div>` : nothing} </button>`, )} </div> </div> </div> </uip-card> ${this.isWritingReview ? html`<uip-card class="border-primary/30 bg-card p-6 shadow-sm"> <div class="space-y-4"> <div class="flex items-center justify-between"> <h3 class="text-foreground text-base font-semibold">Write Your Review</h3> <uip-button aria-label="Close review form" type="button" variant="ghost" size="icon" class="text-muted-foreground hover:text-foreground size-8" @click=${() => { this.isWritingReview = false }} > ${icon(X, 'x', 'size-4')} </uip-button> </div> <div class="space-y-3"> <div> <label class="text-foreground mb-1.5 block text-xs font-medium">Overall Rating</label> <uip-rating value=${this.newRating} size="medium" hover @value-change=${(e: CustomEvent<{ value: number }>) => { this.newRating = e.detail.value }} ></uip-rating> </div> <div class="grid gap-3 sm:grid-cols-2"> <div> <label class="text-foreground mb-1.5 block text-xs font-medium">Your Name</label> <uip-input .value=${this.newAuthor} type="text" placeholder="e.g. Alex Morgan" class="text-xs" @input=${(e: Event) => { this.newAuthor = (e.target as UipInput).value ?? '' }} ></uip-input> </div> <div> <label class="text-foreground mb-1.5 block text-xs font-medium">Purchased Option</label> <uip-input .value=${this.newVariant} type="text" placeholder="e.g. Color: Space Black · Size: L" class="text-xs" @input=${(e: Event) => { this.newVariant = (e.target as UipInput).value ?? '' }} ></uip-input> </div> </div> <div> <label class="text-foreground mb-1.5 block text-xs font-medium">Review Headline</label> <uip-input .value=${this.newTitle} type="text" placeholder="Summarize your experience in one sentence" class="text-xs" @input=${(e: Event) => { this.newTitle = (e.target as UipInput).value ?? '' }} ></uip-input> </div> <div> <label class="text-foreground mb-1.5 block text-xs font-medium">Detailed Feedback</label> <uip-textarea .value=${this.newBody} rows="3" placeholder="What did you like or dislike? How does it perform?" class="text-xs" @value-change=${(e: CustomEvent<{ value: string }>) => { this.newBody = e.detail.value }} ></uip-textarea> </div> <div class="flex items-center justify-end gap-2 pt-2"> <uip-button aria-label="Close review form" type="button" variant="outline" size="sm" class="h-8 text-xs" @click=${() => { this.isWritingReview = false }} > Cancel </uip-button> <uip-button type="button" size="sm" class="h-8 text-xs" ?disabled=${!this.newTitle.trim() || !this.newBody.trim() || this.newRating === 0} @click=${() => this.submitReview()} > Submit Review </uip-button> </div> </div> </div> </uip-card>` : nothing} <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-wrap items-center gap-2"> <uip-button type="button" size="sm" variant=${this.selectedRating === null && !this.withPhotosOnly && !this.verifiedOnly ? 'default' : 'outline'} class="h-8 rounded-full text-xs" @click=${() => this.resetFilters()} > All Ratings </uip-button> <uip-button type="button" size="sm" variant=${this.selectedRating === 5 ? 'default' : 'outline'} class="h-8 gap-1 rounded-full text-xs" @click=${() => this.toggleRatingFilter(5)} > ${icon(Star, 'star', cn('size-3', this.selectedRating === 5 ? 'fill-current' : 'fill-warning text-warning'))}5 Stars </uip-button> <uip-button type="button" size="sm" variant=${this.withPhotosOnly ? 'default' : 'outline'} class="h-8 gap-1.5 rounded-full text-xs" @click=${() => { this.withPhotosOnly = !this.withPhotosOnly }} > ${icon(ImageIcon, 'image', 'size-3.5')} With Photos </uip-button> <uip-button type="button" size="sm" variant=${this.verifiedOnly ? 'default' : 'outline'} class="h-8 gap-1.5 rounded-full text-xs" @click=${() => { this.verifiedOnly = !this.verifiedOnly }} > ${icon(CheckCircle2, 'circle-check', 'size-3.5')} Verified Only </uip-button> </div> <div class="flex items-center gap-2"> <span class="text-muted-foreground text-xs font-medium">Sort by:</span> <uip-select class="w-36 text-xs sm:w-40" size="sm" .value=${this.sortBy} @change=${(e: Event) => { this.sortBy = (e.target as UipSelect).value as SortBy }} > <option value="recent">Most Recent</option> <option value="highest">Highest Rated</option> <option value="lowest">Lowest Rated</option> <option value="helpful">Most Helpful</option> </uip-select> </div> </div> <div class="space-y-4"> ${filteredReviews.length === 0 ? html`<div class="border-border bg-card rounded-xl border p-12 text-center"> <p class="text-foreground text-sm font-medium">No reviews match the selected filters</p> <p class="text-muted-foreground mt-1 text-xs">Try clearing your filters to view all customer feedback</p> <uip-button type="button" variant="outline" size="sm" class="mt-4 h-8 text-xs" @click=${() => this.resetFilters()}> Reset Filters </uip-button> </div>` : filteredReviews.map( (rev) => html`<uip-card class="border-border bg-card p-5 shadow-xs"> <div class="space-y-4"> <div class="flex flex-wrap items-start justify-between gap-3"> <div class="flex items-center gap-3"> <uip-avatar class="border-border size-10 border"> ${rev.avatar ? html`<uip-avatar-image src=${rev.avatar} alt=${rev.author}></uip-avatar-image>` : nothing} <uip-avatar-fallback class="text-xs font-medium">${rev.initials}</uip-avatar-fallback> </uip-avatar> <div class="space-y-0.5"> <div class="flex items-center gap-2"> <span class="text-foreground text-sm font-semibold">${rev.author}</span> ${rev.verified ? html`<uip-badge variant="secondary" class="text-muted-foreground h-5 gap-1 px-1.5 text-xs font-normal"> ${icon(CheckCircle2, 'circle-check', 'text-success size-3')} Verified Buyer </uip-badge>` : nothing} </div> <time class="text-muted-foreground text-xs">${rev.date}</time> </div> </div> <uip-rating value=${rev.rating} readonly size="small"></uip-rating> </div> <div class="space-y-2"> <h5 class="text-foreground text-sm font-semibold sm:text-base">${rev.title}</h5> <p class="text-muted-foreground text-xs leading-relaxed sm:text-sm">${rev.body}</p> </div> ${rev.variant ? html`<div class="bg-muted/60 text-muted-foreground inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium"> <span>${rev.variant}</span> </div>` : nothing} ${rev.photos && rev.photos.length > 0 ? html`<div class="flex flex-wrap gap-2 pt-1"> ${rev.photos.map( (photoUrl, pIdx) => html`<button type="button" class="group border-border bg-muted focus-visible:ring-ring relative size-16 cursor-pointer overflow-hidden rounded-lg border focus-visible:ring-2 focus-visible:outline-none sm:size-20" aria-label=${`Review photo ${pIdx + 1} by ${rev.author}`} @click=${() => { this.activePhotoUrl = photoUrl }} > <img src=${photoUrl} alt=${`Review photo ${pIdx + 1} by ${rev.author}`} class="size-full object-cover transition-transform duration-300 group-hover:scale-105" /> </button>`, )} </div>` : nothing} <div class="border-border flex items-center justify-between border-t pt-3"> <p class="text-muted-foreground text-xs">Was this review helpful?</p> <uip-button type="button" variant="outline" size="sm" class=${cn('h-8 gap-1.5 text-xs', rev.isHelpful && 'border-primary/40 bg-primary/10 text-primary font-medium')} @click=${() => this.toggleHelpful(rev.id)} > ${icon(ThumbsUp, 'thumbs-up', cn('size-3.5', rev.isHelpful && 'fill-primary'))} Helpful (${rev.helpfulCount}) </uip-button> </div> </div> </uip-card>`, )} </div> <div class="border-border flex flex-col items-center justify-between gap-4 border-t pt-6 sm:flex-row"> <p class="text-muted-foreground text-xs">Showing 1-${filteredReviews.length} of 342 reviews</p> ${!this.hasLoadedMore ? html`<uip-button type="button" variant="outline" size="sm" class="h-9 px-4 text-xs font-medium" @click=${() => this.loadMore()}> Load More Reviews </uip-button>` : nothing} </div> ${this.activePhotoUrl ? html`<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4 backdrop-blur-xs" @click=${() => { this.activePhotoUrl = null }} > <div class="border-border bg-card relative max-h-[85vh] max-w-2xl overflow-hidden rounded-xl border p-3 shadow-sm" @click=${(e: Event) => e.stopPropagation()} > <img src=${this.activePhotoUrl} alt="Customer review photo preview" class="max-h-[75vh] w-auto rounded-lg object-contain" /> <div class="flex items-center justify-between p-2 pt-3"> <span class="text-muted-foreground text-xs">Customer Review Photo</span> <uip-button type="button" variant="ghost" size="sm" class="h-7 text-xs" @click=${() => { this.activePhotoUrl = null }} > Close </uip-button> </div> </div> </div>` : nothing} </section> ` } } customElements.get('uip-customer-reviews') || customElements.define('uip-customer-reviews', UipCustomerReviews) declare global { interface HTMLElementTagNameMap { 'uip-customer-reviews': UipCustomerReviews } }
Raw manifest:https://uipkge.dev/r/lit/customer-reviews.json