Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions app/components/demo/TestimonialsDemo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { Testimonials, type TestimonialItem } from "~/registry/blocks/testimonials";

const testimonials: TestimonialItem[] = [
{
id: "sarah-chen",
quote: "Stackhacker UI gave our team a polished starting point without locking us into a product model. We shipped our customer portal redesign in half the time.",
author: {
name: "Sarah Chen",
role: "Product Director, Bloom Finance",
avatar: {
src: "https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?auto=format&fit=facearea&facepad=2&w=96&h=96&q=80",
srcset: "https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?auto=format&fit=facearea&facepad=2&w=192&h=192&q=80 2x",
},
},
},
{
id: "michael-rodriguez",
quote: "The blocks feel production-ready, but still simple enough for our engineers to understand and adapt. That balance is hard to find.",
author: {
name: "Michael Rodriguez",
role: "Co-founder, Wavelength Music",
avatar: {
src: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=facearea&facepad=2&w=96&h=96&q=80",
srcset: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=facearea&facepad=2&w=192&h=192&q=80 2x",
},
},
},
{
id: "aisha-johnson",
quote: "We could bring our own data, avatars, and copy while keeping the interface consistent with our design system. It removed a lot of busywork.",
author: {
name: "Dr. Aisha Johnson",
role: "Chief Innovation Officer, GreenTech Solutions",
avatar: {
src: "https://images.unsplash.com/photo-1573497019940-1c28c88b4f3e?auto=format&fit=facearea&facepad=2&w=96&h=96&q=80",
srcset: "https://images.unsplash.com/photo-1573497019940-1c28c88b4f3e?auto=format&fit=facearea&facepad=2&w=192&h=192&q=80 2x",
},
},
},
];
</script>

<template>
<div class="overflow-hidden rounded-xl border bg-muted/20">
<Testimonials
title="Trusted by teams shipping faster"
description="Use app-owned testimonial data to add focused social proof to a landing page, pricing page, or launch announcement."
:items="testimonials"
class="py-10"
/>
</div>
</template>
37 changes: 37 additions & 0 deletions app/components/demo/TestimonialsFallbackDemo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { Testimonials, type TestimonialItem } from "~/registry/blocks/testimonials";

const testimonials: TestimonialItem[] = [
{
id: "no-avatar",
quote: "The fallback initials keep author identity clear even when a profile photo is not available.",
author: {
name: "Jordan Lee",
role: "Founder, Northstar Labs",
},
},
{
id: "broken-avatar",
quote: "A failed avatar image falls back to initials without changing the testimonial layout.",
author: {
name: "Priya Shah",
role: "Design Lead, Orbit Studio",
avatar: {
src: "data:image/gif;base64,invalid",
alt: "Priya Shah",
},
},
},
];
</script>

<template>
<div class="overflow-hidden rounded-xl border bg-muted/20">
<Testimonials
title="Avatar fallbacks"
description="Testimonials stay readable when app-owned avatar data is missing or fails to load."
:items="testimonials"
class="py-10"
/>
</div>
</template>
129 changes: 129 additions & 0 deletions app/registry/blocks/testimonials/Testimonials.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { ref } from "vue";
import { cn } from "@/lib/utils";

defineOptions({
name: "TestimonialsBlock",
});

export interface TestimonialAvatar {
src: string;
alt?: string;
srcset?: string;
}

export interface TestimonialAuthor {
name: string;
role?: string;
description?: string;
avatar?: TestimonialAvatar;
}

export interface TestimonialItem {
id?: string;
quote: string;
author: TestimonialAuthor;
}

export interface TestimonialsProps {
/** Section heading shown above the testimonials */
title?: string;
/** Optional supporting copy shown below the title */
description?: string;
/** Testimonials supplied by the consuming app */
items?: TestimonialItem[];
/** Additional CSS classes for the section */
class?: HTMLAttributes["class"];
}

const props = withDefaults(defineProps<TestimonialsProps>(), {
title: "What our customers say",
description: "Real feedback from teams using this product to move faster.",
items: () => [],
});

const failedAvatarKeys = ref(new Set<string>());

function itemKey(item: TestimonialItem, index: number) {
return item.id ?? `${item.author.name}-${index}`;
}

function avatarKey(item: TestimonialItem, index: number) {
return `${itemKey(item, index)}:${item.author.avatar?.src ?? ""}`;
}

function initials(name: string) {
return name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map(part => part.charAt(0))
.join("")
.toUpperCase();
}

function onAvatarError(key: string) {
failedAvatarKeys.value = new Set(failedAvatarKeys.value).add(key);
}
</script>

<template>
<section
data-slot="testimonials"
:class="cn('py-12 md:py-16', props.class)"
>
<div class="mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="mx-auto max-w-2xl text-center">
<h2 class="text-balance text-3xl font-semibold tracking-tight sm:text-4xl">
{{ title }}
</h2>
<p
v-if="description"
class="mt-3 text-base/7 text-muted-foreground"
>
{{ description }}
</p>
</div>

