Product Qa Community
blockecommerceSearchable customer and engineering Q&A community forum with staff answer badges, question upvoting, category filters, and live ask modal.
Installation
$pnpm dlx shadcn@latest add https://uipkge.dev/r/lit/product-qa-community.json$npx shadcn@latest add https://uipkge.dev/r/lit/product-qa-community.json$yarn dlx shadcn@latest add https://uipkge.dev/r/lit/product-qa-community.json$bunx shadcn@latest add https://uipkge.dev/r/lit/product-qa-community.jsonManifest:
https://uipkge.dev/r/lit/product-qa-community.jsonInstalls to:src/uip/blocks/product-qa-community/Variants
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
product-name | string | Apex Pro Reference Studio Monitor Headphones | optional |
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
AnswerIteminterface AnswerItem {
id: string
answer: string
answeredBy: string
answeredDate: string
role: 'staff' | 'verified_buyer' | 'community'
helpfulCount: number
hasUpvoted?: boolean
}QuestionIteminterface QuestionItem {
id: string
question: string
askedBy: string
askedDate: string
category: 'compatibility' | 'sound' | 'comfort' | 'cables' | 'shipping'
upvotes: number
hasVoted?: boolean
answers: AnswerItem[]
}HTMLElementTagNameMapinterface HTMLElementTagNameMap {
'uip-product-qa-community': UipProductQaCommunity
}
}Files installed (1)
src/uip/blocks/product-qa-community/product-qa-community.ts18.5 kB
import { LitElement, css, html, nothing } from 'lit' import { HelpCircle, MessageSquare, Search, ThumbsUp, ShieldCheck, CheckCircle, Plus, Send } from 'lucide' 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/badge/badge' import '../../components/button/button' import '../../components/card/card' import '../../components/input/input' import type { UipInput } from '../../components/input/input' import '../../components/textarea/textarea' export interface AnswerItem { id: string answer: string answeredBy: string answeredDate: string role: 'staff' | 'verified_buyer' | 'community' helpfulCount: number hasUpvoted?: boolean } export interface QuestionItem { id: string question: string askedBy: string askedDate: string category: 'compatibility' | 'sound' | 'comfort' | 'cables' | 'shipping' upvotes: number hasVoted?: boolean answers: AnswerItem[] } const defaultQuestions: QuestionItem[] = [ { id: 'q1', question: 'Can these 32-ohm planar drivers be driven directly from a MacBook Pro headphone jack without an external DAC/AMP?', askedBy: 'Julian R. (Audio Engineer)', askedDate: '3 days ago', category: 'compatibility', upvotes: 42, answers: [ { id: 'a1', answer: 'Yes! Thanks to our high-sensitivity 106 dB/mW planar trace design and flat 32Ω impedance curve, modern high-power laptops (such as Apple Silicon M-series MacBook Pros) drive them with pristine headroom. However, for 192kHz/24-bit studio mastering, pairing with a dedicated balanced 4.4mm DAC/AMP will unlock their full dynamic staging.', answeredBy: 'Marcus Sterling · Lead Acoustic Engineer (Apex)', answeredDate: '2 days ago', role: 'staff', helpfulCount: 38, }, ], }, { id: 'q2', question: 'Are the lambskin earpads user-replaceable if they wear down over time?', askedBy: 'Elena K.', askedDate: '1 week ago', category: 'comfort', upvotes: 19, answers: [ { id: 'a2', answer: 'The earpads utilize our magnetic snap-lock mounting system. You can easily remove and swap them in seconds without adhesive. We also offer perforated vegan suede and cooling velour pads in our accessories catalog.', answeredBy: 'Apex Support Team', answeredDate: '6 days ago', role: 'staff', helpfulCount: 22, }, { id: 'a3', answer: 'Verified buyer here — swapped mine to the cooling velour for long 6-hour mixing sessions. Magnetic snaps are rock solid!', answeredBy: 'Devon T. (Verified Studio Buyer)', answeredDate: '5 days ago', role: 'verified_buyer', helpfulCount: 14, }, ], }, { id: 'q3', question: 'Does the included balanced 4.4mm cable work with Sony DAP walkmans and standard desktop audio interfaces?', askedBy: 'Kenji S.', askedDate: '2 weeks ago', category: 'cables', upvotes: 15, answers: [ { id: 'a4', answer: 'Yes, the native 4.4mm TRRRS Pentaconn termination matches Sony, FiiO, Astell&Kern, and modern studio DAC balanced outputs. The package also includes a gold-plated 6.35mm (1/4") TRS screw-on adapter for standard rack equipment.', answeredBy: 'David L. · Product Specialist', answeredDate: '2 weeks ago', role: 'staff', helpfulCount: 16, }, ], }, ] const categories = [ { id: 'all', label: 'All Questions' }, { id: 'compatibility', label: 'Compatibility & DACs' }, { id: 'sound', label: 'Sound Signature' }, { id: 'comfort', label: 'Fit & Ergonomics' }, { id: 'cables', label: 'Cables & Hardware' }, { id: 'shipping', label: 'Warranty & Shipping' }, ] type QaCategory = 'compatibility' | 'sound' | 'comfort' | 'cables' | 'shipping' /** * <uip-product-qa-community> — the registry ProductQaCommunity block as a web component. * * Class strings and seed data are React's verbatim. React state becomes * { state: true } fields; `questions` is a property (attribute: false — set it * from JS). Like React, the block fires no events; search, category filter, * voting, and the ask form are internal. The search icon moves from an * absolute overlay into <uip-input>'s prefix slot (same visual result, no * overlay positioning to maintain). */ export class UipProductQaCommunity 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 = { productName: { attribute: 'product-name' }, questions: { attribute: false }, localQuestions: { state: true }, searchQuery: { state: true }, selectedCategory: { state: true }, isAsking: { state: true }, newQuestionText: { state: true }, newQuestionCategory: { state: true }, } productName = 'Apex Pro Reference Studio Monitor Headphones' questions: QuestionItem[] = defaultQuestions private localQuestions: QuestionItem[] = [] private searchQuery = '' private selectedCategory = 'all' private isAsking = false private newQuestionText = '' private newQuestionCategory: QaCategory = 'compatibility' constructor() { super() new ThemeController(this) } connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'product-qa-community') } willUpdate(changed: Map<string, unknown>) { if (changed.has('questions')) { this.localQuestions = (this.questions ?? []).map((q) => ({ ...q, answers: q.answers.map((a) => ({ ...a })) })) } } private get filteredQuestions(): QuestionItem[] { return this.localQuestions.filter((q) => { const matchesCat = this.selectedCategory === 'all' || q.category === this.selectedCategory const matchesSearch = this.searchQuery.trim() === '' || q.question.toLowerCase().includes(this.searchQuery.toLowerCase()) || q.answers.some((a) => a.answer.toLowerCase().includes(this.searchQuery.toLowerCase())) return matchesCat && matchesSearch }) } private toggleVoteQuestion(qId: string) { this.localQuestions = this.localQuestions.map((q) => { if (q.id !== qId) return q return { ...q, upvotes: q.hasVoted ? q.upvotes - 1 : q.upvotes + 1, hasVoted: !q.hasVoted } }) } private toggleVoteAnswer(qId: string, aId: string) { this.localQuestions = this.localQuestions.map((q) => { if (q.id !== qId) return q return { ...q, answers: q.answers.map((a) => { if (a.id !== aId) return a return { ...a, helpfulCount: a.hasUpvoted ? a.helpfulCount - 1 : a.helpfulCount + 1, hasUpvoted: !a.hasUpvoted, } }), } }) } private submitNewQuestion() { if (!this.newQuestionText.trim()) return const newQ: QuestionItem = { id: `q-${Date.now()}`, question: this.newQuestionText.trim(), askedBy: 'You (Guest User)', askedDate: 'Just now', category: this.newQuestionCategory, upvotes: 1, hasVoted: true, answers: [], } this.newQuestionText = '' this.isAsking = false this.localQuestions = [newQ, ...this.localQuestions] } render() { const filteredQuestions = this.filteredQuestions // data-slot lives on this wrapper: <uip-card> stamps its own // data-slot="card" on connect, which would clobber the block slot. return html` <div data-slot="product-qa-community"> <uip-card class="border-border w-full shadow-xs"> <uip-card-header class="border-border bg-muted/20 border-b pb-4"> <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div> <div class="flex items-center gap-2"> <span class="border-border bg-background flex size-6 items-center justify-center rounded-md border shadow-2xs"> ${icon(HelpCircle, 'circle-help', 'text-primary size-3.5')} </span> <span class="text-muted-foreground text-xs font-semibold tracking-wider uppercase"> Customer & Engineering Community </span> </div> <uip-card-title class="text-foreground mt-1 text-lg font-semibold tracking-tight sm:text-xl"> Questions & Answers </uip-card-title> <uip-card-description class="text-xs"> Have questions about ${this.productName}? Search community inquiries or ask our acoustic engineering team. </uip-card-description> </div> <uip-button size="sm" class="gap-1.5 self-start text-xs shadow-xs sm:self-auto" @click=${() => { this.isAsking = !this.isAsking }} > ${icon(Plus, 'plus', 'size-3.5')} <span>Ask a Question</span> </uip-button> </div> <div class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-wrap items-center gap-1.5"> ${categories.map( (cat) => html`<button type="button" class=${`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${ this.selectedCategory === cat.id ? 'bg-foreground text-background shadow-2xs' : 'bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground border border-transparent' }`} @click=${() => { this.selectedCategory = cat.id }} > ${cat.label} </button>`, )} </div> <uip-input .value=${this.searchQuery} placeholder="Search answered Q&A..." class="h-8 w-full text-xs sm:w-64" @input=${(e: Event) => { this.searchQuery = (e.target as UipInput).value ?? '' }} > <span slot="prefix">${icon(Search, 'search', 'size-3.5')}</span> </uip-input> </div> </uip-card-header> <uip-card-content class="space-y-6 p-4 sm:p-6"> ${this.isAsking ? html`<div class="border-primary/40 bg-primary/5 space-y-3 rounded-lg border p-4 shadow-xs"> <div class="flex items-center justify-between"> <div class="flex items-center gap-2"> ${icon(MessageSquare, 'message-square', 'text-primary size-4')} <span class="text-foreground text-xs font-semibold">Post a Question to Engineering & Community</span> </div> <span class="text-muted-foreground text-xs">Staff answers typically arrive within 4 hours</span> </div> <uip-textarea .value=${this.newQuestionText} rows="3" placeholder="e.g. Does the headband clamping force loosen up after 20 hours of break-in?" class="text-xs" @value-change=${(e: CustomEvent<{ value: string }>) => { this.newQuestionText = e.detail.value }} ></uip-textarea> <div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex items-center gap-2 text-xs"> <span class="text-muted-foreground">Topic:</span> <select class="border-border bg-background text-foreground h-7 rounded-md border px-2 text-xs focus:outline-hidden" .value=${this.newQuestionCategory} @change=${(e: Event) => { this.newQuestionCategory = (e.target as HTMLSelectElement).value as QaCategory }} > <option value="compatibility">Compatibility & DACs</option> <option value="sound">Sound Signature</option> <option value="comfort">Fit & Ergonomics</option> <option value="cables">Cables & Hardware</option> <option value="shipping">Warranty & Shipping</option> </select> </div> <div class="flex items-center gap-2"> <uip-button variant="ghost" size="sm" class="h-7 text-xs" @click=${() => { this.isAsking = false }} > Cancel </uip-button> <uip-button size="sm" class="h-7 gap-1.5 text-xs shadow-2xs" @click=${() => this.submitNewQuestion()}> ${icon(Send, 'send', 'size-3')} <span>Submit Question</span> </uip-button> </div> </div> </div>` : nothing} <div class="divide-border space-y-5 divide-y"> ${filteredQuestions.map( (q) => html`<div class="space-y-3 pt-5 first:pt-0"> <div class="flex items-start justify-between gap-3"> <div class="flex items-start gap-3"> <div class="bg-muted text-muted-foreground flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-bold"> Q </div> <div class="space-y-1"> <h4 class="text-foreground text-sm leading-snug font-semibold">${q.question}</h4> <div class="text-muted-foreground flex flex-wrap items-center gap-2 text-xs"> <span>Asked by ${q.askedBy}</span> <span>·</span> <span>${q.askedDate}</span> <uip-badge variant="outline" class="h-4.5 font-mono text-xs uppercase">${q.category}</uip-badge> </div> </div> </div> <button type="button" class=${`flex shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium shadow-2xs transition-colors ${ q.hasVoted ? 'border-primary bg-primary/10 text-primary' : 'border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground' }`} @click=${() => this.toggleVoteQuestion(q.id)} > ${icon(ThumbsUp, 'thumbs-up', 'size-3')} <span>${q.upvotes}</span> </button> </div> <div class="ml-9 space-y-3"> ${q.answers.map( (ans) => html`<div class=${`space-y-2 rounded-lg border p-3.5 text-xs ${ ans.role === 'staff' ? 'border-success/30 bg-success/5' : 'border-border bg-muted/30' }`} > <div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex items-center gap-2"> ${ans.role === 'staff' ? html`<uip-badge variant="default" class="bg-success h-4.5 gap-1 px-1.5 text-xs font-semibold text-white"> ${icon(ShieldCheck, 'shield-check', 'size-2.5')} Staff Expert </uip-badge>` : nothing} ${ans.role === 'verified_buyer' ? html`<uip-badge variant="secondary" class="text-foreground h-4.5 gap-1 px-1.5 text-xs font-semibold"> ${icon(CheckCircle, 'circle-check', 'text-success size-2.5')} Verified Owner </uip-badge>` : nothing} <span class="text-foreground font-semibold">${ans.answeredBy}</span> <span class="text-muted-foreground">· ${ans.answeredDate}</span> </div> <button type="button" class=${`flex items-center gap-1 text-xs font-medium transition-colors ${ ans.hasUpvoted ? 'text-primary font-bold' : 'text-muted-foreground hover:text-foreground' }`} @click=${() => this.toggleVoteAnswer(q.id, ans.id)} > ${icon(ThumbsUp, 'thumbs-up', 'size-3')} <span>Helpful (${ans.helpfulCount})</span> </button> </div> <p class="text-foreground leading-relaxed">${ans.answer}</p> </div>`, )} ${q.answers.length === 0 ? html`<div class="border-border text-muted-foreground rounded-lg border border-dashed p-3 text-xs"> Awaiting answer from engineering team. You will be notified when responded. </div>` : nothing} </div> </div>`, )} ${filteredQuestions.length === 0 ? html`<div class="text-muted-foreground py-12 text-center text-xs"> No questions found matching your search. Be the first to ask! </div>` : nothing} </div> </uip-card-content> </uip-card> </div> ` } } customElements.get('uip-product-qa-community') || customElements.define('uip-product-qa-community', UipProductQaCommunity) declare global { interface HTMLElementTagNameMap { 'uip-product-qa-community': UipProductQaCommunity } }
Raw manifest:https://uipkge.dev/r/lit/product-qa-community.json