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.
- package/README.md +179 -0
- package/package.json +59 -0
- package/src/contexts/ThemeContext.tsx +34 -0
- package/src/contexts/index.ts +1 -0
- package/src/design_system/elements/IconComponent.tsx +98 -0
- package/src/design_system/elements/avatar/avatar-label-group.tsx +30 -0
- package/src/design_system/elements/avatar/avatar-profile-photo.tsx +125 -0
- package/src/design_system/elements/avatar/avatar.tsx +131 -0
- package/src/design_system/elements/avatar/base-components/avatar-add-button.tsx +34 -0
- package/src/design_system/elements/avatar/base-components/avatar-company-icon.tsx +26 -0
- package/src/design_system/elements/avatar/base-components/avatar-online-indicator.tsx +31 -0
- package/src/design_system/elements/avatar/base-components/index.tsx +4 -0
- package/src/design_system/elements/avatar/base-components/verified-tick.tsx +34 -0
- package/src/design_system/elements/avatar/utils.ts +12 -0
- package/src/design_system/elements/badges/avatar.tsx +132 -0
- package/src/design_system/elements/badges/badge-groups.tsx +176 -0
- package/src/design_system/elements/badges/badge-types.ts +266 -0
- package/src/design_system/elements/badges/badges.tsx +430 -0
- package/src/design_system/elements/breadcrumb/Breadcrumb.tsx +33 -0
- package/src/design_system/elements/button-group/button-group.tsx +106 -0
- package/src/design_system/elements/buttons/app-store-buttons-outline.tsx +378 -0
- package/src/design_system/elements/buttons/app-store-buttons.tsx +567 -0
- package/src/design_system/elements/buttons/button-utility.tsx +116 -0
- package/src/design_system/elements/buttons/button.aman.tsx +174 -0
- package/src/design_system/elements/buttons/button.tsx +271 -0
- package/src/design_system/elements/buttons/close-button.tsx +42 -0
- package/src/design_system/elements/buttons/round-button.tsx +29 -0
- package/src/design_system/elements/buttons/social-button.tsx +148 -0
- package/src/design_system/elements/buttons/social-logos.tsx +115 -0
- package/src/design_system/elements/carousel/carousel-base.tsx +308 -0
- package/src/design_system/elements/carousel/carousel.tsx +308 -0
- package/src/design_system/elements/checkbox/checkbox.tsx +120 -0
- package/src/design_system/elements/date-picker/calendar.tsx +101 -0
- package/src/design_system/elements/date-picker/cell.tsx +106 -0
- package/src/design_system/elements/date-picker/date-input.tsx +32 -0
- package/src/design_system/elements/date-picker/date-picker.tsx +86 -0
- package/src/design_system/elements/date-picker/date-range-picker.tsx +163 -0
- package/src/design_system/elements/date-picker/range-calendar.tsx +161 -0
- package/src/design_system/elements/date-picker/range-preset.tsx +28 -0
- package/src/design_system/elements/featured-icon/featured-icon.tsx +154 -0
- package/src/design_system/elements/form/form.tsx +10 -0
- package/src/design_system/elements/form/hook-form.tsx +75 -0
- package/src/design_system/elements/hint-text/hint-text.tsx +33 -0
- package/src/design_system/elements/index.tsx +158 -0
- package/src/design_system/elements/input/hint-text.tsx +33 -0
- package/src/design_system/elements/input/input-group.tsx +133 -0
- package/src/design_system/elements/input/input.aman.tsx +172 -0
- package/src/design_system/elements/input/input.tsx +271 -0
- package/src/design_system/elements/input/label.tsx +50 -0
- package/src/design_system/elements/label/label.tsx +50 -0
- package/src/design_system/elements/loading-indicator/loading-indicator.tsx +123 -0
- package/src/design_system/elements/map/GoogleMap.tsx +286 -0
- package/src/design_system/elements/markdown-renderer/MarkdownRenderer.tsx +155 -0
- package/src/design_system/elements/modals/modal.tsx +41 -0
- package/src/design_system/elements/pagination/pagination-base.tsx +378 -0
- package/src/design_system/elements/pagination/pagination-dot.tsx +54 -0
- package/src/design_system/elements/pagination/pagination-line.tsx +50 -0
- package/src/design_system/elements/pagination/pagination.tsx +330 -0
- package/src/design_system/elements/photo-fallback/photo-fallback.tsx +143 -0
- package/src/design_system/elements/progress-indicators/progress-circles.tsx +176 -0
- package/src/design_system/elements/progress-indicators/progress-indicators.tsx +123 -0
- package/src/design_system/elements/progress-indicators/simple-circle.tsx +29 -0
- package/src/design_system/elements/radio-buttons/radio-buttons.tsx +129 -0
- package/src/design_system/elements/rating/rating-badge.tsx +144 -0
- package/src/design_system/elements/rating/rating-stars.tsx +77 -0
- package/src/design_system/elements/select/combobox.tsx +152 -0
- package/src/design_system/elements/select/multi-select.tsx +363 -0
- package/src/design_system/elements/select/popover.tsx +34 -0
- package/src/design_system/elements/select/select-item.tsx +97 -0
- package/src/design_system/elements/select/select-native.tsx +69 -0
- package/src/design_system/elements/select/select.aman.tsx +75 -0
- package/src/design_system/elements/select/select.tsx +146 -0
- package/src/design_system/elements/shared-assets/credit-card/credit-card.tsx +237 -0
- package/src/design_system/elements/shared-assets/credit-card/icons.tsx +75 -0
- package/src/design_system/elements/shared-assets/iphone-mockup.tsx +172 -0
- package/src/design_system/elements/shared-assets/section-divider.tsx +12 -0
- package/src/design_system/elements/slideout-menus/slideout-menu.tsx +122 -0
- package/src/design_system/elements/tabs/tabs.tsx +225 -0
- package/src/design_system/elements/tags/base-components/tag-checkbox.tsx +45 -0
- package/src/design_system/elements/tags/base-components/tag-close-x.tsx +34 -0
- package/src/design_system/elements/tags/tags.tsx +176 -0
- package/src/design_system/elements/textarea/textarea.aman.tsx +52 -0
- package/src/design_system/elements/textarea/textarea.tsx +111 -0
- package/src/design_system/elements/toggle/toggle.tsx +140 -0
- package/src/design_system/elements/tooltip/tooltip.tsx +109 -0
- package/src/design_system/hooks/use-breakpoint.ts +37 -0
- package/src/design_system/hooks/use-resize-observer.ts +68 -0
- package/src/design_system/logo/keystone-logo-minimal.tsx +93 -0
- package/src/design_system/logo/keystone-logo.tsx +22 -0
- package/src/design_system/sections/about-home.aman.tsx +85 -0
- package/src/design_system/sections/about-home.tsx +115 -0
- package/src/design_system/sections/blog-cards.tsx +848 -0
- package/src/design_system/sections/blog-gallery.aman.tsx +77 -0
- package/src/design_system/sections/blog-gallery.tsx +204 -0
- package/src/design_system/sections/blog-home.aman.tsx +84 -0
- package/src/design_system/sections/blog-home.tsx +153 -0
- package/src/design_system/sections/blog-post.aman.tsx +74 -0
- package/src/design_system/sections/blog-post.tsx +301 -0
- package/src/design_system/sections/blog-section.aman.tsx +101 -0
- package/src/design_system/sections/blog-section.tsx +179 -0
- package/src/design_system/sections/contact-home.tsx +25 -0
- package/src/design_system/sections/contact-section.aman.tsx +173 -0
- package/src/design_system/sections/contact-section.tsx +143 -0
- package/src/design_system/sections/faq-grid.aman.tsx +79 -0
- package/src/design_system/sections/faq-grid.tsx +102 -0
- package/src/design_system/sections/faq-home.aman.tsx +92 -0
- package/src/design_system/sections/faq-home.tsx +134 -0
- package/src/design_system/sections/feature-tab.tsx +43 -0
- package/src/design_system/sections/feature-text.tsx +284 -0
- package/src/design_system/sections/footer-home.aman.tsx +62 -0
- package/src/design_system/sections/footer-home.tsx +259 -0
- package/src/design_system/sections/generic-header-component.tsx +103 -0
- package/src/design_system/sections/header-navigation.aman.tsx +360 -0
- package/src/design_system/sections/header-navigation.tsx +334 -0
- package/src/design_system/sections/hero-faq.aman.tsx +38 -0
- package/src/design_system/sections/hero-faq.tsx +55 -0
- package/src/design_system/sections/hero-generic-text.aman.tsx +49 -0
- package/src/design_system/sections/hero-generic-text.tsx +51 -0
- package/src/design_system/sections/hero-home.aman.tsx +84 -0
- package/src/design_system/sections/hero-home.tsx +246 -0
- package/src/design_system/sections/hero-location-detail.aman.tsx +33 -0
- package/src/design_system/sections/hero-location-detail.tsx +72 -0
- package/src/design_system/sections/hero-service-detail.aman.tsx +53 -0
- package/src/design_system/sections/hero-service-detail.tsx +51 -0
- package/src/design_system/sections/hero-social-media.aman.tsx +42 -0
- package/src/design_system/sections/hero-social-media.tsx +35 -0
- package/src/design_system/sections/hero-testimonials.aman.tsx +38 -0
- package/src/design_system/sections/hero-testimonials.tsx +55 -0
- package/src/design_system/sections/home-hero-component.tsx +228 -0
- package/src/design_system/sections/index.tsx +131 -0
- package/src/design_system/sections/job-gallery.aman.tsx +91 -0
- package/src/design_system/sections/job-gallery.tsx +183 -0
- package/src/design_system/sections/location-details-section.aman.tsx +179 -0
- package/src/design_system/sections/location-details-section.tsx +196 -0
- package/src/design_system/sections/location-grid.aman.tsx +76 -0
- package/src/design_system/sections/location-grid.tsx +123 -0
- package/src/design_system/sections/services-grid.aman.tsx +85 -0
- package/src/design_system/sections/services-grid.tsx +104 -0
- package/src/design_system/sections/services-home.aman.tsx +78 -0
- package/src/design_system/sections/services-home.tsx +131 -0
- package/src/design_system/sections/social-media-grid.aman.tsx +132 -0
- package/src/design_system/sections/social-media-grid.tsx +189 -0
- package/src/design_system/sections/statistics-section.aman.tsx +79 -0
- package/src/design_system/sections/statistics-section.tsx +97 -0
- package/src/design_system/sections/team-grid.aman.tsx +85 -0
- package/src/design_system/sections/team-grid.tsx +88 -0
- package/src/design_system/sections/testimonials-home.aman.tsx +113 -0
- package/src/design_system/sections/testimonials-home.tsx +90 -0
- package/src/design_system/sections/values-section.aman.tsx +73 -0
- package/src/design_system/sections/values-section.tsx +128 -0
- package/src/design_system/utils/icon-mapping.tsx +28 -0
- package/src/index.ts +7 -0
- package/src/lib/component-registry.ts +53 -0
- package/src/lib/hooks/index.ts +8 -0
- package/src/lib/hooks/use-breakpoint.ts +37 -0
- package/src/lib/hooks/use-clipboard.ts +79 -0
- package/src/lib/hooks/use-resize-observer.ts +68 -0
- package/src/lib/server-api.ts +115 -0
- package/src/styles/style-overrides.aman.css +101 -0
- package/src/styles/theme.css +224 -0
- package/src/styles/typography.css +430 -0
- package/src/themes/index.ts +23 -0
- package/src/types/api/blog-post.ts +53 -0
- package/src/types/api/company-information.ts +44 -0
- package/src/types/api/contact.ts +63 -0
- package/src/types/api/faq.ts +37 -0
- package/src/types/api/job-posting.ts +34 -0
- package/src/types/api/location.ts +36 -0
- package/src/types/api/photos.ts +28 -0
- package/src/types/api/service.ts +37 -0
- package/src/types/api/social-post.ts +28 -0
- package/src/types/api/team-member.ts +29 -0
- package/src/types/api/testimonial.ts +29 -0
- package/src/types/api/website-photos.ts +22 -0
- package/src/types/config.ts +21 -0
- package/src/types/index.ts +21 -0
- package/src/utils/countries.tsx +1351 -0
- package/src/utils/cx.ts +25 -0
- package/src/utils/gradient-placeholder.ts +59 -0
- package/src/utils/is-react-component.ts +33 -0
- package/src/utils/markdown-toc.ts +54 -0
- 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
|
+
…
|
|
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
|
+
…
|
|
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
|
+
…
|
|
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
|
+
…
|
|
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
|
+
};
|