<div class="mt-10 grid gap-4 md:grid-cols-3">
<figure
v-for="(item, index) in items"
:key="itemKey(item, index)"
class="flex min-w-0 flex-col justify-between rounded-2xl border bg-card p-6 text-card-foreground shadow-sm"
>
<blockquote class="text-base/7 text-foreground">
<p class="break-words before:mr-1 before:text-muted-foreground before:content-[open-quote] after:ml-1 after:text-muted-foreground after:content-[close-quote]">
{{ item.quote }}
</p>
</blockquote>

<figcaption class="mt-8 flex items-center gap-3">
<span class="relative flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted text-sm font-medium text-muted-foreground ring-1 ring-border">
<img
v-if="item.author.avatar && !failedAvatarKeys.has(avatarKey(item, index))"
:src="item.author.avatar.src"
:srcset="item.author.avatar.srcset"
:alt="item.author.avatar.alt ?? item.author.name"
class="size-full object-cover"
@error="onAvatarError(avatarKey(item, index))"
>
<span v-else>{{ initials(item.author.name) }}</span>
</span>
<span class="min-w-0">
<cite class="block truncate text-sm font-medium not-italic">
{{ item.author.name }}
</cite>
<span
v-if="item.author.role || item.author.description"
class="block truncate text-sm text-muted-foreground"
>
{{ item.author.role || item.author.description }}
</span>
</span>
</figcaption>
</figure>
</div>
</div>
</section>
</template>
1 change: 1 addition & 0 deletions app/registry/blocks/testimonials/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as Testimonials, type TestimonialAuthor, type TestimonialAvatar, type TestimonialItem, type TestimonialsProps } from "./Testimonials.vue";
116 changes: 116 additions & 0 deletions content/docs/2.components/13.testimonials.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
---
title: Testimonials
description: Render a responsive social-proof section from app-owned testimonial data.
category: marketing
---

::component-preview
---
name: TestimonialsDemo
---
::

## Installation

```bash
npx shadcn-vue@latest add "https://ui.stackhacker.io/r/testimonials.json"
```

## Usage

```vue
<script setup lang="ts">
import { Testimonials, type TestimonialItem } from "@/components/testimonials";

const testimonials: TestimonialItem[] = [
{
id: "sarah-chen",
quote: "The redesign reduced support tickets and made onboarding feel effortless.",
author: {
name: "Sarah Chen",
role: "Product Director, Bloom Finance",
avatar: {
src: "/avatars/sarah.jpg",
alt: "Sarah Chen",
},
},
},
];
</script>

<template>
<Testimonials :items="testimonials" />
</template>
```

## App-Owned Testimonials

`Testimonials` owns the section layout, quote cards, accessible author rendering, and simple avatar fallback. Your app owns testimonial collection, moderation, ordering, source mapping, avatar storage, image optimization, carousel behavior, analytics, and any CMS or API integration.

Pass already-approved, display-ready testimonial items to the component. Keep remote fetching, caching, personalization, and rotation logic in app code so the registry item remains portable.

## Examples

### Default

::component-preview
---
name: TestimonialsDemo
---
::

### Without Avatars

::component-preview
---
name: TestimonialsFallbackDemo
---
::

```vue
<Testimonials
:items="[
{
id: 'no-avatar',
quote: 'The component still renders author identity clearly without an image.',
author: { name: 'Jordan Lee', role: 'Founder, Northstar Labs' },
},
]"
/>
```

## API Reference

### Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `title` | `string` | `"What our customers say"` | Section heading shown above the testimonials. |
| `description` | `string` | `"Real feedback from teams using this product to move faster."` | Optional supporting copy shown below the title. |
| `items` | `TestimonialItem[]` | `[]` | Testimonials supplied by your app. |
| `class` | `HTMLAttributes["class"]` | — | Additional CSS classes for the section. |

### Item Type

| Field | Type | Description |
|-------|------|-------------|
| `id` | `string` | Stable item id. Falls back to author name plus index when omitted. |
| `quote` | `string` | Testimonial quote text. |
| `author` | `TestimonialAuthor` | Person or organization credited for the quote. |

### Author Type

| Field | Type | Description |
|-------|------|-------------|
| `name` | `string` | Author display name. |
| `role` | `string` | Optional role, company, or byline. |
| `description` | `string` | Optional legacy-friendly byline. Used when `role` is omitted. |
| `avatar` | `TestimonialAvatar` | Optional avatar image supplied by your app. |

### Avatar Type

| Field | Type | Description |
|-------|------|-------------|
| `src` | `string` | Avatar image source. |
| `alt` | `string` | Optional alt text. Falls back to author name. |
| `srcset` | `string` | Optional responsive source set. |
4 changes: 4 additions & 0 deletions scripts/registry-verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ const expectedItems: Record<string, ExpectedRegistryItem> = {
dependencies: [],
registryDependencies: [],
},
"testimonials": {
dependencies: [],
registryDependencies: [],
},
};

if (registryIndex) {
Expand Down
Loading