keystone-design-bootstrap 1.0.3

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.
Files changed (182) hide show
  1. package/README.md +179 -0
  2. package/package.json +59 -0
  3. package/src/contexts/ThemeContext.tsx +34 -0
  4. package/src/contexts/index.ts +1 -0
  5. package/src/design_system/elements/IconComponent.tsx +98 -0
  6. package/src/design_system/elements/avatar/avatar-label-group.tsx +30 -0
  7. package/src/design_system/elements/avatar/avatar-profile-photo.tsx +125 -0
  8. package/src/design_system/elements/avatar/avatar.tsx +131 -0
  9. package/src/design_system/elements/avatar/base-components/avatar-add-button.tsx +34 -0
  10. package/src/design_system/elements/avatar/base-components/avatar-company-icon.tsx +26 -0
  11. package/src/design_system/elements/avatar/base-components/avatar-online-indicator.tsx +31 -0
  12. package/src/design_system/elements/avatar/base-components/index.tsx +4 -0
  13. package/src/design_system/elements/avatar/base-components/verified-tick.tsx +34 -0
  14. package/src/design_system/elements/avatar/utils.ts +12 -0
  15. package/src/design_system/elements/badges/avatar.tsx +132 -0
  16. package/src/design_system/elements/badges/badge-groups.tsx +176 -0
  17. package/src/design_system/elements/badges/badge-types.ts +266 -0
  18. package/src/design_system/elements/badges/badges.tsx +430 -0
  19. package/src/design_system/elements/breadcrumb/Breadcrumb.tsx +33 -0
  20. package/src/design_system/elements/button-group/button-group.tsx +106 -0
  21. package/src/design_system/elements/buttons/app-store-buttons-outline.tsx +378 -0
  22. package/src/design_system/elements/buttons/app-store-buttons.tsx +567 -0
  23. package/src/design_system/elements/buttons/button-utility.tsx +116 -0
  24. package/src/design_system/elements/buttons/button.aman.tsx +174 -0
  25. package/src/design_system/elements/buttons/button.tsx +271 -0
  26. package/src/design_system/elements/buttons/close-button.tsx +42 -0
  27. package/src/design_system/elements/buttons/round-button.tsx +29 -0
  28. package/src/design_system/elements/buttons/social-button.tsx +148 -0
  29. package/src/design_system/elements/buttons/social-logos.tsx +115 -0
  30. package/src/design_system/elements/carousel/carousel-base.tsx +308 -0
  31. package/src/design_system/elements/carousel/carousel.tsx +308 -0
  32. package/src/design_system/elements/checkbox/checkbox.tsx +120 -0
  33. package/src/design_system/elements/date-picker/calendar.tsx +101 -0
  34. package/src/design_system/elements/date-picker/cell.tsx +106 -0
  35. package/src/design_system/elements/date-picker/date-input.tsx +32 -0
  36. package/src/design_system/elements/date-picker/date-picker.tsx +86 -0
  37. package/src/design_system/elements/date-picker/date-range-picker.tsx +163 -0
  38. package/src/design_system/elements/date-picker/range-calendar.tsx +161 -0
  39. package/src/design_system/elements/date-picker/range-preset.tsx +28 -0
  40. package/src/design_system/elements/featured-icon/featured-icon.tsx +154 -0
  41. package/src/design_system/elements/form/form.tsx +10 -0
  42. package/src/design_system/elements/form/hook-form.tsx +75 -0
  43. package/src/design_system/elements/hint-text/hint-text.tsx +33 -0
  44. package/src/design_system/elements/index.tsx +158 -0
  45. package/src/design_system/elements/input/hint-text.tsx +33 -0
  46. package/src/design_system/elements/input/input-group.tsx +133 -0
  47. package/src/design_system/elements/input/input.aman.tsx +172 -0
  48. package/src/design_system/elements/input/input.tsx +271 -0
  49. package/src/design_system/elements/input/label.tsx +50 -0
  50. package/src/design_system/elements/label/label.tsx +50 -0
  51. package/src/design_system/elements/loading-indicator/loading-indicator.tsx +123 -0
  52. package/src/design_system/elements/map/GoogleMap.tsx +286 -0
  53. package/src/design_system/elements/markdown-renderer/MarkdownRenderer.tsx +155 -0
  54. package/src/design_system/elements/modals/modal.tsx +41 -0
  55. package/src/design_system/elements/pagination/pagination-base.tsx +378 -0
  56. package/src/design_system/elements/pagination/pagination-dot.tsx +54 -0
  57. package/src/design_system/elements/pagination/pagination-line.tsx +50 -0
  58. package/src/design_system/elements/pagination/pagination.tsx +330 -0
  59. package/src/design_system/elements/photo-fallback/photo-fallback.tsx +143 -0
  60. package/src/design_system/elements/progress-indicators/progress-circles.tsx +176 -0
  61. package/src/design_system/elements/progress-indicators/progress-indicators.tsx +123 -0
  62. package/src/design_system/elements/progress-indicators/simple-circle.tsx +29 -0
  63. package/src/design_system/elements/radio-buttons/radio-buttons.tsx +129 -0
  64. package/src/design_system/elements/rating/rating-badge.tsx +144 -0
  65. package/src/design_system/elements/rating/rating-stars.tsx +77 -0
  66. package/src/design_system/elements/select/combobox.tsx +152 -0
  67. package/src/design_system/elements/select/multi-select.tsx +363 -0
  68. package/src/design_system/elements/select/popover.tsx +34 -0
  69. package/src/design_system/elements/select/select-item.tsx +97 -0
  70. package/src/design_system/elements/select/select-native.tsx +69 -0
  71. package/src/design_system/elements/select/select.aman.tsx +75 -0
  72. package/src/design_system/elements/select/select.tsx +146 -0
  73. package/src/design_system/elements/shared-assets/credit-card/credit-card.tsx +237 -0
  74. package/src/design_system/elements/shared-assets/credit-card/icons.tsx +75 -0
  75. package/src/design_system/elements/shared-assets/iphone-mockup.tsx +172 -0
  76. package/src/design_system/elements/shared-assets/section-divider.tsx +12 -0
  77. package/src/design_system/elements/slideout-menus/slideout-menu.tsx +122 -0
  78. package/src/design_system/elements/tabs/tabs.tsx +225 -0
  79. package/src/design_system/elements/tags/base-components/tag-checkbox.tsx +45 -0
  80. package/src/design_system/elements/tags/base-components/tag-close-x.tsx +34 -0
  81. package/src/design_system/elements/tags/tags.tsx +176 -0
  82. package/src/design_system/elements/textarea/textarea.aman.tsx +52 -0
  83. package/src/design_system/elements/textarea/textarea.tsx +111 -0
  84. package/src/design_system/elements/toggle/toggle.tsx +140 -0
  85. package/src/design_system/elements/tooltip/tooltip.tsx +109 -0
  86. package/src/design_system/hooks/use-breakpoint.ts +37 -0
  87. package/src/design_system/hooks/use-resize-observer.ts +68 -0
  88. package/src/design_system/logo/keystone-logo-minimal.tsx +93 -0
  89. package/src/design_system/logo/keystone-logo.tsx +22 -0
  90. package/src/design_system/sections/about-home.aman.tsx +85 -0
  91. package/src/design_system/sections/about-home.tsx +115 -0
  92. package/src/design_system/sections/blog-cards.tsx +848 -0
  93. package/src/design_system/sections/blog-gallery.aman.tsx +77 -0
  94. package/src/design_system/sections/blog-gallery.tsx +204 -0
  95. package/src/design_system/sections/blog-home.aman.tsx +84 -0
  96. package/src/design_system/sections/blog-home.tsx +153 -0
  97. package/src/design_system/sections/blog-post.aman.tsx +74 -0
  98. package/src/design_system/sections/blog-post.tsx +301 -0
  99. package/src/design_system/sections/blog-section.aman.tsx +101 -0
  100. package/src/design_system/sections/blog-section.tsx +179 -0
  101. package/src/design_system/sections/contact-home.tsx +25 -0
  102. package/src/design_system/sections/contact-section.aman.tsx +173 -0
  103. package/src/design_system/sections/contact-section.tsx +143 -0
  104. package/src/design_system/sections/faq-grid.aman.tsx +79 -0
  105. package/src/design_system/sections/faq-grid.tsx +102 -0
  106. package/src/design_system/sections/faq-home.aman.tsx +92 -0
  107. package/src/design_system/sections/faq-home.tsx +134 -0
  108. package/src/design_system/sections/feature-tab.tsx +43 -0
  109. package/src/design_system/sections/feature-text.tsx +284 -0
  110. package/src/design_system/sections/footer-home.aman.tsx +62 -0
  111. package/src/design_system/sections/footer-home.tsx +259 -0
  112. package/src/design_system/sections/generic-header-component.tsx +103 -0
  113. package/src/design_system/sections/header-navigation.aman.tsx +360 -0
  114. package/src/design_system/sections/header-navigation.tsx +334 -0
  115. package/src/design_system/sections/hero-faq.aman.tsx +38 -0
  116. package/src/design_system/sections/hero-faq.tsx +55 -0
  117. package/src/design_system/sections/hero-generic-text.aman.tsx +49 -0
  118. package/src/design_system/sections/hero-generic-text.tsx +51 -0
  119. package/src/design_system/sections/hero-home.aman.tsx +84 -0
  120. package/src/design_system/sections/hero-home.tsx +246 -0
  121. package/src/design_system/sections/hero-location-detail.aman.tsx +33 -0
  122. package/src/design_system/sections/hero-location-detail.tsx +72 -0
  123. package/src/design_system/sections/hero-service-detail.aman.tsx +53 -0
  124. package/src/design_system/sections/hero-service-detail.tsx +51 -0
  125. package/src/design_system/sections/hero-social-media.aman.tsx +42 -0
  126. package/src/design_system/sections/hero-social-media.tsx +35 -0
  127. package/src/design_system/sections/hero-testimonials.aman.tsx +38 -0
  128. package/src/design_system/sections/hero-testimonials.tsx +55 -0
  129. package/src/design_system/sections/home-hero-component.tsx +228 -0
  130. package/src/design_system/sections/index.tsx +131 -0
  131. package/src/design_system/sections/job-gallery.aman.tsx +91 -0
  132. package/src/design_system/sections/job-gallery.tsx +183 -0
  133. package/src/design_system/sections/location-details-section.aman.tsx +179 -0
  134. package/src/design_system/sections/location-details-section.tsx +196 -0
  135. package/src/design_system/sections/location-grid.aman.tsx +76 -0
  136. package/src/design_system/sections/location-grid.tsx +123 -0
  137. package/src/design_system/sections/services-grid.aman.tsx +85 -0
  138. package/src/design_system/sections/services-grid.tsx +104 -0
  139. package/src/design_system/sections/services-home.aman.tsx +78 -0
  140. package/src/design_system/sections/services-home.tsx +131 -0
  141. package/src/design_system/sections/social-media-grid.aman.tsx +132 -0
  142. package/src/design_system/sections/social-media-grid.tsx +189 -0
  143. package/src/design_system/sections/statistics-section.aman.tsx +79 -0
  144. package/src/design_system/sections/statistics-section.tsx +97 -0
  145. package/src/design_system/sections/team-grid.aman.tsx +85 -0
  146. package/src/design_system/sections/team-grid.tsx +88 -0
  147. package/src/design_system/sections/testimonials-home.aman.tsx +113 -0
  148. package/src/design_system/sections/testimonials-home.tsx +90 -0
  149. package/src/design_system/sections/values-section.aman.tsx +73 -0
  150. package/src/design_system/sections/values-section.tsx +128 -0
  151. package/src/design_system/utils/icon-mapping.tsx +28 -0
  152. package/src/index.ts +7 -0
  153. package/src/lib/component-registry.ts +53 -0
  154. package/src/lib/hooks/index.ts +8 -0
  155. package/src/lib/hooks/use-breakpoint.ts +37 -0
  156. package/src/lib/hooks/use-clipboard.ts +79 -0
  157. package/src/lib/hooks/use-resize-observer.ts +68 -0
  158. package/src/lib/server-api.ts +115 -0
  159. package/src/styles/style-overrides.aman.css +101 -0
  160. package/src/styles/theme.css +224 -0
  161. package/src/styles/typography.css +430 -0
  162. package/src/themes/index.ts +23 -0
  163. package/src/types/api/blog-post.ts +53 -0
  164. package/src/types/api/company-information.ts +44 -0
  165. package/src/types/api/contact.ts +63 -0
  166. package/src/types/api/faq.ts +37 -0
  167. package/src/types/api/job-posting.ts +34 -0
  168. package/src/types/api/location.ts +36 -0
  169. package/src/types/api/photos.ts +28 -0
  170. package/src/types/api/service.ts +37 -0
  171. package/src/types/api/social-post.ts +28 -0
  172. package/src/types/api/team-member.ts +29 -0
  173. package/src/types/api/testimonial.ts +29 -0
  174. package/src/types/api/website-photos.ts +22 -0
  175. package/src/types/config.ts +21 -0
  176. package/src/types/index.ts +21 -0
  177. package/src/utils/countries.tsx +1351 -0
  178. package/src/utils/cx.ts +25 -0
  179. package/src/utils/gradient-placeholder.ts +59 -0
  180. package/src/utils/is-react-component.ts +33 -0
  181. package/src/utils/markdown-toc.ts +54 -0
  182. package/src/utils/photo-helpers.ts +94 -0
