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

Variants

Loading interactive previews…

Used by

Files installed (1)

  • src/lib/components/blocks/product-qa-community/ProductQaCommunity.svelte14.5 kB
    <script lang="ts" module>
      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[]
      }
    
      export interface ProductQaProps {
        productName?: string
        questions?: QuestionItem[]
        className?: string
      }
    
      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,
            },
          ],
        },
      ]
    </script>
    
    <script lang="ts">
      import { CheckCircle, HelpCircle, MessageSquare, Plus, Search, Send, ShieldCheck, ThumbsUp } from '@lucide/svelte'
      import { Badge } from '$lib/components/ui/badge'
      import { Button } from '$lib/components/ui/button'
      import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card'
      import { Input } from '$lib/components/ui/input'
      import { Textarea } from '$lib/components/ui/textarea'
      import { cn } from '$lib/utils'
    
      let {
        productName = 'Apex Pro Reference Studio Monitor Headphones',
        questions,
        className,
      }: ProductQaProps = $props()
    
      let localQuestions = $state<QuestionItem[]>(JSON.parse(JSON.stringify(questions ?? defaultQuestions)))
      let searchQuery = $state('')
      let selectedCategory = $state<'all' | 'compatibility' | 'sound' | 'comfort' | 'cables' | 'shipping'>('all')
    
      let isAsking = $state(false)
      let newQuestionText = $state('')
      let newQuestionCategory = $state<'compatibility' | 'sound' | 'comfort' | 'cables' | 'shipping'>('compatibility')
    
      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' },
      ]
    
      const filteredQuestions = $derived.by(() => {
        return localQuestions.filter((q) => {
          const matchesCat = selectedCategory === 'all' || q.category === selectedCategory
          const matchesSearch =
            searchQuery.trim() === '' ||
            q.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
            q.answers.some((a) => a.answer.toLowerCase().includes(searchQuery.toLowerCase()))
          return matchesCat && matchesSearch
        })
      })
    
      function toggleVoteQuestion(q: QuestionItem) {
        if (q.hasVoted) {
          q.upvotes--
          q.hasVoted = false
        } else {
          q.upvotes++
          q.hasVoted = true
        }
      }
    
      function toggleVoteAnswer(ans: AnswerItem) {
        if (ans.hasUpvoted) {
          ans.helpfulCount--
          ans.hasUpvoted = false
        } else {
          ans.helpfulCount++
          ans.hasUpvoted = true
        }
      }
    
      function submitNewQuestion() {
        if (!newQuestionText.trim()) return
        localQuestions.unshift({
          id: `q-${Date.now()}`,
          question: newQuestionText.trim(),
          askedBy: 'You (Guest User)',
          askedDate: 'Just now',
          category: newQuestionCategory,
          upvotes: 1,
          hasVoted: true,
          answers: [],
        })
        newQuestionText = ''
        isAsking = false
      }
    </script>
    
    <Card data-slot="product-qa-community" class={cn('border-border w-full shadow-xs', className)}>
      <CardHeader 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"
              >
                <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>
            <CardTitle class="text-foreground mt-1 text-lg font-semibold tracking-tight sm:text-xl">
              Questions & Answers
            </CardTitle>
            <CardDescription class="text-xs">
              Have questions about {productName}? Search community inquiries or ask our acoustic engineering team.
            </CardDescription>
          </div>
    
          <Button size="sm" class="gap-1.5 self-start text-xs shadow-xs sm:self-auto" onclick={() => (isAsking = !isAsking)}>
            <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">
            {#each categories as cat (cat.id)}
              <button
                type="button"
                class={cn(
                  'rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
                  selectedCategory === cat.id
                    ? 'bg-foreground text-background shadow-2xs'
                    : 'bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground border border-transparent',
                )}
                onclick={() => (selectedCategory = cat.id as typeof selectedCategory)}
              >
                {cat.label}
              </button>
            {/each}
          </div>
    
          <div class="relative w-full sm:w-64">
            <Search class="text-muted-foreground absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2" />
            <Input bind:value={searchQuery} placeholder="Search answered Q&A..." class="h-8 pl-8 text-xs" />
          </div>
        </div>
      </CardHeader>
    
      <CardContent 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">
                <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 text-xs">Staff answers typically arrive within 4 hours</span>
            </div>
    
            <Textarea
              bind:value={newQuestionText}
              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
                  bind:value={newQuestionCategory}
                  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 variant="ghost" size="sm" class="h-7 text-xs" onclick={() => (isAsking = false)}> Cancel </Button>
                <Button size="sm" class="h-7 gap-1.5 text-xs shadow-2xs" onclick={submitNewQuestion}>
                  <Send class="size-3" />
                  <span>Submit Question</span>
                </Button>
              </div>
            </div>
          </div>
        {/if}
    
        <!-- Question List -->
        <div class="divide-border space-y-5 divide-y">
          {#each filteredQuestions as q (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>
                      <Badge variant="outline" class="h-4.5 font-mono text-xs uppercase">
                        {q.category}
                      </Badge>
                    </div>
                  </div>
                </div>
    
                <!-- Upvote Question Button -->
                <button
                  type="button"
                  class={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',
                    q.hasVoted
                      ? 'border-primary bg-primary/10 text-primary'
                      : 'border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground',
                  )}
                  onclick={() => toggleVoteQuestion(q)}
                >
                  <ThumbsUp class="size-3" />
                  <span>{q.upvotes}</span>
                </button>
              </div>
    
              <!-- Answers Section -->
              <div class="ml-9 space-y-3">
                {#each q.answers as ans (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'}
                          <Badge variant="default" class="bg-success h-4.5 gap-1 px-1.5 text-xs font-semibold text-white">
                            <ShieldCheck class="size-2.5" />
                            Staff Expert
                          </Badge>
                        {:else if ans.role === 'verified_buyer'}
                          <Badge variant="secondary" class="text-foreground h-4.5 gap-1 px-1.5 text-xs font-semibold">
                            <CheckCircle class="text-success size-2.5" />
                            Verified Owner
                          </Badge>
                        {/if}
    
                        <span class="text-foreground font-semibold">{ans.answeredBy}</span>
                        <span class="text-muted-foreground">· {ans.answeredDate}</span>
                      </div>
    
                      <button
                        type="button"
                        class={cn(
                          'flex items-center gap-1 text-xs font-medium transition-colors',
                          ans.hasUpvoted ? 'text-primary font-bold' : 'text-muted-foreground hover:text-foreground',
                        )}
                        onclick={() => toggleVoteAnswer(ans)}
                      >
                        <ThumbsUp class="size-3" />
                        <span>Helpful ({ans.helpfulCount})</span>
                      </button>
                    </div>
    
                    <!-- Answer Body Text -->
                    <p class="text-foreground leading-relaxed">
                      {ans.answer}
                    </p>
                  </div>
                {/each}
    
                {#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>
                {/if}
              </div>
            </div>
          {/each}
    
          {#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>
          {/if}
        </div>
      </CardContent>
    </Card>
    

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