keystone-design-bootstrap 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/design_system/elements/buttons/button.aman.tsx +1 -1
- package/src/design_system/elements/carousel/carousel-section-wrapper.tsx +59 -0
- package/src/design_system/elements/index.tsx +1 -0
- package/src/design_system/sections/blog-gallery.aman.tsx +92 -35
- package/src/design_system/sections/blog-gallery.tsx +4 -30
- package/src/design_system/sections/blog-section.aman.tsx +22 -36
- package/src/design_system/sections/contact-section.aman.tsx +7 -6
- package/src/design_system/sections/faq-grid.aman.tsx +1 -1
- package/src/design_system/sections/faq-home.aman.tsx +1 -1
- package/src/design_system/sections/faq-home.tsx +1 -1
- package/src/design_system/sections/header-navigation.aman.tsx +68 -17
- package/src/design_system/sections/header-navigation.tsx +30 -3
- package/src/design_system/sections/index.tsx +3 -0
- package/src/design_system/sections/location-details-section.aman.tsx +2 -3
- package/src/design_system/sections/social-media-grid.aman.tsx +8 -10
- package/src/design_system/sections/testimonials-grid.aman.tsx +111 -0
- package/src/design_system/sections/testimonials-grid.tsx +100 -0
- package/src/design_system/sections/testimonials-home.aman.tsx +23 -31
- package/src/styles/style-overrides.aman.css +14 -2
package/package.json
CHANGED
|
@@ -34,7 +34,7 @@ export const styles = sortCx({
|
|
|
34
34
|
|
|
35
35
|
colors: {
|
|
36
36
|
primary: {
|
|
37
|
-
root: "bg-
|
|
37
|
+
root: "bg-brand-solid text-white hover:bg-brand-solid_hover transition-colors",
|
|
38
38
|
},
|
|
39
39
|
secondary: {
|
|
40
40
|
root: "bg-white text-fg-primary border-2 hover:opacity-90 transition-all",
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { ReactNode } from 'react';
|
|
4
|
+
import { Carousel } from '../carousel/carousel';
|
|
5
|
+
import { ChevronLeft, ChevronRight } from '@untitledui/icons';
|
|
6
|
+
|
|
7
|
+
interface CarouselSectionWrapperProps {
|
|
8
|
+
title?: string;
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
hasItems: boolean;
|
|
11
|
+
emptyMessage?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const CarouselSectionWrapper = ({
|
|
15
|
+
title = "",
|
|
16
|
+
children,
|
|
17
|
+
hasItems,
|
|
18
|
+
emptyMessage = "No items available"
|
|
19
|
+
}: CarouselSectionWrapperProps) => {
|
|
20
|
+
if (!hasItems) {
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
{title && (
|
|
24
|
+
<div className="mb-12">
|
|
25
|
+
<h2 className="font-display text-4xl font-normal text-fg-primary md:text-5xl">
|
|
26
|
+
{title}
|
|
27
|
+
</h2>
|
|
28
|
+
</div>
|
|
29
|
+
)}
|
|
30
|
+
<div className="text-center py-12">
|
|
31
|
+
<p className="font-body text-base text-tertiary">{emptyMessage}</p>
|
|
32
|
+
</div>
|
|
33
|
+
</>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<Carousel.Root opts={{ align: "start", loop: true }}>
|
|
39
|
+
<div className="flex items-center justify-between mb-12">
|
|
40
|
+
<h2 className="font-display text-4xl font-normal text-fg-primary md:text-5xl">
|
|
41
|
+
{title}
|
|
42
|
+
</h2>
|
|
43
|
+
<div className="flex gap-2">
|
|
44
|
+
<Carousel.PrevTrigger className="rounded-full p-2 border border-secondary hover:bg-primary_hover transition-colors disabled:opacity-30 disabled:cursor-not-allowed">
|
|
45
|
+
<ChevronLeft className="w-6 h-6 text-fg-primary" />
|
|
46
|
+
</Carousel.PrevTrigger>
|
|
47
|
+
<Carousel.NextTrigger className="rounded-full p-2 border border-secondary hover:bg-primary_hover transition-colors disabled:opacity-30 disabled:cursor-not-allowed">
|
|
48
|
+
<ChevronRight className="w-6 h-6 text-fg-primary" />
|
|
49
|
+
</Carousel.NextTrigger>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<Carousel.Content className="-ml-4">
|
|
54
|
+
{children}
|
|
55
|
+
</Carousel.Content>
|
|
56
|
+
</Carousel.Root>
|
|
57
|
+
);
|
|
58
|
+
};
|
|
59
|
+
|
|
@@ -153,6 +153,7 @@ export { SlideoutMenu } from './slideout-menus/slideout-menu';
|
|
|
153
153
|
export * from './rating/rating-stars';
|
|
154
154
|
export * from './rating/rating-badge';
|
|
155
155
|
export * from './carousel/carousel';
|
|
156
|
+
export { CarouselSectionWrapper } from './carousel/carousel-section-wrapper';
|
|
156
157
|
|
|
157
158
|
// Re-export combobox (still exists in select directory but not wrapped with theming)
|
|
158
159
|
export { ComboBox } from './select/combobox';
|
|
@@ -1,60 +1,117 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import { PhotoWithFallback, PaginationPageDefault } from '../elements';
|
|
4
5
|
import type { BlogPost } from '../../types/api/blog-post';
|
|
5
6
|
|
|
6
7
|
interface BlogGalleryProps {
|
|
7
8
|
blogPosts?: BlogPost[] | null;
|
|
9
|
+
postsPerPage?: number;
|
|
10
|
+
className?: string;
|
|
8
11
|
}
|
|
9
12
|
|
|
10
13
|
export const BlogGallery = ({
|
|
11
14
|
blogPosts: postsData,
|
|
15
|
+
postsPerPage = 12,
|
|
16
|
+
className = "",
|
|
12
17
|
}: BlogGalleryProps) => {
|
|
13
18
|
const posts = Array.isArray(postsData) ? postsData : [];
|
|
19
|
+
const [currentPage, setCurrentPage] = useState(1);
|
|
20
|
+
|
|
21
|
+
// Get featured post (first post with featured: true or first post)
|
|
22
|
+
const featuredPost = posts.find((post) => post.featured) || posts[0];
|
|
23
|
+
const nonFeaturedPosts = posts.filter((post) => post.id !== featuredPost?.id);
|
|
24
|
+
|
|
25
|
+
// Calculate pagination for non-featured posts
|
|
26
|
+
const totalPages = Math.ceil(nonFeaturedPosts.length / postsPerPage);
|
|
27
|
+
const startIndex = (currentPage - 1) * postsPerPage;
|
|
28
|
+
const endIndex = startIndex + postsPerPage;
|
|
29
|
+
const paginatedNonFeaturedPosts = nonFeaturedPosts.slice(startIndex, endIndex);
|
|
14
30
|
|
|
15
31
|
return (
|
|
16
|
-
<section>
|
|
32
|
+
<section className={className}>
|
|
17
33
|
<div className="mx-auto max-w-container px-4 md:px-8">
|
|
18
34
|
{posts.length > 0 ? (
|
|
19
|
-
|
|
20
|
-
{
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
<>
|
|
36
|
+
{/* Featured Post - Full Width */}
|
|
37
|
+
{featuredPost && (
|
|
38
|
+
<a
|
|
39
|
+
href={`/blog/${featuredPost.slug}`}
|
|
40
|
+
className="block mb-16 group"
|
|
41
|
+
>
|
|
42
|
+
<div className="w-full h-96 md:h-[500px] mb-8 overflow-hidden">
|
|
43
|
+
<PhotoWithFallback
|
|
44
|
+
item={featuredPost}
|
|
45
|
+
fallbackId={featuredPost.id}
|
|
46
|
+
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
47
|
+
/>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div className="max-w-3xl">
|
|
51
|
+
<p className="text-xs font-body font-normal uppercase tracking-widest mb-4" style={{ color: 'var(--color-text-brand-secondary)' }}>
|
|
52
|
+
{featuredPost.published_at ? new Date(featuredPost.published_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) : 'Recent'}
|
|
35
53
|
</p>
|
|
36
54
|
|
|
37
|
-
<
|
|
38
|
-
{
|
|
39
|
-
</
|
|
55
|
+
<h2 className="font-display text-3xl md:text-4xl font-normal text-fg-primary mb-6 group-hover:underline">
|
|
56
|
+
{featuredPost.title}
|
|
57
|
+
</h2>
|
|
40
58
|
|
|
41
|
-
{
|
|
42
|
-
<p className="font-
|
|
43
|
-
{
|
|
59
|
+
{featuredPost.excerpt_markdown && (
|
|
60
|
+
<p className="font-display text-lg leading-relaxed text-tertiary">
|
|
61
|
+
{featuredPost.excerpt_markdown.replace(/[#*\[\]()]/g, '').trim()}
|
|
44
62
|
</p>
|
|
45
63
|
)}
|
|
46
|
-
|
|
47
|
-
<a
|
|
48
|
-
href={`/blog/${post.slug}`}
|
|
49
|
-
className="font-body text-base underline underline-offset-4 hover:no-underline transition-colors"
|
|
50
|
-
style={{ color: 'var(--color-text-brand-accent)' }}
|
|
51
|
-
>
|
|
52
|
-
Read more
|
|
53
|
-
</a>
|
|
54
64
|
</div>
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
65
|
+
</a>
|
|
66
|
+
)}
|
|
67
|
+
|
|
68
|
+
{/* Posts Grid */}
|
|
69
|
+
{paginatedNonFeaturedPosts.length > 0 && (
|
|
70
|
+
<div className="grid grid-cols-1 gap-12 md:grid-cols-2 lg:grid-cols-3 mb-16">
|
|
71
|
+
{paginatedNonFeaturedPosts.map((post: any) => {
|
|
72
|
+
const excerpt = post.excerpt_markdown || post.content_markdown?.replace(/[#*\[\]()]/g, '').trim().substring(0, 150) || '';
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<a key={post.id} href={`/blog/${post.slug}`} className="flex flex-col group">
|
|
76
|
+
<div className="w-full h-64 mb-6 overflow-hidden">
|
|
77
|
+
<PhotoWithFallback
|
|
78
|
+
item={post}
|
|
79
|
+
fallbackId={post.id}
|
|
80
|
+
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
81
|
+
/>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<p className="text-xs font-body font-normal uppercase tracking-widest mb-2" style={{ color: 'var(--color-text-brand-secondary)' }}>
|
|
85
|
+
{post.published_at ? new Date(post.published_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) : 'Recent'}
|
|
86
|
+
</p>
|
|
87
|
+
|
|
88
|
+
<h3 className="font-display text-2xl font-normal text-fg-primary mb-4 group-hover:underline">
|
|
89
|
+
{post.title}
|
|
90
|
+
</h3>
|
|
91
|
+
|
|
92
|
+
{excerpt && (
|
|
93
|
+
<p className="font-body text-base leading-relaxed text-tertiary mb-4 flex-grow line-clamp-3">
|
|
94
|
+
{excerpt}
|
|
95
|
+
</p>
|
|
96
|
+
)}
|
|
97
|
+
</a>
|
|
98
|
+
);
|
|
99
|
+
})}
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
|
|
103
|
+
{/* Pagination */}
|
|
104
|
+
{totalPages > 1 && (
|
|
105
|
+
<div className="flex justify-center">
|
|
106
|
+
<PaginationPageDefault
|
|
107
|
+
rounded
|
|
108
|
+
page={currentPage}
|
|
109
|
+
total={totalPages}
|
|
110
|
+
onPageChange={setCurrentPage}
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
</>
|
|
58
115
|
) : (
|
|
59
116
|
<div className="text-center py-12">
|
|
60
117
|
<p className="font-body text-base text-tertiary">No posts available</p>
|
|
@@ -11,11 +11,7 @@ import type { BlogPost } from '../../types/api/blog-post';
|
|
|
11
11
|
|
|
12
12
|
interface BlogGalleryProps {
|
|
13
13
|
blogPosts?: BlogPost[] | null;
|
|
14
|
-
label?: string;
|
|
15
|
-
title?: string;
|
|
16
|
-
subtitle?: string;
|
|
17
14
|
postsPerPage?: number;
|
|
18
|
-
backgroundColor?: string;
|
|
19
15
|
className?: string;
|
|
20
16
|
}
|
|
21
17
|
|
|
@@ -27,11 +23,7 @@ const defaultSortByOptions = [
|
|
|
27
23
|
|
|
28
24
|
export const BlogGallery = ({
|
|
29
25
|
blogPosts: postsData,
|
|
30
|
-
label = "",
|
|
31
|
-
title = "",
|
|
32
|
-
subtitle = "",
|
|
33
26
|
postsPerPage = 12,
|
|
34
|
-
backgroundColor = "bg-primary",
|
|
35
27
|
className = "",
|
|
36
28
|
}: BlogGalleryProps) => {
|
|
37
29
|
const sortByOptions = defaultSortByOptions;
|
|
@@ -68,26 +60,8 @@ export const BlogGallery = ({
|
|
|
68
60
|
}, [sortBy]);
|
|
69
61
|
|
|
70
62
|
return (
|
|
71
|
-
<
|
|
72
|
-
|
|
73
|
-
<section className={`${backgroundColor} py-16 md:py-24`}>
|
|
74
|
-
<div className="mx-auto w-full max-w-container px-4 md:px-8">
|
|
75
|
-
<div className="flex w-full max-w-3xl flex-col">
|
|
76
|
-
{label && (
|
|
77
|
-
<span className="text-sm font-semibold text-brand-secondary md:text-md">{label}</span>
|
|
78
|
-
)}
|
|
79
|
-
<h2 className="mt-3 text-display-md font-semibold text-primary md:text-display-lg">
|
|
80
|
-
{title}
|
|
81
|
-
</h2>
|
|
82
|
-
<p className="mt-4 text-lg text-tertiary md:mt-6 md:text-xl">
|
|
83
|
-
{subtitle}
|
|
84
|
-
</p>
|
|
85
|
-
</div>
|
|
86
|
-
</div>
|
|
87
|
-
</section>
|
|
88
|
-
|
|
89
|
-
{/* Main Content */}
|
|
90
|
-
<main className="mx-auto flex w-full max-w-container flex-col gap-12 px-4 pb-16 md:gap-16 md:px-8 md:pb-24">
|
|
63
|
+
<section className={className}>
|
|
64
|
+
<div className="mx-auto flex w-full max-w-container flex-col gap-12 px-4 md:gap-16 md:px-8">
|
|
91
65
|
{posts.length > 0 ? (
|
|
92
66
|
<>
|
|
93
67
|
{/* Featured Post - Desktop */}
|
|
@@ -196,7 +170,7 @@ export const BlogGallery = ({
|
|
|
196
170
|
<p className="text-gray-500">No blog posts available</p>
|
|
197
171
|
</div>
|
|
198
172
|
)}
|
|
199
|
-
</
|
|
200
|
-
</
|
|
173
|
+
</div>
|
|
174
|
+
</section>
|
|
201
175
|
);
|
|
202
176
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import React from 'react';
|
|
4
|
-
import { PhotoWithFallback } from '../elements';
|
|
4
|
+
import { PhotoWithFallback, Carousel, CarouselSectionWrapper } from '../elements';
|
|
5
5
|
import type { BlogPost } from '../../types/api/blog-post';
|
|
6
6
|
|
|
7
7
|
interface BlogSectionProps {
|
|
@@ -13,10 +13,10 @@ interface BlogSectionProps {
|
|
|
13
13
|
export const BlogSection = ({
|
|
14
14
|
blogPosts: postsData,
|
|
15
15
|
title = "",
|
|
16
|
-
maxPosts
|
|
16
|
+
maxPosts,
|
|
17
17
|
}: BlogSectionProps) => {
|
|
18
18
|
|
|
19
|
-
const postsArray = Array.isArray(postsData) ? postsData.slice(0, maxPosts) : [];
|
|
19
|
+
const postsArray = Array.isArray(postsData) ? (maxPosts ? postsData.slice(0, maxPosts) : postsData) : [];
|
|
20
20
|
|
|
21
21
|
const formatDate = (dateString?: string) => {
|
|
22
22
|
if (!dateString) return 'Recent';
|
|
@@ -34,24 +34,22 @@ export const BlogSection = ({
|
|
|
34
34
|
return (
|
|
35
35
|
<section>
|
|
36
36
|
<div className="mx-auto max-w-container px-4 md:px-8">
|
|
37
|
-
<
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return (
|
|
49
|
-
<div key={post.id} className="flex flex-col">
|
|
37
|
+
<CarouselSectionWrapper
|
|
38
|
+
title={title}
|
|
39
|
+
hasItems={postsArray.length > 0}
|
|
40
|
+
emptyMessage="No posts available"
|
|
41
|
+
>
|
|
42
|
+
{postsArray.map((post: BlogPost) => {
|
|
43
|
+
const excerpt = (post as any).summary || post.content_markdown?.replace(/[#*\[\]()]/g, '').trim().substring(0, 150) || '';
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<Carousel.Item key={post.id} className="pl-4 md:basis-1/2 lg:basis-1/3">
|
|
47
|
+
<a href={`/blog/${post.slug}`} className="flex flex-col h-full group">
|
|
50
48
|
<div className="w-full h-64 mb-6 overflow-hidden">
|
|
51
49
|
<PhotoWithFallback
|
|
52
50
|
item={post}
|
|
53
51
|
fallbackId={post.id}
|
|
54
|
-
className="w-full h-full object-cover"
|
|
52
|
+
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
55
53
|
/>
|
|
56
54
|
</div>
|
|
57
55
|
|
|
@@ -59,32 +57,20 @@ export const BlogSection = ({
|
|
|
59
57
|
{formatDate(post.published_at)}
|
|
60
58
|
</p>
|
|
61
59
|
|
|
62
|
-
<h3 className="font-display text-2xl font-normal text-fg-primary mb-4">
|
|
60
|
+
<h3 className="font-display text-2xl font-normal text-fg-primary mb-4 group-hover:underline line-clamp-1">
|
|
63
61
|
{post.title}
|
|
64
62
|
</h3>
|
|
65
63
|
|
|
66
64
|
{excerpt && (
|
|
67
|
-
<p className="font-body text-base leading-relaxed text-tertiary mb-
|
|
65
|
+
<p className="font-body text-base leading-relaxed text-tertiary mb-4 flex-grow line-clamp-3">
|
|
68
66
|
{excerpt}
|
|
69
67
|
</p>
|
|
70
68
|
)}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
>
|
|
77
|
-
Read more
|
|
78
|
-
</a>
|
|
79
|
-
</div>
|
|
80
|
-
);
|
|
81
|
-
})}
|
|
82
|
-
</div>
|
|
83
|
-
) : (
|
|
84
|
-
<div className="text-center py-12">
|
|
85
|
-
<p className="font-body text-base text-tertiary">No posts available</p>
|
|
86
|
-
</div>
|
|
87
|
-
)}
|
|
69
|
+
</a>
|
|
70
|
+
</Carousel.Item>
|
|
71
|
+
);
|
|
72
|
+
})}
|
|
73
|
+
</CarouselSectionWrapper>
|
|
88
74
|
</div>
|
|
89
75
|
</section>
|
|
90
76
|
);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useState } from 'react';
|
|
4
|
-
import { Form, PhotoWithFallback } from '../elements';
|
|
4
|
+
import { Form, PhotoWithFallback, Button } from '../elements';
|
|
5
5
|
import type { WebsitePhotos } from '../../types/api/website-photos';
|
|
6
6
|
|
|
7
7
|
interface ContactSectionProps {
|
|
@@ -125,13 +125,14 @@ const ContactSection = ({
|
|
|
125
125
|
</label>
|
|
126
126
|
</div>
|
|
127
127
|
|
|
128
|
-
<
|
|
129
|
-
type="submit"
|
|
130
|
-
|
|
131
|
-
|
|
128
|
+
<Button
|
|
129
|
+
type="submit"
|
|
130
|
+
color="primary"
|
|
131
|
+
size="xl"
|
|
132
|
+
className="w-full font-body text-base uppercase tracking-wide rounded-sm"
|
|
132
133
|
>
|
|
133
134
|
Send message
|
|
134
|
-
</
|
|
135
|
+
</Button>
|
|
135
136
|
</Form>
|
|
136
137
|
</div>
|
|
137
138
|
|
|
@@ -49,7 +49,7 @@ export const FAQGrid = ({
|
|
|
49
49
|
</button>
|
|
50
50
|
{isOpen && (
|
|
51
51
|
<div className="pb-4">
|
|
52
|
-
<p className="font-body text-base leading-relaxed text-tertiary">
|
|
52
|
+
<p className="font-body text-base leading-relaxed text-tertiary whitespace-pre-line">
|
|
53
53
|
{answer}
|
|
54
54
|
</p>
|
|
55
55
|
</div>
|
|
@@ -61,7 +61,7 @@ export const FAQHome = ({
|
|
|
61
61
|
style={{ overflow: 'hidden' }}
|
|
62
62
|
>
|
|
63
63
|
<div className="pb-4">
|
|
64
|
-
<p className="font-body text-base leading-relaxed text-tertiary">
|
|
64
|
+
<p className="font-body text-base leading-relaxed text-tertiary whitespace-pre-line">
|
|
65
65
|
{answer}
|
|
66
66
|
</p>
|
|
67
67
|
</div>
|
|
@@ -145,14 +145,29 @@ export function HeaderNavigation({
|
|
|
145
145
|
{companyName}
|
|
146
146
|
</Link>
|
|
147
147
|
|
|
148
|
-
{/* Right:
|
|
149
|
-
<div className="flex items-center gap-
|
|
148
|
+
{/* Right: CTA Buttons */}
|
|
149
|
+
<div className="flex items-center gap-3">
|
|
150
|
+
{props?.cta_button?.secondary_label && props?.cta_button?.secondary_href && (
|
|
151
|
+
<Button
|
|
152
|
+
href={props.cta_button.secondary_href}
|
|
153
|
+
target={props.cta_button.secondary_target}
|
|
154
|
+
rel={props.cta_button.secondary_target === '_blank' ? 'noopener noreferrer' : undefined}
|
|
155
|
+
size="sm"
|
|
156
|
+
color="primary"
|
|
157
|
+
className="font-body text-sm uppercase tracking-wide px-6 py-2 rounded-sm"
|
|
158
|
+
>
|
|
159
|
+
{props.cta_button.secondary_label}
|
|
160
|
+
</Button>
|
|
161
|
+
)}
|
|
150
162
|
<Button
|
|
151
|
-
href="/contact"
|
|
163
|
+
href={props?.cta_button?.href || "/contact"}
|
|
164
|
+
target={props?.cta_button?.target}
|
|
165
|
+
rel={props?.cta_button?.target === '_blank' ? 'noopener noreferrer' : undefined}
|
|
152
166
|
size="sm"
|
|
153
|
-
|
|
167
|
+
color="primary"
|
|
168
|
+
className="font-body text-sm uppercase tracking-wide px-6 py-2 rounded-sm"
|
|
154
169
|
>
|
|
155
|
-
Contact
|
|
170
|
+
{props?.cta_button?.label || "Contact"}
|
|
156
171
|
</Button>
|
|
157
172
|
</div>
|
|
158
173
|
</div>
|
|
@@ -333,24 +348,60 @@ export function HeaderNavigation({
|
|
|
333
348
|
</nav>
|
|
334
349
|
|
|
335
350
|
{/* Mobile Menu Footer */}
|
|
336
|
-
<div className="border-t border-secondary px-4 py-
|
|
337
|
-
<
|
|
338
|
-
|
|
339
|
-
|
|
351
|
+
<div className="border-t border-secondary px-4 py-6">
|
|
352
|
+
<div className="flex flex-col gap-3">
|
|
353
|
+
{props?.cta_button?.secondary_label && props?.cta_button?.secondary_href && (
|
|
354
|
+
<Button
|
|
355
|
+
href={props.cta_button.secondary_href}
|
|
356
|
+
target={props.cta_button.secondary_target}
|
|
357
|
+
rel={props.cta_button.secondary_target === '_blank' ? 'noopener noreferrer' : undefined}
|
|
358
|
+
color="primary"
|
|
359
|
+
className="w-full font-body text-sm uppercase tracking-wide py-3 rounded-sm"
|
|
360
|
+
onClick={() => setIsMobileMenuOpen(false)}
|
|
361
|
+
>
|
|
362
|
+
{props.cta_button.secondary_label}
|
|
363
|
+
</Button>
|
|
364
|
+
)}
|
|
365
|
+
<Button
|
|
366
|
+
href={props?.cta_button?.href || "/contact"}
|
|
367
|
+
target={props?.cta_button?.target}
|
|
368
|
+
rel={props?.cta_button?.target === '_blank' ? 'noopener noreferrer' : undefined}
|
|
369
|
+
color="primary"
|
|
370
|
+
className="w-full font-body text-sm uppercase tracking-wide py-3 rounded-sm"
|
|
371
|
+
onClick={() => setIsMobileMenuOpen(false)}
|
|
372
|
+
>
|
|
373
|
+
{props?.cta_button?.label || "Contact"}
|
|
374
|
+
</Button>
|
|
375
|
+
</div>
|
|
340
376
|
</div>
|
|
341
377
|
</div>
|
|
342
378
|
</div>
|
|
343
379
|
)}
|
|
344
380
|
|
|
345
381
|
{/* Sticky Contact Button (Mobile) */}
|
|
346
|
-
<div className="fixed bottom-0 left-0 right-0 z-40 md:hidden"
|
|
347
|
-
<
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
382
|
+
<div className="fixed bottom-0 left-0 right-0 z-40 md:hidden bg-fg-primary">
|
|
383
|
+
<div className="flex gap-0">
|
|
384
|
+
{props?.cta_button?.secondary_label && props?.cta_button?.secondary_href && (
|
|
385
|
+
<Button
|
|
386
|
+
href={props.cta_button.secondary_href}
|
|
387
|
+
target={props.cta_button.secondary_target}
|
|
388
|
+
rel={props.cta_button.secondary_target === '_blank' ? 'noopener noreferrer' : undefined}
|
|
389
|
+
color="primary"
|
|
390
|
+
className="flex-1 font-body text-sm uppercase tracking-wide py-4 rounded-none border-r border-gray-700"
|
|
391
|
+
>
|
|
392
|
+
{props.cta_button.secondary_label}
|
|
393
|
+
</Button>
|
|
394
|
+
)}
|
|
395
|
+
<Button
|
|
396
|
+
href={props?.cta_button?.href || "/contact"}
|
|
397
|
+
target={props?.cta_button?.target}
|
|
398
|
+
rel={props?.cta_button?.target === '_blank' ? 'noopener noreferrer' : undefined}
|
|
399
|
+
color="primary"
|
|
400
|
+
className={`${props?.cta_button?.secondary_label ? 'flex-1' : 'w-full'} font-body text-sm uppercase tracking-wide py-4 rounded-none`}
|
|
401
|
+
>
|
|
402
|
+
{props?.cta_button?.label || "Contact"}
|
|
403
|
+
</Button>
|
|
404
|
+
</div>
|
|
354
405
|
</div>
|
|
355
406
|
</>
|
|
356
407
|
);
|
|
@@ -20,8 +20,10 @@ export interface HeaderProps {
|
|
|
20
20
|
cta_button?: {
|
|
21
21
|
label: string;
|
|
22
22
|
href: string;
|
|
23
|
+
target?: '_blank' | '_self';
|
|
23
24
|
secondary_label?: string;
|
|
24
25
|
secondary_href?: string;
|
|
26
|
+
secondary_target?: '_blank' | '_self';
|
|
25
27
|
};
|
|
26
28
|
}
|
|
27
29
|
|
|
@@ -246,15 +248,27 @@ export function HeaderNavigation({
|
|
|
246
248
|
</div>
|
|
247
249
|
|
|
248
250
|
<div className="hidden items-center gap-3 md:flex">
|
|
251
|
+
{cta_button?.secondary_label && cta_button?.secondary_href && (
|
|
252
|
+
<Button
|
|
253
|
+
href={cta_button.secondary_href}
|
|
254
|
+
target={cta_button.secondary_target}
|
|
255
|
+
rel={cta_button.secondary_target === '_blank' ? 'noopener noreferrer' : undefined}
|
|
256
|
+
color="secondary"
|
|
257
|
+
size="lg"
|
|
258
|
+
>
|
|
259
|
+
{cta_button.secondary_label}
|
|
260
|
+
</Button>
|
|
261
|
+
)}
|
|
249
262
|
<Button
|
|
250
|
-
href={cta_button?.href || "/contact"}
|
|
263
|
+
href={cta_button?.href || "/contact"}
|
|
264
|
+
target={cta_button?.target}
|
|
265
|
+
rel={cta_button?.target === '_blank' ? 'noopener noreferrer' : undefined}
|
|
251
266
|
color="primary"
|
|
252
267
|
size="lg"
|
|
253
268
|
>
|
|
254
269
|
{cta_button?.label || "Get Started"}
|
|
255
270
|
</Button>
|
|
256
271
|
</div>
|
|
257
|
-
|
|
258
272
|
<AriaDialogTrigger>
|
|
259
273
|
<AriaButton
|
|
260
274
|
aria-label="Toggle navigation menu"
|
|
@@ -315,8 +329,21 @@ export function HeaderNavigation({
|
|
|
315
329
|
</ul>
|
|
316
330
|
|
|
317
331
|
<div className="flex flex-col gap-3 border-t border-secondary px-4 py-6">
|
|
332
|
+
{cta_button?.secondary_label && cta_button?.secondary_href && (
|
|
333
|
+
<Button
|
|
334
|
+
href={cta_button.secondary_href}
|
|
335
|
+
target={cta_button.secondary_target}
|
|
336
|
+
rel={cta_button.secondary_target === '_blank' ? 'noopener noreferrer' : undefined}
|
|
337
|
+
color="secondary"
|
|
338
|
+
size="lg"
|
|
339
|
+
>
|
|
340
|
+
{cta_button.secondary_label}
|
|
341
|
+
</Button>
|
|
342
|
+
)}
|
|
318
343
|
<Button
|
|
319
|
-
href={cta_button?.href || "/contact"}
|
|
344
|
+
href={cta_button?.href || "/contact"}
|
|
345
|
+
target={cta_button?.target}
|
|
346
|
+
rel={cta_button?.target === '_blank' ? 'noopener noreferrer' : undefined}
|
|
320
347
|
color="primary"
|
|
321
348
|
size="lg"
|
|
322
349
|
>
|
|
@@ -15,6 +15,7 @@ import { HeroHome as BaseHeroHome } from './hero-home';
|
|
|
15
15
|
import { ServicesHome as BaseServicesHome } from './services-home';
|
|
16
16
|
import { AboutHome as BaseAboutHome } from './about-home';
|
|
17
17
|
import { TestimonialsHome as BaseTestimonialsHome } from './testimonials-home';
|
|
18
|
+
import { TestimonialsGrid as BaseTestimonialsGrid } from './testimonials-grid';
|
|
18
19
|
import { FAQHome as BaseFAQHome } from './faq-home';
|
|
19
20
|
import { BlogSection as BaseBlogSection } from './blog-section';
|
|
20
21
|
import ContactSectionBase from './contact-section';
|
|
@@ -49,6 +50,7 @@ import './about-home.aman';
|
|
|
49
50
|
import './services-home.aman';
|
|
50
51
|
import './services-grid.aman';
|
|
51
52
|
import './testimonials-home.aman';
|
|
53
|
+
import './testimonials-grid.aman';
|
|
52
54
|
import './contact-section.aman';
|
|
53
55
|
import './team-grid.aman';
|
|
54
56
|
import './footer-home.aman';
|
|
@@ -95,6 +97,7 @@ export const HeroHome = createThemedExport('hero-home', BaseHeroHome);
|
|
|
95
97
|
export const ServicesHome = createThemedExport('services-home', BaseServicesHome);
|
|
96
98
|
export const AboutHome = createThemedExport('about-home', BaseAboutHome);
|
|
97
99
|
export const TestimonialsHome = createThemedExport('testimonials-home', BaseTestimonialsHome);
|
|
100
|
+
export const TestimonialsGrid = createThemedExport('testimonials-grid', BaseTestimonialsGrid);
|
|
98
101
|
export const FAQHome = createThemedExport('faq-home', BaseFAQHome);
|
|
99
102
|
export const BlogSection = createThemedExport('blog-section', BaseBlogSection);
|
|
100
103
|
export const ContactSection = createThemedExport('contact-section', ContactSectionBase);
|
|
@@ -46,7 +46,6 @@ const parseBusinessHours = (hours: string | Record<string, { open: string; close
|
|
|
46
46
|
};
|
|
47
47
|
|
|
48
48
|
export const LocationDetailsSection = ({
|
|
49
|
-
config,
|
|
50
49
|
location,
|
|
51
50
|
}: LocationDetailsSectionProps) => {
|
|
52
51
|
if (!location) return null;
|
|
@@ -72,7 +71,7 @@ export const LocationDetailsSection = ({
|
|
|
72
71
|
)}
|
|
73
72
|
|
|
74
73
|
{description && (
|
|
75
|
-
<p className="mt-6 font-display text-lg leading-relaxed text-tertiary">
|
|
74
|
+
<p className="mt-6 font-display text-lg leading-relaxed text-tertiary whitespace-pre-line">
|
|
76
75
|
{description}
|
|
77
76
|
</p>
|
|
78
77
|
)}
|
|
@@ -149,7 +148,7 @@ export const LocationDetailsSection = ({
|
|
|
149
148
|
{businessHours.map(({ day, hours }) => (
|
|
150
149
|
<div key={day} className="flex justify-between gap-4">
|
|
151
150
|
<span className="font-medium">{day}:</span>
|
|
152
|
-
<span>{hours}</span>
|
|
151
|
+
<span className="whitespace-pre-line">{hours}</span>
|
|
153
152
|
</div>
|
|
154
153
|
))}
|
|
155
154
|
</div>
|
|
@@ -65,16 +65,14 @@ export const SocialMediaGrid = ({
|
|
|
65
65
|
|
|
66
66
|
return (
|
|
67
67
|
<div key={post.id} className="flex flex-col bg-white overflow-hidden">
|
|
68
|
-
|
|
69
|
-
<
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
</div>
|
|
77
|
-
)}
|
|
68
|
+
<div className="w-full h-64 overflow-hidden">
|
|
69
|
+
<PhotoWithFallback
|
|
70
|
+
photoUrl={firstImage}
|
|
71
|
+
photoAlt={content.substring(0, 50) || 'Social post'}
|
|
72
|
+
fallbackId={post.id}
|
|
73
|
+
className="w-full h-full object-cover"
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
78
76
|
|
|
79
77
|
<div className="p-6 flex flex-col flex-grow">
|
|
80
78
|
<div className="flex items-center justify-between mb-4">
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { PhotoWithFallback } from '../elements';
|
|
5
|
+
import type { Testimonial } from '../../types/api/testimonial';
|
|
6
|
+
import { getAvatarUrl } from '../../utils/photo-helpers';
|
|
7
|
+
|
|
8
|
+
interface TestimonialsGridProps {
|
|
9
|
+
testimonials?: Testimonial[] | null;
|
|
10
|
+
title?: string;
|
|
11
|
+
subtitle?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const TestimonialsGrid = ({
|
|
15
|
+
testimonials: testimonialsData,
|
|
16
|
+
title = "",
|
|
17
|
+
subtitle = "",
|
|
18
|
+
}: TestimonialsGridProps) => {
|
|
19
|
+
const testimonials = Array.isArray(testimonialsData) ? testimonialsData : [];
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<section>
|
|
23
|
+
<div className="mx-auto max-w-container px-4 md:px-8">
|
|
24
|
+
{(title || subtitle) && (
|
|
25
|
+
<div className="mb-12 text-center">
|
|
26
|
+
{title && (
|
|
27
|
+
<h2 className="font-display text-4xl font-normal text-fg-primary md:text-5xl">
|
|
28
|
+
{title}
|
|
29
|
+
</h2>
|
|
30
|
+
)}
|
|
31
|
+
{subtitle && (
|
|
32
|
+
<p className="mt-4 font-display text-lg leading-relaxed text-tertiary md:text-xl max-w-3xl mx-auto">
|
|
33
|
+
{subtitle}
|
|
34
|
+
</p>
|
|
35
|
+
)}
|
|
36
|
+
</div>
|
|
37
|
+
)}
|
|
38
|
+
|
|
39
|
+
{testimonials.length > 0 ? (
|
|
40
|
+
<div className="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3">
|
|
41
|
+
{testimonials.map((testimonial: any, i: number) => {
|
|
42
|
+
const quote = testimonial.content_markdown?.replace(/[#*\[\]()]/g, '').trim() || '';
|
|
43
|
+
const reviewerName = testimonial.reviewer_name || 'Customer';
|
|
44
|
+
const username = `@${reviewerName.toLowerCase().replace(/\s+/g, '')}`;
|
|
45
|
+
const avatarUrl = getAvatarUrl(testimonial.photo_attachments, testimonial.id, reviewerName);
|
|
46
|
+
const rating = testimonial.rating || 5;
|
|
47
|
+
const isVerified = testimonial.is_verified !== false;
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div key={testimonial.id || i} className="bg-white p-8 flex flex-col h-full">
|
|
51
|
+
<div className="flex gap-1 mb-6">
|
|
52
|
+
{Array.from({ length: 5 }).map((_, starIdx) => (
|
|
53
|
+
<svg
|
|
54
|
+
key={starIdx}
|
|
55
|
+
className="w-5 h-5"
|
|
56
|
+
style={{
|
|
57
|
+
fill: starIdx < rating ? "var(--color-text-brand-secondary)" : "none",
|
|
58
|
+
stroke: starIdx < rating ? "none" : "#E5E0D8"
|
|
59
|
+
}}
|
|
60
|
+
strokeWidth="2"
|
|
61
|
+
viewBox="0 0 24 24"
|
|
62
|
+
>
|
|
63
|
+
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
|
64
|
+
</svg>
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<p className="font-display text-lg leading-relaxed text-tertiary mb-6 flex-grow">
|
|
69
|
+
"{quote}"
|
|
70
|
+
</p>
|
|
71
|
+
|
|
72
|
+
<div className="flex items-center gap-3">
|
|
73
|
+
<div className="w-12 h-12 rounded-full overflow-hidden flex-shrink-0">
|
|
74
|
+
<PhotoWithFallback
|
|
75
|
+
photoUrl={avatarUrl}
|
|
76
|
+
photoAlt={reviewerName}
|
|
77
|
+
fallbackId={testimonial.id || i}
|
|
78
|
+
className="w-full h-full object-cover"
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
<div className="flex-1">
|
|
82
|
+
<div className="flex items-center gap-1.5">
|
|
83
|
+
<p className="font-body text-sm font-medium text-fg-primary">
|
|
84
|
+
{reviewerName}
|
|
85
|
+
</p>
|
|
86
|
+
{isVerified && (
|
|
87
|
+
<svg className="w-4 h-4 text-fg-primary" viewBox="0 0 20 20" fill="currentColor">
|
|
88
|
+
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clipRule="evenodd" />
|
|
89
|
+
</svg>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
<p className="font-body text-xs" style={{ color: 'var(--color-text-brand-secondary)' }}>{username}</p>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
})}
|
|
98
|
+
</div>
|
|
99
|
+
) : (
|
|
100
|
+
<div className="text-center py-12">
|
|
101
|
+
<p className="font-body text-base text-tertiary">No testimonials available</p>
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
</section>
|
|
106
|
+
);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
import { registerThemeVariant } from '../../lib/component-registry';
|
|
110
|
+
registerThemeVariant('testimonials-grid', 'aman', TestimonialsGrid);
|
|
111
|
+
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { PhotoWithFallback } from '../elements';
|
|
5
|
+
import type { Testimonial } from '../../types/api/testimonial';
|
|
6
|
+
import { getAvatarUrl } from '../../utils/photo-helpers';
|
|
7
|
+
|
|
8
|
+
interface TestimonialsGridProps {
|
|
9
|
+
testimonials?: Testimonial[] | null;
|
|
10
|
+
title?: string;
|
|
11
|
+
subtitle?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const TestimonialsGrid = ({
|
|
15
|
+
testimonials: testimonialsData,
|
|
16
|
+
title = "",
|
|
17
|
+
subtitle = ""
|
|
18
|
+
}: TestimonialsGridProps) => {
|
|
19
|
+
const testimonials = Array.isArray(testimonialsData) ? testimonialsData : [];
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<section className="py-16 md:py-24">
|
|
23
|
+
<div className="mx-auto w-full max-w-container px-4 md:px-8">
|
|
24
|
+
{(title || subtitle) && (
|
|
25
|
+
<div className="mx-auto mb-12 flex w-full max-w-3xl flex-col items-center text-center">
|
|
26
|
+
{title && (
|
|
27
|
+
<h2 className="text-display-sm font-semibold text-primary md:text-display-md">
|
|
28
|
+
{title}
|
|
29
|
+
</h2>
|
|
30
|
+
)}
|
|
31
|
+
{subtitle && (
|
|
32
|
+
<p className="mt-4 text-lg text-tertiary md:mt-5 md:text-xl">
|
|
33
|
+
{subtitle}
|
|
34
|
+
</p>
|
|
35
|
+
)}
|
|
36
|
+
</div>
|
|
37
|
+
)}
|
|
38
|
+
|
|
39
|
+
{testimonials.length > 0 ? (
|
|
40
|
+
<div className="grid max-w-container grid-cols-1 gap-5 lg:grid-cols-3 lg:gap-6">
|
|
41
|
+
{testimonials.map((testimonial, index) => {
|
|
42
|
+
const reviewerName = testimonial.reviewer_name || 'Customer';
|
|
43
|
+
const quote = testimonial.content_markdown?.replace(/[#*\[\]()]/g, '').trim() || '';
|
|
44
|
+
const username = `@${reviewerName.toLowerCase().replace(/\s+/g, '')}`;
|
|
45
|
+
const avatarUrl = getAvatarUrl(testimonial.photo_attachments, testimonial.id, reviewerName);
|
|
46
|
+
const rating = testimonial.rating || 5;
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div key={testimonial.id || index} className="flex flex-col gap-6 rounded-2xl bg-secondary p-6 md:p-8">
|
|
50
|
+
<div className="flex gap-0.5">
|
|
51
|
+
{Array.from({ length: 5 }).map((_, i) => (
|
|
52
|
+
<svg
|
|
53
|
+
key={i}
|
|
54
|
+
className="size-5"
|
|
55
|
+
fill={i < rating ? "currentColor" : "none"}
|
|
56
|
+
stroke={i < rating ? "none" : "currentColor"}
|
|
57
|
+
strokeWidth={2}
|
|
58
|
+
viewBox="0 0 24 24"
|
|
59
|
+
>
|
|
60
|
+
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
|
61
|
+
</svg>
|
|
62
|
+
))}
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<p className="text-md text-primary">
|
|
66
|
+
{quote}
|
|
67
|
+
</p>
|
|
68
|
+
|
|
69
|
+
<div className="flex items-center gap-3 mt-auto">
|
|
70
|
+
<div className="size-12 shrink-0 overflow-hidden rounded-full">
|
|
71
|
+
<PhotoWithFallback
|
|
72
|
+
photoUrl={avatarUrl}
|
|
73
|
+
photoAlt={reviewerName}
|
|
74
|
+
fallbackId={`testimonial-${testimonial.id || index}`}
|
|
75
|
+
className="size-full object-cover"
|
|
76
|
+
/>
|
|
77
|
+
</div>
|
|
78
|
+
<div>
|
|
79
|
+
<p className="text-sm font-semibold text-primary">
|
|
80
|
+
{reviewerName}
|
|
81
|
+
</p>
|
|
82
|
+
<p className="text-sm text-tertiary">
|
|
83
|
+
{username}
|
|
84
|
+
</p>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
})}
|
|
90
|
+
</div>
|
|
91
|
+
) : (
|
|
92
|
+
<div className="text-center py-12">
|
|
93
|
+
<p className="text-gray-500">No testimonials available</p>
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
</section>
|
|
98
|
+
);
|
|
99
|
+
};
|
|
100
|
+
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { PhotoWithFallback, Carousel, CarouselSectionWrapper } from '../elements';
|
|
4
5
|
import type { Testimonial } from '../../types/api/testimonial';
|
|
5
6
|
import { getAvatarUrl } from '../../utils/photo-helpers';
|
|
6
7
|
|
|
@@ -16,32 +17,27 @@ export const TestimonialsHome = ({
|
|
|
16
17
|
perPage,
|
|
17
18
|
}: TestimonialsHomeProps) => {
|
|
18
19
|
const testimonials = Array.isArray(testimonialsData) ? testimonialsData : [];
|
|
19
|
-
const displayTestimonials = perPage ? testimonials.slice(0, perPage) : testimonials
|
|
20
|
+
const displayTestimonials = perPage ? testimonials.slice(0, perPage) : testimonials;
|
|
20
21
|
|
|
21
22
|
return (
|
|
22
23
|
<section>
|
|
23
24
|
<div className="mx-auto max-w-container px-4 md:px-8">
|
|
24
|
-
<
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
{
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return (
|
|
41
|
-
<div
|
|
42
|
-
key={testimonial.id || i}
|
|
43
|
-
className="bg-white p-8 flex flex-col"
|
|
44
|
-
>
|
|
25
|
+
<CarouselSectionWrapper
|
|
26
|
+
title={title}
|
|
27
|
+
hasItems={displayTestimonials.length > 0}
|
|
28
|
+
emptyMessage="No testimonials available"
|
|
29
|
+
>
|
|
30
|
+
{displayTestimonials.map((testimonial: any, i: number) => {
|
|
31
|
+
const quote = testimonial.content_markdown?.replace(/[#*\[\]()]/g, '').trim() || '';
|
|
32
|
+
const reviewerName = testimonial.reviewer_name || 'Customer';
|
|
33
|
+
const username = `@${reviewerName.toLowerCase().replace(/\s+/g, '')}`;
|
|
34
|
+
const avatarUrl = getAvatarUrl(testimonial.photo_attachments, testimonial.id, reviewerName);
|
|
35
|
+
const rating = testimonial.rating || 5;
|
|
36
|
+
const isVerified = testimonial.is_verified !== false;
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<Carousel.Item key={testimonial.id || i} className="pl-4 md:basis-1/2 lg:basis-1/3">
|
|
40
|
+
<div className="bg-white p-8 flex flex-col h-full">
|
|
45
41
|
<div className="flex gap-1 mb-6">
|
|
46
42
|
{Array.from({ length: 5 }).map((_, starIdx) => (
|
|
47
43
|
<svg
|
|
@@ -87,14 +83,10 @@ export const TestimonialsHome = ({
|
|
|
87
83
|
</div>
|
|
88
84
|
</div>
|
|
89
85
|
</div>
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
<div className="text-center py-12">
|
|
95
|
-
<p className="font-body text-base text-tertiary">No testimonials available</p>
|
|
96
|
-
</div>
|
|
97
|
-
)}
|
|
86
|
+
</Carousel.Item>
|
|
87
|
+
);
|
|
88
|
+
})}
|
|
89
|
+
</CarouselSectionWrapper>
|
|
98
90
|
</div>
|
|
99
91
|
</section>
|
|
100
92
|
);
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
/* Aman color palette - warm, elegant tones */
|
|
10
10
|
--color-bg-primary: #F9F7F0; /* Light warm beige */
|
|
11
11
|
--color-bg-secondary: #F9F7F0;
|
|
12
|
+
--color-bg-primary_hover: #F0EDE4; /* Slightly darker beige on hover */
|
|
12
13
|
--background-color-primary: #F9F7F0;
|
|
13
14
|
--background-color-secondary: #F9F7F0;
|
|
14
15
|
|
|
@@ -22,9 +23,20 @@
|
|
|
22
23
|
--color-fg-secondary: #505050;
|
|
23
24
|
--color-fg-tertiary: #787878;
|
|
24
25
|
|
|
25
|
-
/* Brand
|
|
26
|
-
--color-
|
|
26
|
+
/* Brand colors - elegant gold/bronze for primary buttons and accents */
|
|
27
|
+
--color-brand-500: #B48C64; /* Elegant gold/bronze */
|
|
28
|
+
--color-brand-600: #8C6440; /* Darker bronze for primary button background */
|
|
29
|
+
--color-brand-700: #6D4E32; /* Even darker for hover */
|
|
30
|
+
--color-fg-brand-primary: #8C6440;
|
|
31
|
+
--color-fg-brand-secondary: #B48C64;
|
|
27
32
|
--color-text-brand-secondary: #8C6440;
|
|
33
|
+
--color-text-brand-accent: #B48C64; /* Used for links and accents in Aman components */
|
|
34
|
+
|
|
35
|
+
/* Button backgrounds - override semantic tokens */
|
|
36
|
+
--color-bg-brand-solid: #8C6440; /* Primary button (Contact, Send Message) */
|
|
37
|
+
--color-bg-brand-solid_hover: #6D4E32; /* Primary button hover */
|
|
38
|
+
--background-color-brand-solid: #8C6440;
|
|
39
|
+
--background-color-brand-solid_hover: #6D4E32;
|
|
28
40
|
|
|
29
41
|
/* Border colors */
|
|
30
42
|
--color-border-primary: #E5E2D9;
|