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 uipkge-ng@latest add product-qa-community
Manifest:https://uipkge.dev/r/angular/product-qa-community.jsonInstalls to:src/app/components/blocks/product-qa-community/

Variants

Loading interactive previews…

Schema

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

QaAnswer
interface QaAnswer {
  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: QaCategory
  upvotes: number
  hasVoted?: boolean
  answers: QaAnswer[]
}

Files installed (2)

  • src/app/components/blocks/product-qa-community/product-qa-community.component.ts17.7 kB
    // Searchable customer + engineering Q&A community forum: category chips, keyword search,
    // question upvoting, staff / verified-owner answer badges, and an inline "ask a question" form.
    // Port of the Vue block 1:1.
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import {
      CheckCircle2,
      HelpCircle,
      LucideAngularModule,
      MessageSquare,
      Plus,
      Search,
      Send,
      ShieldCheck,
      ThumbsUp,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiCardComponent,
      UiCardContentComponent,
      UiCardDescriptionComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import { UiTextareaComponent } from '@/ui/textarea/textarea.component'
    
    export type QaCategory = 'compatibility' | 'sound' | 'comfort' | 'cables' | 'shipping'
    export type QaCategoryFilter = 'all' | QaCategory
    
    export interface QaAnswer {
      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: QaCategory
      upvotes: number
      hasVoted?: boolean
      answers: QaAnswer[]
    }
    
    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: QaCategoryFilter; label: string }[] = [
      { 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' },
    ]
    
    function cloneQuestions(items: QuestionItem[]): QuestionItem[] {
      return items.map((q) => ({ ...q, answers: q.answers.map((a) => ({ ...a })) }))
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-product-qa-community, [ui-product-qa-community]',
      standalone: true,
      // Vue renders the Card root itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiInputComponent,
        UiTextareaComponent,
      ],
      template: `
        <div ui-card data-slot="product-qa-community" [class]="rootClass">
          <div ui-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"
                  >
                    <lucide-icon [img]="HelpCircle" class="text-primary size-3.5" />
                  </span>
                  <span class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
                    Customer & Engineering Community
                  </span>
                </div>
                <h3 ui-card-title class="text-foreground mt-1 text-lg font-semibold tracking-tight sm:text-xl">
                  Questions & Answers
                </h3>
                <p ui-card-description class="text-xs">
                  Have questions about {{ productName }}? Search community inquiries or ask our acoustic engineering team.
                </p>
              </div>
    
              <button ui-button size="sm" class="gap-1.5 self-start text-xs shadow-xs sm:self-auto" (click)="toggleAsk()">
                <lucide-icon [img]="Plus" class="size-3.5" />
                <span>Ask a Question</span>
              </button>
            </div>
    
            <!-- Controls: Category Chips & Search Input -->
            <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">
                @for (cat of categoryList; track cat.id) {
                  <button type="button" [class]="chipClass(cat.id)" (click)="selectedCategory.set(cat.id)">
                    {{ cat.label }}
                  </button>
                }
              </div>
    
              <div class="relative w-full sm:w-64">
                <lucide-icon
                  [img]="Search"
                  class="text-muted-foreground absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2"
                />
                <ui-input
                  [value]="searchQuery()"
                  (valueChange)="searchQuery.set($event)"
                  placeholder="Search answered Q&A..."
                  class="h-8 pl-8 text-xs"
                />
              </div>
            </div>
          </div>
    
          <div ui-card-content class="space-y-6 p-4 sm:p-6">
            <!-- Ask Question Form Accordion -->
            @if (isAsking()) {
              <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">
                    <lucide-icon [img]="MessageSquare" class="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 hidden text-xs sm:inline">
                    Staff answers typically arrive within 4 hours
                  </span>
                </div>
    
                <ui-textarea
                  [value]="newQuestionText()"
                  (valueChange)="newQuestionText.set($event)"
                  [rows]="3"
                  placeholder="e.g. Does the headband clamping force loosen up after 20 hours of break-in?"
                  class="text-xs"
                />
    
                <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
                      [value]="newQuestionCategory()"
                      (change)="newQuestionCategory.set($any($event.target).value)"
                      class="border-border bg-background text-foreground h-7 rounded-md border px-2 text-xs focus:outline-hidden"
                    >
                      <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">
                    <button ui-button variant="ghost" size="sm" class="h-7 text-xs" (click)="isAsking.set(false)">
                      Cancel
                    </button>
                    <button ui-button size="sm" class="h-7 gap-1.5 text-xs shadow-2xs" (click)="submitNewQuestion()">
                      <lucide-icon [img]="Send" class="size-3" />
                      <span>Submit Question</span>
                    </button>
                  </div>
                </div>
              </div>
            }
    
            <!-- Question List -->
            <div class="divide-border space-y-5 divide-y">
              @for (q of filteredQuestions(); track q.id) {
                <div class="space-y-3 pt-5 first:pt-0">
                  <!-- Question Header Row -->
                  <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>
                          <span ui-badge variant="outline" class="h-4.5 font-mono text-xs uppercase">
                            {{ q.category }}
                          </span>
                        </div>
                      </div>
                    </div>
    
                    <!-- Upvote Question Button -->
                    <button type="button" [class]="voteClass(q.hasVoted)" (click)="toggleVoteQuestion(q.id)">
                      <lucide-icon [img]="ThumbsUp" class="size-3" />
                      <span>{{ q.upvotes }}</span>
                    </button>
                  </div>
    
                  <!-- Answers Section -->
                  <div class="ml-9 space-y-3">
                    @for (ans of q.answers; track ans.id) {
                      <div
                        [class]="
                          cn(
                            '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'
                          )
                        "
                      >
                        <!-- Answer Meta Bar -->
                        <div class="flex flex-wrap items-center justify-between gap-2">
                          <div class="flex items-center gap-2">
                            @if (ans.role === 'staff') {
                              <span
                                ui-badge
                                variant="default"
                                class="bg-success h-4.5 gap-1 px-1.5 text-xs font-semibold text-white"
                              >
                                <lucide-icon [img]="ShieldCheck" class="size-2.5" />
                                Staff Expert
                              </span>
                            } @else if (ans.role === 'verified_buyer') {
                              <span
                                ui-badge
                                variant="secondary"
                                class="text-foreground h-4.5 gap-1 px-1.5 text-xs font-semibold"
                              >
                                <lucide-icon [img]="CheckCircle2" class="text-success size-2.5" />
                                Verified Owner
                              </span>
                            }
    
                            <span class="text-foreground font-semibold">{{ ans.answeredBy }}</span>
                            <span class="text-muted-foreground">· {{ ans.answeredDate }}</span>
                          </div>
    
                          <button type="button" [class]="helpfulClass(ans.hasUpvoted)" (click)="toggleVoteAnswer(ans.id)">
                            <lucide-icon [img]="ThumbsUp" class="size-3" />
                            <span>Helpful ({{ ans.helpfulCount }})</span>
                          </button>
                        </div>
    
                        <!-- Answer Body Text -->
                        <p class="text-foreground leading-relaxed">
                          {{ ans.answer }}
                        </p>
                      </div>
                    }
    
                    @if (q.answers.length === 0) {
                      <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>
                    }
                  </div>
                </div>
              }
    
              @if (filteredQuestions().length === 0) {
                <div class="text-muted-foreground py-12 text-center text-xs">
                  No questions found matching your search. Be the first to ask!
                </div>
              }
            </div>
          </div>
        </div>
      `,
    })
    export class UiProductQaCommunityComponent {
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly HelpCircle = HelpCircle
      protected readonly MessageSquare = MessageSquare
      protected readonly Plus = Plus
      protected readonly Search = Search
      protected readonly Send = Send
      protected readonly ShieldCheck = ShieldCheck
      protected readonly ThumbsUp = ThumbsUp
      protected readonly cn = cn
    
      protected readonly categoryList = categories
    
      @Input() productName = 'Apex Pro Reference Studio Monitor Headphones'
      @Input() set questions(v: QuestionItem[] | undefined) {
        this.localQuestions.set(cloneQuestions(v ?? defaultQuestions))
      }
      @Input('class') className?: string
    
      readonly localQuestions = signal<QuestionItem[]>(cloneQuestions(defaultQuestions))
      readonly searchQuery = signal('')
      readonly selectedCategory = signal<QaCategoryFilter>('all')
    
      readonly isAsking = signal(false)
      readonly newQuestionText = signal('')
      readonly newQuestionCategory = signal<QaCategory>('compatibility')
    
      readonly filteredQuestions = computed(() => {
        const cat = this.selectedCategory()
        const q = this.searchQuery().trim().toLowerCase()
        return this.localQuestions().filter((item) => {
          const matchesCat = cat === 'all' || item.category === cat
          const matchesSearch =
            q === '' ||
            item.question.toLowerCase().includes(q) ||
            item.answers.some((a) => a.answer.toLowerCase().includes(q))
          return matchesCat && matchesSearch
        })
      })
    
      get rootClass(): string {
        return cn('border-border w-full shadow-xs', this.className)
      }
    
      chipClass(id: QaCategoryFilter): string {
        return cn(
          'rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
          this.selectedCategory() === id
            ? 'bg-foreground text-background shadow-2xs'
            : 'bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground border border-transparent',
        )
      }
    
      voteClass(hasVoted?: boolean): string {
        return cn(
          'flex shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium shadow-2xs transition-colors',
          hasVoted
            ? 'border-primary bg-primary/10 text-primary'
            : 'border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground',
        )
      }
    
      helpfulClass(hasUpvoted?: boolean): string {
        return cn(
          'flex items-center gap-1 text-xs font-medium transition-colors',
          hasUpvoted ? 'text-primary font-bold' : 'text-muted-foreground hover:text-foreground',
        )
      }
    
      toggleAsk(): void {
        this.isAsking.set(!this.isAsking())
      }
    
      toggleVoteQuestion(id: string): void {
        this.localQuestions.update((qs) =>
          qs.map((q) => (q.id === id ? { ...q, upvotes: q.upvotes + (q.hasVoted ? -1 : 1), hasVoted: !q.hasVoted } : q)),
        )
      }
    
      toggleVoteAnswer(answerId: string): void {
        this.localQuestions.update((qs) =>
          qs.map((q) => ({
            ...q,
            answers: q.answers.map((a) =>
              a.id === answerId
                ? { ...a, helpfulCount: a.helpfulCount + (a.hasUpvoted ? -1 : 1), hasUpvoted: !a.hasUpvoted }
                : a,
            ),
          })),
        )
      }
    
      submitNewQuestion(): void {
        const text = this.newQuestionText().trim()
        if (!text) return
        this.localQuestions.update((qs) => [
          {
            id: `q-${Date.now()}`,
            question: text,
            askedBy: 'You (Guest User)',
            askedDate: 'Just now',
            category: this.newQuestionCategory(),
            upvotes: 1,
            hasVoted: true,
            answers: [],
          },
          ...qs,
        ])
        this.newQuestionText.set('')
        this.isAsking.set(false)
      }
    }
    
  • src/app/components/blocks/product-qa-community/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/product-qa-community.json