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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keystone-design-bootstrap",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "Keystone Design Bootstrap - Sections, Elements, and Theme System for customer websites",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -34,7 +34,7 @@ export const styles = sortCx({
34
34
 
35
35
  colors: {
36
36
  primary: {
37
- root: "bg-primary text-primary_contrast hover:opacity-90",
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 { PhotoWithFallback } from '../elements';
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
- <div className="grid grid-cols-1 gap-12 md:grid-cols-2 lg:grid-cols-3">
20
- {posts.map((post: any) => {
21
- const excerpt = post.summary || post.content_markdown?.replace(/[#*\[\]()]/g, '').trim().substring(0, 150) || '';
22
-
23
- return (
24
- <div key={post.id} className="flex flex-col">
25
- <div className="w-full h-64 mb-6 overflow-hidden">
26
- <PhotoWithFallback
27
- item={post}
28
- fallbackId={post.id}
29
- className="w-full h-full object-cover"
30
- />
31
- </div>
32
-
33
- <p className="text-xs font-body font-normal uppercase tracking-widest mb-2" style={{ color: 'var(--color-text-brand-secondary)' }}>
34
- {new Date(post.published_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
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
- <h3 className="font-display text-2xl font-normal text-fg-primary mb-4">
38
- {post.title}
39
- </h3>
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
- {excerpt && (
42
- <p className="font-body text-base leading-relaxed text-tertiary mb-6 flex-grow">
43
- {excerpt}
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
- </div>
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
- <div className={`${backgroundColor} ${className}`}>
72
- {/* Hero Section */}
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
- </main>
200
- </div>
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 = 3,
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
- <div className="mb-12">
38
- <h2 className="font-display text-4xl font-normal text-fg-primary md:text-5xl">
39
- {title}
40
- </h2>
41
- </div>
42
-
43
- {postsArray.length > 0 ? (
44
- <div className="grid grid-cols-1 gap-12 md:grid-cols-2 lg:grid-cols-3">
45
- {postsArray.map((post: BlogPost) => {
46
- const excerpt = (post as any).summary || post.content_markdown?.replace(/[#*\[\]()]/g, '').trim().substring(0, 150) || '';
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-6 flex-grow">
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
- <a
73
- href={`/blog/${post.slug}`}
74
- className="font-body text-base underline underline-offset-4 hover:no-underline transition-colors"
75
- style={{ color: 'var(--color-text-brand-accent)' }}
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
- <button
129
- type="submit"
130
- className="w-full font-body text-base uppercase tracking-wide py-3 px-6 rounded-sm hover:opacity-90 transition-opacity"
131
- style={{ backgroundColor: '#1E1E1E', color: '#FFFFFF' }}
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
- </button>
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>
@@ -106,7 +106,7 @@ export const FAQHome = ({
106
106
  }}
107
107
  >
108
108
  <div className="pt-1 pr-8 md:pr-0 md:pl-10">
109
- <div className="text-md text-tertiary">
109
+ <div className="text-md text-tertiary whitespace-pre-line">
110
110
  {faq.answer_markdown || ''}
111
111
  </div>
112
112
  </div>
@@ -145,14 +145,29 @@ export function HeaderNavigation({
145
145
  {companyName}
146
146
  </Link>
147
147
 
148
- {/* Right: Contact Button */}
149
- <div className="flex items-center gap-6">
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
- className="bg-fg-primary text-white font-body text-sm uppercase tracking-wide px-6 py-2 rounded-sm hover:opacity-90"
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-4">
337
- <select className="w-full font-body text-sm text-fg-primary bg-transparent border-b border-secondary py-2">
338
- <option>English</option>
339
- </select>
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" style={{ backgroundColor: '#1E1E1E' }}>
347
- <Button
348
- href="/contact"
349
- className="w-full font-body text-sm uppercase tracking-wide py-4 rounded-none"
350
- style={{ backgroundColor: '#1E1E1E', color: '#FFFFFF' }}
351
- >
352
- Contact
353
- </Button>
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
- {firstImage && (
69
- <div className="w-full h-64 overflow-hidden">
70
- <PhotoWithFallback
71
- photoUrl={firstImage}
72
- photoAlt={content.substring(0, 50) || 'Social post'}
73
- fallbackId={post.id}
74
- className="w-full h-full object-cover"
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 { PhotoWithFallback } from '../elements';
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.slice(0, 3);
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
- <div className="mb-12 text-center">
25
- <h2 className="font-display text-4xl font-normal text-fg-primary md:text-5xl">
26
- {title}
27
- </h2>
28
- </div>
29
-
30
- {displayTestimonials && displayTestimonials.length > 0 ? (
31
- <div className="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3">
32
- {displayTestimonials.map((testimonial: any, i: number) => {
33
- const quote = testimonial.content_markdown?.replace(/[#*\[\]()]/g, '').trim() || '';
34
- const reviewerName = testimonial.reviewer_name || 'Customer';
35
- const username = `@${reviewerName.toLowerCase().replace(/\s+/g, '')}`;
36
- const avatarUrl = getAvatarUrl(testimonial.photo_attachments, testimonial.id, reviewerName);
37
- const rating = testimonial.rating || 5;
38
- const isVerified = testimonial.is_verified !== false;
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
- </div>
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 accent - elegant gold/bronze */
26
- --color-text-brand-accent: #B48C64;
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;