@@ -0,0 +1,330 @@
1
+ "use client";
2
+
3
+ import { ArrowLeft, ArrowRight } from "@untitledui/icons";
4
+ import { Button } from '../buttons/button';
5
+ import { ButtonGroup, ButtonGroupItem } from '../button-group/button-group';
6
+ import { useBreakpoint } from '../../../lib/hooks/use-breakpoint';
7
+ import { cx } from '../../../utils/cx';
8
+ import type { PaginationRootProps } from "./pagination-base";
9
+ import { Pagination } from "./pagination-base";
10
+
11
+ interface PaginationProps extends Partial<Omit<PaginationRootProps, "children">> {
12
+ /** Whether the pagination buttons are rounded. */
13
+ rounded?: boolean;
14
+ }
15
+
16
+ const PaginationItem = ({ value, rounded, isCurrent }: { value: number; rounded?: boolean; isCurrent: boolean }) => {
17
+ return (
18
+ <Pagination.Item
19
+ value={value}
20
+ isCurrent={isCurrent}
21
+ className={({ isSelected }) =>
22
+ cx(
23
+ "flex size-10 cursor-pointer items-center justify-center p-3 text-sm font-medium text-quaternary outline-focus-ring transition duration-100 ease-linear hover:bg-primary_hover hover:text-secondary focus-visible:z-10 focus-visible:bg-primary_hover focus-visible:outline-2 focus-visible:outline-offset-2",
24
+ rounded ? "rounded-full" : "rounded-lg",
25
+ isSelected && "bg-primary_hover text-secondary",
26
+ )
27
+ }
28
+ >
29
+ {value}
30
+ </Pagination.Item>
31
+ );
32
+ };
33
+
34
+ interface MobilePaginationProps {
35
+ /** The current page. */
36
+ page?: number;
37
+ /** The total number of pages. */
38
+ total?: number;
39
+ /** The class name of the pagination component. */
40
+ className?: string;
41
+ /** The function to call when the page changes. */
42
+ onPageChange?: (page: number) => void;
43
+ }
44
+
45
+ const MobilePagination = ({ page = 1, total = 10, className, onPageChange }: MobilePaginationProps) => {
46
+ return (
47
+ <nav aria-label="Pagination" className={cx("flex items-center justify-between md:hidden", className)}>
48
+ <Button
49
+ aria-label="Go to previous page"
50
+ iconLeading={ArrowLeft}
51
+ color="secondary"
52
+ size="sm"
53
+ onClick={() => onPageChange?.(Math.max(0, page - 1))}
54
+ />
55
+
56
+ <span className="text-sm text-fg-secondary">
57
+ Page <span className="font-medium">{page}</span> of <span className="font-medium">{total}</span>
58
+ </span>
59
+
60
+ <Button
61
+ aria-label="Go to next page"
62
+ iconLeading={ArrowRight}
63
+ color="secondary"
64
+ size="sm"
65
+ onClick={() => onPageChange?.(Math.min(total, page + 1))}
66
+ />
67
+ </nav>
68
+ );
69
+ };
70
+
71
+ export const PaginationPageDefault = ({ rounded, page = 1, total = 10, className, ...props }: PaginationProps) => {
72
+ const isDesktop = useBreakpoint("md");
73
+
74
+ return (
75
+ <Pagination.Root
76
+ {...props}
77
+ page={page}
78
+ total={total}
79
+ className={cx("flex w-full items-center justify-between gap-3 border-t border-secondary pt-4 md:pt-5", className)}
80
+ >
81
+ <div className="hidden flex-1 justify-start md:flex">
82
+ <Pagination.PrevTrigger asChild>
83
+ <Button iconLeading={ArrowLeft} color="link-gray" size="sm">
84
+ {isDesktop ? "Previous" : undefined}{" "}
85
+ </Button>
86
+ </Pagination.PrevTrigger>
87
+ </div>
88
+
89
+ <Pagination.PrevTrigger asChild className="md:hidden">
90
+ <Button iconLeading={ArrowLeft} color="secondary" size="sm">
91
+ {isDesktop ? "Previous" : undefined}
92
+ </Button>
93
+ </Pagination.PrevTrigger>
94
+
95
+ <Pagination.Context>
96
+ {({ pages, currentPage, total }) => (
97
+ <>
98
+ <div className="hidden justify-center gap-0.5 md:flex">
99
+ {pages.map((page, index) =>
100
+ page.type === "page" ? (
101
+ <PaginationItem key={index} rounded={rounded} {...page} />
102
+ ) : (
103
+ <Pagination.Ellipsis key={index} className="flex size-10 shrink-0 items-center justify-center text-tertiary">
104
+ &#8230;
105
+ </Pagination.Ellipsis>
106
+ ),
107
+ )}
108
+ </div>
109
+
110
+ <div className="flex justify-center text-sm whitespace-pre text-fg-secondary md:hidden">
111
+ Page <span className="font-medium">{currentPage}</span> of <span className="font-medium">{total}</span>
112
+ </div>
113
+ </>
114
+ )}
115
+ </Pagination.Context>
116
+
117
+ <div className="hidden flex-1 justify-end md:flex">
118
+ <Pagination.NextTrigger asChild>
119
+ <Button iconTrailing={ArrowRight} color="link-gray" size="sm">
120
+ {isDesktop ? "Next" : undefined}
121
+ </Button>
122
+ </Pagination.NextTrigger>
123
+ </div>
124
+ <Pagination.NextTrigger asChild className="md:hidden">
125
+ <Button iconTrailing={ArrowRight} color="secondary" size="sm">
126
+ {isDesktop ? "Next" : undefined}
127
+ </Button>
128
+ </Pagination.NextTrigger>
129
+ </Pagination.Root>
130
+ );
131
+ };
132
+
133
+ export const PaginationPageMinimalCenter = ({ rounded, page = 1, total = 10, className, ...props }: PaginationProps) => {
134
+ const isDesktop = useBreakpoint("md");
135
+
136
+ return (
137
+ <Pagination.Root
138
+ {...props}
139
+ page={page}
140
+ total={total}
141
+ className={cx("flex w-full items-center justify-between gap-3 border-t border-secondary pt-4 md:pt-5", className)}
142
+ >
143
+ <div className="flex flex-1 justify-start">
144
+ <Pagination.PrevTrigger asChild>
145
+ <Button iconLeading={ArrowLeft} color="secondary" size="sm">
146
+ {isDesktop ? "Previous" : undefined}
147
+ </Button>
148
+ </Pagination.PrevTrigger>
149
+ </div>
150
+
151
+ <Pagination.Context>
152
+ {({ pages, currentPage, total }) => (
153
+ <>
154
+ <div className="hidden justify-center gap-0.5 md:flex">
155
+ {pages.map((page, index) =>
156
+ page.type === "page" ? (
157
+ <PaginationItem key={index} rounded={rounded} {...page} />
158
+ ) : (
159
+ <Pagination.Ellipsis key={index} className="flex size-10 shrink-0 items-center justify-center text-tertiary">
160
+ &#8230;
161
+ </Pagination.Ellipsis>
162
+ ),
163
+ )}
164
+ </div>
165
+
166
+ <div className="flex justify-center text-sm whitespace-pre text-fg-secondary md:hidden">
167
+ Page <span className="font-medium">{currentPage}</span> of <span className="font-medium">{total}</span>
168
+ </div>
169
+ </>
170
+ )}
171
+ </Pagination.Context>
172
+
173
+ <div className="flex flex-1 justify-end">
174
+ <Pagination.NextTrigger asChild>
175
+ <Button iconTrailing={ArrowRight} color="secondary" size="sm">
176
+ {isDesktop ? "Next" : undefined}
177
+ </Button>
178
+ </Pagination.NextTrigger>
179
+ </div>
180
+ </Pagination.Root>
181
+ );
182
+ };
183
+
184
+ export const PaginationCardDefault = ({ rounded, page = 1, total = 10, ...props }: PaginationProps) => {
185
+ const isDesktop = useBreakpoint("md");
186
+
187
+ return (
188
+ <Pagination.Root
189
+ {...props}
190
+ page={page}
191
+ total={total}
192
+ className="flex w-full items-center justify-between gap-3 border-t border-secondary px-4 py-3 md:px-6 md:pt-3 md:pb-4"
193
+ >
194
+ <div className="flex flex-1 justify-start">
195
+ <Pagination.PrevTrigger asChild>
196
+ <Button iconLeading={ArrowLeft} color="secondary" size="sm">
197
+ {isDesktop ? "Previous" : undefined}
198
+ </Button>
199
+ </Pagination.PrevTrigger>
200
+ </div>
201
+
202
+ <Pagination.Context>
203
+ {({ pages, currentPage, total }) => (
204
+ <>
205
+ <div className="hidden justify-center gap-0.5 md:flex">
206
+ {pages.map((page, index) =>
207
+ page.type === "page" ? (
208
+ <PaginationItem key={index} rounded={rounded} {...page} />
209
+ ) : (
210
+ <Pagination.Ellipsis key={index} className="flex size-10 shrink-0 items-center justify-center text-tertiary">
211
+ &#8230;
212
+ </Pagination.Ellipsis>
213
+ ),
214
+ )}
215
+ </div>
216
+
217
+ <div className="flex justify-center text-sm whitespace-pre text-fg-secondary md:hidden">
218
+ Page <span className="font-medium">{currentPage}</span> of <span className="font-medium">{total}</span>
219
+ </div>
220
+ </>
221
+ )}
222
+ </Pagination.Context>
223
+
224
+ <div className="flex flex-1 justify-end">
225
+ <Pagination.NextTrigger asChild>
226
+ <Button iconTrailing={ArrowRight} color="secondary" size="sm">
227
+ {isDesktop ? "Next" : undefined}
228
+ </Button>
229
+ </Pagination.NextTrigger>
230
+ </div>
231
+ </Pagination.Root>
232
+ );
233
+ };
234
+
235
+ interface PaginationCardMinimalProps {
236
+ /** The current page. */
237
+ page?: number;
238
+ /** The total number of pages. */
239
+ total?: number;
240
+ /** The alignment of the pagination. */
241
+ align?: "left" | "center" | "right";
242
+ /** The class name of the pagination component. */
243
+ className?: string;
244
+ /** The function to call when the page changes. */
245
+ onPageChange?: (page: number) => void;
246
+ }
247
+
248
+ export const PaginationCardMinimal = ({ page = 1, total = 10, align = "left", onPageChange, className }: PaginationCardMinimalProps) => {
249
+ return (
250
+ <div className={cx("border-t border-secondary px-4 py-3 md:px-6 md:pt-3 md:pb-4", className)}>
251
+ <MobilePagination page={page} total={total} onPageChange={onPageChange} />
252
+
253
+ <nav aria-label="Pagination" className={cx("hidden items-center gap-3 md:flex", align === "center" && "justify-between")}>
254
+ <div className={cx(align === "center" && "flex flex-1 justify-start")}>
255
+ <Button isDisabled={page === 1} color="secondary" size="sm" onClick={() => onPageChange?.(Math.max(0, page - 1))}>
256
+ Previous
257
+ </Button>
258
+ </div>
259
+
260
+ <span
261
+ className={cx(
262
+ "text-sm font-medium text-fg-secondary",
263
+ align === "right" && "order-first mr-auto",
264
+ align === "left" && "order-last ml-auto",
265
+ )}
266
+ >
267
+ Page {page} of {total}
268
+ </span>
269
+
270
+ <div className={cx(align === "center" && "flex flex-1 justify-end")}>
271
+ <Button isDisabled={page === total} color="secondary" size="sm" onClick={() => onPageChange?.(Math.min(total, page + 1))}>
272
+ Next
273
+ </Button>
274
+ </div>
275
+ </nav>
276
+ </div>
277
+ );
278
+ };
279
+
280
+ interface PaginationButtonGroupProps extends Partial<Omit<PaginationRootProps, "children">> {
281
+ /** The alignment of the pagination. */
282
+ align?: "left" | "center" | "right";
283
+ }
284
+
285
+ export const PaginationButtonGroup = ({ align = "left", page = 1, total = 10, ...props }: PaginationButtonGroupProps) => {
286
+ const isDesktop = useBreakpoint("md");
287
+
288
+ return (
289
+ <div
290
+ className={cx(
291
+ "flex border-t border-secondary px-4 py-3 md:px-6 md:pt-3 md:pb-4",
292
+ align === "left" && "justify-start",
293
+ align === "center" && "justify-center",
294
+ align === "right" && "justify-end",
295
+ )}
296
+ >
297
+ <Pagination.Root {...props} page={page} total={total}>
298
+ <Pagination.Context>
299
+ {({ pages }) => (
300
+ <ButtonGroup size="md">
301
+ <Pagination.PrevTrigger asChild>
302
+ <ButtonGroupItem iconLeading={ArrowLeft}>{isDesktop ? "Previous" : undefined}</ButtonGroupItem>
303
+ </Pagination.PrevTrigger>
304
+
305
+ {pages.map((page, index) =>
306
+ page.type === "page" ? (
307
+ <Pagination.Item key={index} {...page} asChild>
308
+ <ButtonGroupItem isSelected={page.isCurrent} className="size-10 items-center justify-center">
309
+ {page.value}
310
+ </ButtonGroupItem>
311
+ </Pagination.Item>
312
+ ) : (
313
+ <Pagination.Ellipsis key={index}>
314
+ <ButtonGroupItem className="pointer-events-none size-10 items-center justify-center rounded-none!">
315
+ &#8230;
316
+ </ButtonGroupItem>
317
+ </Pagination.Ellipsis>
318
+ ),
319
+ )}
320
+
321
+ <Pagination.NextTrigger asChild>
322
+ <ButtonGroupItem iconTrailing={ArrowRight}>{isDesktop ? "Next" : undefined}</ButtonGroupItem>
323
+ </Pagination.NextTrigger>
324
+ </ButtonGroup>
325
+ )}
326
+ </Pagination.Context>
327
+ </Pagination.Root>
328
+ </div>
329
+ );
330
+ };
@@ -0,0 +1,143 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect, useMemo } from 'react';
4
+ import { getGradientUrl } from '../../../utils/gradient-placeholder';
5
+ import type { WebsitePhotos } from '../../../types/api/website-photos';
6
+ import type { CompanyInformation } from '../../../types/api/company-information';
7
+
8
+ interface PhotoWithFallbackProps {
9
+ // Generic object with photo_attachments (service, location, team member, blog post, etc.)
10
+ item?: {
11
+ id?: number;
12
+ name?: string;
13
+ title?: string;
14
+ photo_attachments?: Array<{
15
+ id: number;
16
+ featured: boolean;
17
+ photo?: {
18
+ id: number;
19
+ title: string;
20
+ thumbnail_url?: string;
21
+ medium_url?: string;
22
+ large_url?: string;
23
+ original_url?: string;
24
+ };
25
+ }>;
26
+ };
27
+ // Generic photo props (for direct photo URLs)
28
+ photoUrl?: string;
29
+ photoAlt?: string;
30
+ // Fallback props
31
+ fallbackId?: number | string;
32
+ // Image props
33
+ className?: string;
34
+ alt?: string;
35
+ // SSR data props (optional)
36
+ websitePhotos?: WebsitePhotos | null;
37
+ companyInformation?: CompanyInformation | null;
38
+ }
39
+
40
+ /**
41
+ * Reusable component that displays a photo with automatic fallback to gradient.
42
+ * Supports photo_attachments from any object (services, locations, team members, blog posts, etc.),
43
+ * direct photo URLs, or gradient fallback.
44
+ */
45
+ export default function PhotoWithFallback({
46
+ item,
47
+ photoUrl,
48
+ photoAlt,
49
+ fallbackId,
50
+ className = '',
51
+ alt,
52
+ websitePhotos,
53
+ companyInformation,
54
+ }: PhotoWithFallbackProps) {
55
+ // Use data from props (SSR)
56
+ const isStubMode = (companyInformation as any)?.account_status === 'stub';
57
+ const stockPhotos = websitePhotos?.stock_photos || [];
58
+
59
+ // Determine the image URL synchronously to avoid gradient flash
60
+ const { imageUrl, imageAlt } = useMemo(() => {
61
+ // Priority 1: Direct photoUrl prop
62
+ if (photoUrl) {
63
+ return { imageUrl: photoUrl, imageAlt: photoAlt || alt || "" };
64
+ }
65
+
66
+ // Priority 2: Photo from photo_attachments
67
+ if (item?.photo_attachments && item.photo_attachments.length > 0) {
68
+ const photoAttachments = item.photo_attachments;
69
+ const featuredPhoto = photoAttachments.find((pa) => pa.featured) || photoAttachments[0];
70
+ const photo = featuredPhoto?.photo;
71
+
72
+ if (photo) {
73
+ const url = photo.large_url || photo.medium_url || photo.thumbnail_url || photo.original_url;
74
+ if (url) {
75
+ return {
76
+ imageUrl: url,
77
+ imageAlt: photo.title || item.name || item.title || alt || "Image"
78
+ };
79
+ }
80
+ }
81
+ }
82
+
83
+ // Priority 3: In stub mode, use random stock photo if available
84
+ if (isStubMode && stockPhotos.length > 0) {
85
+ const randomIndex = Math.floor(Math.random() * stockPhotos.length);
86
+ const stockPhoto = stockPhotos[randomIndex];
87
+ if (stockPhoto?.url) {
88
+ return {
89
+ imageUrl: stockPhoto.url,
90
+ imageAlt: stockPhoto.alt || item?.name || item?.title || alt || "Image"
91
+ };
92
+ }
93
+ }
94
+
95
+ // Priority 4: Fallback to gradient
96
+ const id = fallbackId || item?.id || 1;
97
+ return {
98
+ imageUrl: getGradientUrl(id),
99
+ imageAlt: item?.name || item?.title || alt || "Image"
100
+ };
101
+ }, [item, photoUrl, photoAlt, fallbackId, alt, isStubMode, stockPhotos]);
102
+
103
+ const [isLoaded, setIsLoaded] = useState(false);
104
+ const imgRef = React.useRef<HTMLImageElement>(null);
105
+
106
+ // Reset loaded state when image URL changes
107
+ useEffect(() => {
108
+ setIsLoaded(false);
109
+
110
+ const timer = setTimeout(() => {
111
+ if (imgRef.current?.complete && imgRef.current.naturalWidth > 0) {
112
+ setIsLoaded(true);
113
+ }
114
+ }, 0);
115
+
116
+ return () => clearTimeout(timer);
117
+ }, [imageUrl]);
118
+
119
+ // Callback ref to check if image is already loaded (cached images)
120
+ const setImgRef = React.useCallback((img: HTMLImageElement | null) => {
121
+ imgRef.current = img;
122
+ if (img && img.complete && img.naturalWidth > 0) {
123
+ setIsLoaded(true);
124
+ }
125
+ }, []);
126
+
127
+ if (!imageUrl) {
128
+ return null;
129
+ }
130
+
131
+ return (
132
+ <img
133
+ ref={setImgRef}
134
+ src={imageUrl}
135
+ alt={imageAlt}
136
+ className={`${className} transition-opacity duration-500 ease-in-out ${
137
+ isLoaded ? 'opacity-100' : 'opacity-0'
138
+ }`}
139
+ onLoad={() => setIsLoaded(true)}
140
+ onError={() => setIsLoaded(true)}
141
+ />
142
+ );
143
+ }
@@ -0,0 +1,176 @@
1
+ "use client";
2
+
3
+ import { cx as clx, sortCx } from '../../../utils/cx';
4
+
5
+ interface ProgressBarProps {
6
+ value: number;
7
+ min?: number;
8
+ max?: number;
9
+ size: "xxs" | "xs" | "sm" | "md" | "lg";
10
+ label?: string;
11
+ valueFormatter?: (value: number, valueInPercentage: number) => string | number;
12
+ }
13
+
14
+ const sizes = sortCx({
15
+ xxs: {
16
+ strokeWidth: 6,
17
+ radius: 29,
18
+ valueClass: "text-sm font-semibold text-primary",
19
+ labelClass: "text-xs font-medium text-tertiary",
20
+ halfCircleTextPosition: "absolute bottom-0.5 text-center",
21
+ },
22
+ xs: {
23
+ strokeWidth: 16,
24
+ radius: 72,
25
+ valueClass: "text-display-xs font-semibold text-primary",
26
+ labelClass: "text-xs font-medium text-tertiary",
27
+ halfCircleTextPosition: "absolute bottom-0.5 text-center",
28
+ },
29
+ sm: {
30
+ strokeWidth: 20,
31
+ radius: 90,
32
+ valueClass: "text-display-sm font-semibold text-primary",
33
+ labelClass: "text-xs font-medium text-tertiary",
34
+ halfCircleTextPosition: "absolute bottom-1 text-center",
35
+ },
36
+ md: {
37
+ strokeWidth: 24,
38
+ radius: 108,
39
+ valueClass: "text-display-md font-semibold text-primary",
40
+ labelClass: "text-sm font-medium text-tertiary",
41
+ halfCircleTextPosition: "absolute bottom-1 text-center",
42
+ },
43
+ lg: {
44
+ strokeWidth: 28,
45
+ radius: 126,
46
+ valueClass: "text-display-lg font-semibold text-primary",
47
+ labelClass: "text-sm font-medium text-tertiary",
48
+ halfCircleTextPosition: "absolute bottom-0 text-center",
49
+ },
50
+ });
51
+
52
+ export const ProgressBarCircle = ({ value, min = 0, max = 100, size, label, valueFormatter }: ProgressBarProps) => {
53
+ const percentage = Math.round(((value - min) * 100) / (max - min));
54
+
55
+ const sizeConfig = sizes[size];
56
+
57
+ const { strokeWidth, radius, valueClass, labelClass } = sizeConfig;
58
+
59
+ const diameter = 2 * (radius + strokeWidth / 2);
60
+ const width = diameter;
61
+ const height = diameter;
62
+ const viewBox = `0 0 ${width} ${height}`;
63
+ const cx = diameter / 2;
64
+ const cy = diameter / 2;
65
+
66
+ const textPosition = label ? "absolute text-center" : "absolute text-primary";
67
+ const strokeDashoffset = 100 - percentage;
68
+
69
+ return (
70
+ <div className="flex flex-col items-center gap-0.5">
71
+ <div role="progressbar" aria-valuenow={value} aria-valuemin={min} aria-valuemax={max} className="relative flex w-max items-center justify-center">
72
+ <svg className="-rotate-90" width={width} height={height} viewBox={viewBox}>
73
+ {/* Background circle */}
74
+ <circle
75
+ className="stroke-bg-quaternary"
76
+ cx={cx}
77
+ cy={cy}
78
+ r={radius}
79
+ fill="none"
80
+ strokeWidth={strokeWidth}
81
+ pathLength="100"
82
+ strokeDasharray="100"
83
+ strokeLinecap="round"
84
+ />
85
+
86
+ {/* Foreground circle */}
87
+ <circle
88
+ className="stroke-fg-brand-primary"
89
+ cx={cx}
90
+ cy={cy}
91
+ r={radius}
92
+ fill="none"
93
+ strokeWidth={strokeWidth}
94
+ pathLength="100"
95
+ strokeDasharray="100"
96
+ strokeLinecap="round"
97
+ strokeDashoffset={strokeDashoffset}
98
+ />
99
+ </svg>
100
+ {label && size !== "xxs" ? (
101
+ <div className="absolute text-center">
102
+ <div className={labelClass}>{label}</div>
103
+ <div className={valueClass}>{valueFormatter ? valueFormatter(value, percentage) : `${percentage}%`}</div>
104
+ </div>
105
+ ) : (
106
+ <span className={clx(textPosition, valueClass)}>{valueFormatter ? valueFormatter(value, percentage) : `${percentage}%`}</span>
107
+ )}
108
+ </div>
109
+
110
+ {label && size === "xxs" && <div className={labelClass}>{label}</div>}
111
+ </div>
112
+ );
113
+ };
114
+
115
+ export const ProgressBarHalfCircle = ({ value, min = 0, max = 100, size, label, valueFormatter }: ProgressBarProps) => {
116
+ const percentage = Math.round(((value - min) * 100) / (max - min));
117
+
118
+ const sizeConfig = sizes[size];
119
+
120
+ const { strokeWidth, radius, valueClass, labelClass, halfCircleTextPosition } = sizeConfig;
121
+
122
+ const width = 2 * (radius + strokeWidth / 2);
123
+ const height = radius + strokeWidth;
124
+ const viewBox = `0 0 ${width} ${height}`;
125
+ const cx = "50%";
126
+ const cy = radius + strokeWidth / 2;
127
+
128
+ const strokeDashoffset = -50 - (100 - percentage) / 2;
129
+
130
+ return (
131
+ <div className="flex flex-col items-center gap-0.5">
132
+ <div role="progressbar" aria-valuenow={value} aria-valuemin={min} aria-valuemax={max} className="relative flex w-max items-center justify-center">
133
+ <svg width={width} height={height} viewBox={viewBox}>
134
+ {/* Background half-circle */}
135
+ <circle
136
+ className="stroke-bg-quaternary"
137
+ cx={cx}
138
+ cy={cy}
139
+ r={radius}
140
+ fill="none"
141
+ strokeWidth={strokeWidth}
142
+ pathLength="100"
143
+ strokeDasharray="100"
144
+ strokeDashoffset="-50"
145
+ strokeLinecap="round"
146
+ />
147
+
148
+ {/* Foreground half-circle */}
149
+ <circle
150
+ className="origin-center -scale-x-100 stroke-fg-brand-primary"
151
+ cx={cx}
152
+ cy={cy}
153
+ r={radius}
154
+ fill="none"
155
+ strokeWidth={strokeWidth}
156
+ pathLength="100"
157
+ strokeDasharray="100"
158
+ strokeDashoffset={strokeDashoffset}
159
+ strokeLinecap="round"
160
+ />
161
+ </svg>
162
+
163
+ {label && size !== "xxs" ? (
164
+ <div className={halfCircleTextPosition}>
165
+ <div className={labelClass}>{label}</div>
166
+ <div className={valueClass}>{valueFormatter ? valueFormatter(value, percentage) : `${percentage}%`}</div>
167
+ </div>
168
+ ) : (
169
+ <span className={clx(halfCircleTextPosition, valueClass)}>{valueFormatter ? valueFormatter(value, percentage) : `${percentage}%`}</span>
170
+ )}
171
+ </div>
172
+
173
+ {label && size === "xxs" && <div className={labelClass}>{label}</div>}
174
+ </div>
175
+ );
176
+ };