Menu

Change language

GitHub repo

Product Qa Community

blockecommerce

Searchable customer and engineering Q&A community forum with staff answer badges, question upvoting, category filters, and live ask modal.

Installation

$npx shadcn@latest add https://uipkge.dev/r/lit/product-qa-community.json
Manifest:https://uipkge.dev/r/lit/product-qa-community.jsonInstalls to:src/uip/blocks/product-qa-community/

Variants

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
product-namestringApex Pro Reference Studio Monitor Headphonesoptional

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

AnswerItem
interface AnswerItem {
  id: string
  answer: string
  answeredBy: string
  answeredDate: string
  role: 'staff' | 'verified_buyer' | 'community'
  helpfulCount: number
  hasUpvoted?: boolean
}
QuestionItem
interface QuestionItem {
  id: string
  question: string
  askedBy: string
  askedDate: string
  category: 'compatibility' | 'sound' | 'comfort' | 'cables' | 'shipping'
  upvotes: number
  hasVoted?: boolean
  answers: AnswerItem[]
}
HTMLElementTagNameMap
interface 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