Skip to content

Commit f430bd9

Browse files
committed
feat(registry): Add testimonials block
1 parent 2cee91c commit f430bd9

6 files changed

Lines changed: 340 additions & 0 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<script setup lang="ts">
2+
import { Testimonials, type TestimonialItem } from "~/registry/blocks/testimonials";
3+
4+
const testimonials: TestimonialItem[] = [
5+
{
6+
id: "sarah-chen",
7+
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.",
8+
author: {
9+
name: "Sarah Chen",
10+
role: "Product Director, Bloom Finance",
11+
avatar: {
12+
src: "https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?auto=format&fit=facearea&facepad=2&w=96&h=96&q=80",
13+
srcset: "https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?auto=format&fit=facearea&facepad=2&w=192&h=192&q=80 2x",
14+
},
15+
},
16+
},
17+
{
18+
id: "michael-rodriguez",
19+
quote: "The blocks feel production-ready, but still simple enough for our engineers to understand and adapt. That balance is hard to find.",
20+
author: {
21+
name: "Michael Rodriguez",
22+
role: "Co-founder, Wavelength Music",
23+
avatar: {
24+
src: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=facearea&facepad=2&w=96&h=96&q=80",
25+
srcset: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=facearea&facepad=2&w=192&h=192&q=80 2x",
26+
},
27+
},
28+
},
29+
{
30+
id: "aisha-johnson",
31+
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.",
32+
author: {
33+
name: "Dr. Aisha Johnson",
34+
role: "Chief Innovation Officer, GreenTech Solutions",
35+
avatar: {
36+
src: "https://images.unsplash.com/photo-1573497019940-1c28c88b4f3e?auto=format&fit=facearea&facepad=2&w=96&h=96&q=80",
37+
srcset: "https://images.unsplash.com/photo-1573497019940-1c28c88b4f3e?auto=format&fit=facearea&facepad=2&w=192&h=192&q=80 2x",
38+
},
39+
},
40+
},
41+
];
42+
</script>
43+
44+
<template>
45+
<div class="overflow-hidden rounded-xl border bg-muted/20">
46+
<Testimonials
47+
title="Trusted by teams shipping faster"
48+
description="Use app-owned testimonial data to add focused social proof to a landing page, pricing page, or launch announcement."
49+
:items="testimonials"
50+
class="py-10"
51+
/>
52+
</div>
53+
</template>
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<script setup lang="ts">
2+
import { Testimonials, type TestimonialItem } from "~/registry/blocks/testimonials";
3+
4+
const testimonials: TestimonialItem[] = [
5+
{
6+
id: "no-avatar",
7+
quote: "The fallback initials keep author identity clear even when a profile photo is not available.",
8+
author: {
9+
name: "Jordan Lee",
10+
role: "Founder, Northstar Labs",
11+
},
12+
},
13+
{
14+
id: "broken-avatar",
15+
quote: "A failed avatar image falls back to initials without changing the testimonial layout.",
16+
author: {
17+
name: "Priya Shah",
18+
role: "Design Lead, Orbit Studio",
19+
avatar: {
20+
src: "data:image/gif;base64,invalid",
21+
alt: "Priya Shah",
22+
},
23+
},
24+
},
25+
];
26+
</script>
27+
28+
<template>
29+
<div class="overflow-hidden rounded-xl border bg-muted/20">
30+
<Testimonials
31+
title="Avatar fallbacks"
32+
description="Testimonials stay readable when app-owned avatar data is missing or fails to load."
33+
:items="testimonials"
34+
class="py-10"
35+
/>
36+
</div>
37+
</template>
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
<script setup lang="ts">
2+
import type { HTMLAttributes } from "vue";
3+
import { ref } from "vue";
4+
import { cn } from "@/lib/utils";
5+
6+
defineOptions({
7+
name: "TestimonialsBlock",
8+
});
9+
10+
export interface TestimonialAvatar {
11+
src: string;
12+
alt?: string;
13+
srcset?: string;
14+
}
15+
16+
export interface TestimonialAuthor {
17+
name: string;
18+
role?: string;
19+
description?: string;
20+
avatar?: TestimonialAvatar;
21+
}
22+
23+
export interface TestimonialItem {
24+
id?: string;
25+
quote: string;
26+
author: TestimonialAuthor;
27+
}
28+
29+
export interface TestimonialsProps {
30+
/** Section heading shown above the testimonials */
31+
title?: string;
32+
/** Optional supporting copy shown below the title */
33+
description?: string;
34+
/** Testimonials supplied by the consuming app */
35+
items?: TestimonialItem[];
36+
/** Additional CSS classes for the section */
37+
class?: HTMLAttributes["class"];
38+
}
39+
40+
const props = withDefaults(defineProps<TestimonialsProps>(), {
41+
title: "What our customers say",
42+
description: "Real feedback from teams using this product to move faster.",
43+
items: () => [],
44+
});
45+
46+
const failedAvatarKeys = ref(new Set<string>());
47+
48+
function itemKey(item: TestimonialItem, index: number) {
49+
return item.id ?? `${item.author.name}-${index}`;
50+
}
51+
52+
function avatarKey(item: TestimonialItem, index: number) {
53+
return `${itemKey(item, index)}:${item.author.avatar?.src ?? ""}`;
54+
}
55+
56+
function initials(name: string) {
57+
return name
58+
.split(" ")
59+
.filter(Boolean)
60+
.slice(0, 2)
61+
.map(part => part.charAt(0))
62+
.join("")
63+
.toUpperCase();
64+
}
65+
66+
function onAvatarError(key: string) {
67+
failedAvatarKeys.value = new Set(failedAvatarKeys.value).add(key);
68+
}
69+
</script>
70+
71+
<template>
72+
<section
73+
data-slot="testimonials"
74+
:class="cn('py-12 md:py-16', props.class)"
75+
>
76+
<div class="mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8">
77+
<div class="mx-auto max-w-2xl text-center">
78+
<h2 class="text-balance text-3xl font-semibold tracking-tight sm:text-4xl">
79+
{{ title }}
80+
</h2>
81+
<p
82+
v-if="description"
83+
class="mt-3 text-base/7 text-muted-foreground"
84+
>
85+
{{ description }}
86+
</p>
87+
</div>
88+
89+
<div class="mt-10 grid gap-4 md:grid-cols-3">
90+
<figure
91+
v-for="(item, index) in items"
92+
:key="itemKey(item, index)"
93+
class="flex min-w-0 flex-col justify-between rounded-2xl border bg-card p-6 text-card-foreground shadow-sm"
94+
>
95+
<blockquote class="text-base/7 text-foreground">
96+
<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]">
97+
{{ item.quote }}
98+
</p>
99+
</blockquote>
100+
101+
<figcaption class="mt-8 flex items-center gap-3">
102+
<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">
103+
<img
104+
v-if="item.author.avatar && !failedAvatarKeys.has(avatarKey(item, index))"
105+
:src="item.author.avatar.src"
106+
:srcset="item.author.avatar.srcset"
107+
:alt="item.author.avatar.alt ?? item.author.name"
108+
class="size-full object-cover"
109+
@error="onAvatarError(avatarKey(item, index))"
110+
>
111+
<span v-else>{{ initials(item.author.name) }}</span>
112+
</span>
113+
<span class="min-w-0">
114+
<cite class="block truncate text-sm font-medium not-italic">
115+
{{ item.author.name }}
116+
</cite>
117+
<span
118+
v-if="item.author.role || item.author.description"
119+
class="block truncate text-sm text-muted-foreground"
120+
>
121+
{{ item.author.role || item.author.description }}
122+
</span>
123+
</span>
124+
</figcaption>
125+
</figure>
126+
</div>
127+
</div>
128+
</section>
129+
</template>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as Testimonials, type TestimonialAuthor, type TestimonialAvatar, type TestimonialItem, type TestimonialsProps } from "./Testimonials.vue";
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
---
2+
title: Testimonials
3+
description: Render a responsive social-proof section from app-owned testimonial data.
4+
category: marketing
5+
---
6+
7+
::component-preview
8+
---
9+
name: TestimonialsDemo
10+
---
11+
::
12+
13+
## Installation
14+
15+
```bash
16+
npx shadcn-vue@latest add "https://ui.stackhacker.io/r/testimonials.json"
17+
```
18+
19+
## Usage
20+
21+
```vue
22+
<script setup lang="ts">
23+
import { Testimonials, type TestimonialItem } from "@/components/testimonials";
24+
25+
const testimonials: TestimonialItem[] = [
26+
{
27+
id: "sarah-chen",
28+
quote: "The redesign reduced support tickets and made onboarding feel effortless.",
29+
author: {
30+
name: "Sarah Chen",
31+
role: "Product Director, Bloom Finance",
32+
avatar: {
33+
src: "/avatars/sarah.jpg",
34+
alt: "Sarah Chen",
35+
},
36+
},
37+
},
38+
];
39+
</script>
40+
41+
<template>
42+
<Testimonials :items="testimonials" />
43+
</template>
44+
```
45+
46+
## App-Owned Testimonials
47+
48+
`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.
49+
50+
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.
51+
52+
## Examples
53+
54+
### Default
55+
56+
::component-preview
57+
---
58+
name: TestimonialsDemo
59+
---
60+
::
61+
62+
### Without Avatars
63+
64+
::component-preview
65+
---
66+
name: TestimonialsFallbackDemo
67+
---
68+
::
69+
70+
```vue
71+
<Testimonials
72+
:items="[
73+
{
74+
id: 'no-avatar',
75+
quote: 'The component still renders author identity clearly without an image.',
76+
author: { name: 'Jordan Lee', role: 'Founder, Northstar Labs' },
77+
},
78+
]"
79+
/>
80+
```
81+
82+
## API Reference
83+
84+
### Props
85+
86+
| Prop | Type | Default | Description |
87+
|------|------|---------|-------------|
88+
| `title` | `string` | `"What our customers say"` | Section heading shown above the testimonials. |
89+
| `description` | `string` | `"Real feedback from teams using this product to move faster."` | Optional supporting copy shown below the title. |
90+
| `items` | `TestimonialItem[]` | `[]` | Testimonials supplied by your app. |
91+
| `class` | `HTMLAttributes["class"]` || Additional CSS classes for the section. |
92+
93+
### Item Type
94+
95+
| Field | Type | Description |
96+
|-------|------|-------------|
97+
| `id` | `string` | Stable item id. Falls back to author name plus index when omitted. |
98+
| `quote` | `string` | Testimonial quote text. |
99+
| `author` | `TestimonialAuthor` | Person or organization credited for the quote. |
100+
101+
### Author Type
102+
103+
| Field | Type | Description |
104+
|-------|------|-------------|
105+
| `name` | `string` | Author display name. |
106+
| `role` | `string` | Optional role, company, or byline. |
107+
| `description` | `string` | Optional legacy-friendly byline. Used when `role` is omitted. |
108+
| `avatar` | `TestimonialAvatar` | Optional avatar image supplied by your app. |
109+
110+
### Avatar Type
111+
112+
| Field | Type | Description |
113+
|-------|------|-------------|
114+
| `src` | `string` | Avatar image source. |
115+
| `alt` | `string` | Optional alt text. Falls back to author name. |
116+
| `srcset` | `string` | Optional responsive source set. |

scripts/registry-verify.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,10 @@ const expectedItems: Record<string, ExpectedRegistryItem> = {
127127
dependencies: [],
128128
registryDependencies: [],
129129
},
130+
"testimonials": {
131+
dependencies: [],
132+
registryDependencies: [],
133+
},
130134
};
131135

132136
if (registryIndex) {

0 commit comments

Comments
 (0)