@trackany-device/components 1.0.0

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 (289) hide show
  1. package/package.json +185 -0
  2. package/src/assets/logo.png +0 -0
  3. package/src/assets/map/arrows/map-arrow-blue.png +0 -0
  4. package/src/assets/map/arrows/map-arrow-green.png +0 -0
  5. package/src/assets/map/arrows/map-arrow-purple.png +0 -0
  6. package/src/assets/map/arrows/map-arrow-red.png +0 -0
  7. package/src/assets/map/flags/flag-blue.png +0 -0
  8. package/src/assets/map/flags/flag-green.png +0 -0
  9. package/src/assets/map/flags/flag-red.png +0 -0
  10. package/src/assets/map/flags/flag-yellow.png +0 -0
  11. package/src/assets/map/pins/map-pin-blue.png +0 -0
  12. package/src/assets/map/pins/map-pin-green.png +0 -0
  13. package/src/assets/map/pins/map-pin-purple.png +0 -0
  14. package/src/assets/map/pins/map-pin-red.png +0 -0
  15. package/src/components/Card.tsx +9 -0
  16. package/src/components/alert-error.tsx +24 -0
  17. package/src/components/app-content.tsx +22 -0
  18. package/src/components/app-header.tsx +153 -0
  19. package/src/components/app-logo-icon.tsx +13 -0
  20. package/src/components/app-logo.tsx +21 -0
  21. package/src/components/app-shell.tsx +19 -0
  22. package/src/components/app-sidebar-header.tsx +68 -0
  23. package/src/components/app-sidebar.tsx +106 -0
  24. package/src/components/appearance-tabs.tsx +46 -0
  25. package/src/components/breadcrumbs.tsx +50 -0
  26. package/src/components/cms/blurred-image.tsx +111 -0
  27. package/src/components/cms/section-bg.tsx +473 -0
  28. package/src/components/cms/section-button.tsx +127 -0
  29. package/src/components/cms/sections/banner-5050-section.tsx +135 -0
  30. package/src/components/cms/sections/blogs-listing-section.tsx +270 -0
  31. package/src/components/cms/sections/cards-grid-section.tsx +185 -0
  32. package/src/components/cms/sections/contact-form-section.tsx +157 -0
  33. package/src/components/cms/sections/cta-section.tsx +101 -0
  34. package/src/components/cms/sections/featured-blog-slider-section.tsx +256 -0
  35. package/src/components/cms/sections/featured-products-grid-section.tsx +173 -0
  36. package/src/components/cms/sections/featured-solutions-grid-section.tsx +183 -0
  37. package/src/components/cms/sections/hero-section.tsx +180 -0
  38. package/src/components/cms/sections/solutions-with-filter-section.tsx +234 -0
  39. package/src/components/cms/sections/text-section.tsx +77 -0
  40. package/src/components/cutout-image.tsx +228 -0
  41. package/src/components/devices/devices-mini-map.tsx +275 -0
  42. package/src/components/docs/docs-shell.tsx +280 -0
  43. package/src/components/fleet-hero-animated.tsx +383 -0
  44. package/src/components/input-error.tsx +17 -0
  45. package/src/components/keenicons/assets/duotone/Read Me.txt +7 -0
  46. package/src/components/keenicons/assets/duotone/demo-files/demo.css +160 -0
  47. package/src/components/keenicons/assets/duotone/demo-files/demo.js +32 -0
  48. package/src/components/keenicons/assets/duotone/demo.html +12424 -0
  49. package/src/components/keenicons/assets/duotone/fonts/keenicons-duotone.svg +1109 -0
  50. package/src/components/keenicons/assets/duotone/fonts/keenicons-duotone.ttf +0 -0
  51. package/src/components/keenicons/assets/duotone/fonts/keenicons-duotone.woff +0 -0
  52. package/src/components/keenicons/assets/duotone/selection.json +17313 -0
  53. package/src/components/keenicons/assets/duotone/style.css +4931 -0
  54. package/src/components/keenicons/assets/filled/Read Me.txt +7 -0
  55. package/src/components/keenicons/assets/filled/demo-files/demo.css +160 -0
  56. package/src/components/keenicons/assets/filled/demo-files/demo.js +32 -0
  57. package/src/components/keenicons/assets/filled/demo.html +12370 -0
  58. package/src/components/keenicons/assets/filled/fonts/keenicons-filled.svg +1082 -0
  59. package/src/components/keenicons/assets/filled/fonts/keenicons-filled.ttf +0 -0
  60. package/src/components/keenicons/assets/filled/fonts/keenicons-filled.woff +0 -0
  61. package/src/components/keenicons/assets/filled/selection.json +17096 -0
  62. package/src/components/keenicons/assets/filled/style.css +4769 -0
  63. package/src/components/keenicons/assets/outline/Read Me.txt +7 -0
  64. package/src/components/keenicons/assets/outline/demo-files/demo.css +160 -0
  65. package/src/components/keenicons/assets/outline/demo-files/demo.js +32 -0
  66. package/src/components/keenicons/assets/outline/demo.html +11356 -0
  67. package/src/components/keenicons/assets/outline/fonts/keenicons-outline.svg +575 -0
  68. package/src/components/keenicons/assets/outline/fonts/keenicons-outline.ttf +0 -0
  69. package/src/components/keenicons/assets/outline/fonts/keenicons-outline.woff +0 -0
  70. package/src/components/keenicons/assets/outline/selection.json +13054 -0
  71. package/src/components/keenicons/assets/outline/style.css +1721 -0
  72. package/src/components/keenicons/assets/solid/Read Me.txt +7 -0
  73. package/src/components/keenicons/assets/solid/demo-files/demo.css +160 -0
  74. package/src/components/keenicons/assets/solid/demo-files/demo.js +32 -0
  75. package/src/components/keenicons/assets/solid/demo.html +11356 -0
  76. package/src/components/keenicons/assets/solid/fonts/keenicons-solid.svg +575 -0
  77. package/src/components/keenicons/assets/solid/fonts/keenicons-solid.ttf +0 -0
  78. package/src/components/keenicons/assets/solid/fonts/keenicons-solid.woff +0 -0
  79. package/src/components/keenicons/assets/solid/selection.json +13048 -0
  80. package/src/components/keenicons/assets/solid/style.css +1721 -0
  81. package/src/components/keenicons/assets/styles.css +4 -0
  82. package/src/components/keenicons/index.ts +2 -0
  83. package/src/components/keenicons/keenicons.tsx +16 -0
  84. package/src/components/keenicons/types.ts +7 -0
  85. package/src/components/nav-footer.tsx +49 -0
  86. package/src/components/nav-main.tsx +53 -0
  87. package/src/components/nav-user.tsx +59 -0
  88. package/src/components/notification-bell.tsx +190 -0
  89. package/src/components/products/product-card.tsx +159 -0
  90. package/src/components/text-link.tsx +23 -0
  91. package/src/components/ui/accordion-menu.tsx +322 -0
  92. package/src/components/ui/accordion.tsx +133 -0
  93. package/src/components/ui/alert-dialog.tsx +82 -0
  94. package/src/components/ui/alert.tsx +63 -0
  95. package/src/components/ui/avatar-group.tsx +129 -0
  96. package/src/components/ui/avatar.tsx +67 -0
  97. package/src/components/ui/badge.tsx +230 -0
  98. package/src/components/ui/breadcrumb.tsx +88 -0
  99. package/src/components/ui/button.tsx +412 -0
  100. package/src/components/ui/calendar.tsx +56 -0
  101. package/src/components/ui/card.tsx +147 -0
  102. package/src/components/ui/chart.tsx +290 -0
  103. package/src/components/ui/checkbox.tsx +47 -0
  104. package/src/components/ui/code.tsx +45 -0
  105. package/src/components/ui/collapsible.tsx +31 -0
  106. package/src/components/ui/command-palette.tsx +189 -0
  107. package/src/components/ui/command.tsx +138 -0
  108. package/src/components/ui/cookie-banner.tsx +220 -0
  109. package/src/components/ui/copy-button.tsx +60 -0
  110. package/src/components/ui/data-grid-column-filter.tsx +124 -0
  111. package/src/components/ui/data-grid-column-header.tsx +284 -0
  112. package/src/components/ui/data-grid-column-visibility.tsx +38 -0
  113. package/src/components/ui/data-grid-pagination.tsx +206 -0
  114. package/src/components/ui/data-grid-table-dnd-rows.tsx +147 -0
  115. package/src/components/ui/data-grid-table-dnd.tsx +175 -0
  116. package/src/components/ui/data-grid-table.tsx +500 -0
  117. package/src/components/ui/data-grid.tsx +193 -0
  118. package/src/components/ui/data-list.tsx +76 -0
  119. package/src/components/ui/datefield.tsx +91 -0
  120. package/src/components/ui/dialog.tsx +139 -0
  121. package/src/components/ui/divider.tsx +41 -0
  122. package/src/components/ui/drawer.tsx +59 -0
  123. package/src/components/ui/dropdown-menu.tsx +224 -0
  124. package/src/components/ui/empty-state.tsx +54 -0
  125. package/src/components/ui/file-upload.tsx +152 -0
  126. package/src/components/ui/form.tsx +88 -0
  127. package/src/components/ui/icon.tsx +14 -0
  128. package/src/components/ui/input-otp.tsx +71 -0
  129. package/src/components/ui/input.tsx +155 -0
  130. package/src/components/ui/kbd.tsx +26 -0
  131. package/src/components/ui/label.tsx +31 -0
  132. package/src/components/ui/navigation-menu.tsx +168 -0
  133. package/src/components/ui/pagination.tsx +37 -0
  134. package/src/components/ui/placeholder-pattern.tsx +21 -0
  135. package/src/components/ui/popover.tsx +50 -0
  136. package/src/components/ui/progress.tsx +65 -0
  137. package/src/components/ui/radio-group.tsx +73 -0
  138. package/src/components/ui/resizable.tsx +39 -0
  139. package/src/components/ui/scroll-area.tsx +50 -0
  140. package/src/components/ui/select.tsx +234 -0
  141. package/src/components/ui/separator.tsx +24 -0
  142. package/src/components/ui/sheet.tsx +147 -0
  143. package/src/components/ui/sidebar.tsx +721 -0
  144. package/src/components/ui/skeleton.tsx +15 -0
  145. package/src/components/ui/slider.tsx +35 -0
  146. package/src/components/ui/sonner.tsx +28 -0
  147. package/src/components/ui/sortable.tsx +724 -0
  148. package/src/components/ui/spinner.tsx +17 -0
  149. package/src/components/ui/stat-card.tsx +82 -0
  150. package/src/components/ui/stepper.tsx +410 -0
  151. package/src/components/ui/switch.tsx +68 -0
  152. package/src/components/ui/table.tsx +42 -0
  153. package/src/components/ui/tabs.tsx +196 -0
  154. package/src/components/ui/timeline.tsx +90 -0
  155. package/src/components/ui/toggle-group.tsx +73 -0
  156. package/src/components/ui/toggle.tsx +45 -0
  157. package/src/components/ui/tooltip.tsx +55 -0
  158. package/src/components/user-info.tsx +33 -0
  159. package/src/components/user-menu-content.tsx +53 -0
  160. package/src/components/web/SiteFooter.tsx +154 -0
  161. package/src/components/web/SiteHeader.tsx +159 -0
  162. package/src/components/workflows/workflow-canvas.tsx +321 -0
  163. package/src/controls/Blockquote.tsx +25 -0
  164. package/src/controls/Button.tsx +101 -0
  165. package/src/controls/Checkbox.tsx +29 -0
  166. package/src/controls/DateField.tsx +37 -0
  167. package/src/controls/FormField.tsx +20 -0
  168. package/src/controls/Heading.tsx +28 -0
  169. package/src/controls/Input.tsx +21 -0
  170. package/src/controls/Label.tsx +18 -0
  171. package/src/controls/Paragraph.tsx +39 -0
  172. package/src/controls/PasswordInput.tsx +40 -0
  173. package/src/controls/RadioGroup.tsx +70 -0
  174. package/src/controls/Select.tsx +24 -0
  175. package/src/controls/Slider.tsx +33 -0
  176. package/src/controls/Switch.tsx +31 -0
  177. package/src/controls/Textarea.tsx +22 -0
  178. package/src/elements/ConfirmPasswordForm.tsx +43 -0
  179. package/src/elements/DeviceStatusBadge.tsx +38 -0
  180. package/src/elements/DriverCard.tsx +67 -0
  181. package/src/elements/ForgotPasswordForm.tsx +64 -0
  182. package/src/elements/IncidentCard.tsx +67 -0
  183. package/src/elements/LoginForm.tsx +100 -0
  184. package/src/elements/OtpForm.tsx +71 -0
  185. package/src/elements/RegisterForm.tsx +150 -0
  186. package/src/elements/ResetPasswordForm.tsx +72 -0
  187. package/src/elements/SmsChallengeForm.tsx +104 -0
  188. package/src/elements/VehicleCard.tsx +73 -0
  189. package/src/elements/VerifyEmailForm.tsx +39 -0
  190. package/src/hooks/use-appearance.tsx +117 -0
  191. package/src/hooks/use-applied-theme.ts +98 -0
  192. package/src/hooks/use-clipboard.ts +34 -0
  193. package/src/hooks/use-current-url.ts +83 -0
  194. package/src/hooks/use-dark-mode.ts +48 -0
  195. package/src/hooks/use-flash-toast.ts +29 -0
  196. package/src/hooks/use-initials.tsx +24 -0
  197. package/src/hooks/use-mobile-navigation.ts +12 -0
  198. package/src/hooks/use-mobile.tsx +38 -0
  199. package/src/index.ts +408 -0
  200. package/src/layouts/AppLayout.tsx +60 -0
  201. package/src/layouts/AuthLayout.tsx +32 -0
  202. package/src/layouts/SettingsLayout.tsx +21 -0
  203. package/src/layouts/app/AIChatLayout.tsx +73 -0
  204. package/src/layouts/app/AsideSidebarLayout.tsx +3 -0
  205. package/src/layouts/app/CalendarSidebarLayout.tsx +69 -0
  206. package/src/layouts/app/CommunitiesNavbarLayout.tsx +3 -0
  207. package/src/layouts/app/DualNavbarSidebarLayout.tsx +3 -0
  208. package/src/layouts/app/FocusSidebarLayout.tsx +75 -0
  209. package/src/layouts/app/MailLayout.tsx +69 -0
  210. package/src/layouts/app/MegaMenuHeaderLayout.tsx +3 -0
  211. package/src/layouts/app/MegaMenuLayout.tsx +81 -0
  212. package/src/layouts/app/MegaMenuNavbarLayout.tsx +88 -0
  213. package/src/layouts/app/MegaMenuSearchNavbarLayout.tsx +3 -0
  214. package/src/layouts/app/NavbarCollapsibleLayout.tsx +88 -0
  215. package/src/layouts/app/NavbarCollapsibleLinksLayout.tsx +3 -0
  216. package/src/layouts/app/NavbarMinimalLayout.tsx +3 -0
  217. package/src/layouts/app/NavbarMinimalSidebarLayout.tsx +3 -0
  218. package/src/layouts/app/NavbarSidebarDashboardLayout.tsx +3 -0
  219. package/src/layouts/app/NavbarSidebarLayout.tsx +92 -0
  220. package/src/layouts/app/NavbarSimpleSidebarLayout.tsx +3 -0
  221. package/src/layouts/app/NavbarTitledSidebarLayout.tsx +3 -0
  222. package/src/layouts/app/PanelSidebarLayout.tsx +3 -0
  223. package/src/layouts/app/SearchNavbarSidebarLayout.tsx +3 -0
  224. package/src/layouts/app/SidebarBreadcrumbLayout.tsx +3 -0
  225. package/src/layouts/app/SidebarCleanLayout.tsx +3 -0
  226. package/src/layouts/app/SidebarCommunitiesLayout.tsx +3 -0
  227. package/src/layouts/app/SidebarContentLayout.tsx +3 -0
  228. package/src/layouts/app/SidebarDualMenuLayout.tsx +104 -0
  229. package/src/layouts/app/SidebarFixedLayout.tsx +166 -0
  230. package/src/layouts/app/SidebarFooterNavbarLayout.tsx +3 -0
  231. package/src/layouts/app/SidebarHeaderMenuLayout.tsx +3 -0
  232. package/src/layouts/app/SidebarMegaMenuLayout.tsx +4 -0
  233. package/src/layouts/app/SidebarMinimalLayout.tsx +70 -0
  234. package/src/layouts/app/SidebarMobileSearchLayout.tsx +3 -0
  235. package/src/layouts/app/SidebarMultiPanelLayout.tsx +3 -0
  236. package/src/layouts/app/SidebarPrimarySecondaryLayout.tsx +3 -0
  237. package/src/layouts/app/SidebarSearchHeaderLayout.tsx +103 -0
  238. package/src/layouts/app/SidebarSearchToolbarLayout.tsx +3 -0
  239. package/src/layouts/app/SidebarTabsDualLayout.tsx +3 -0
  240. package/src/layouts/app/SidebarTabsLayout.tsx +98 -0
  241. package/src/layouts/app/SidebarTreeLayout.tsx +3 -0
  242. package/src/layouts/app/SplitNavbarLayout.tsx +3 -0
  243. package/src/layouts/app/SplitSidebarDashboardLayout.tsx +3 -0
  244. package/src/layouts/app/SplitSidebarLayout.tsx +99 -0
  245. package/src/layouts/app/TopNavLayout.tsx +105 -0
  246. package/src/layouts/app/TopNavLinksLayout.tsx +3 -0
  247. package/src/layouts/app/WorkspaceBreadcrumbLayout.tsx +3 -0
  248. package/src/layouts/app/WorkspaceCommunitiesLayout.tsx +3 -0
  249. package/src/layouts/app/WorkspaceNavbarLayout.tsx +3 -0
  250. package/src/layouts/app/WorkspaceSidebarLayout.tsx +98 -0
  251. package/src/layouts/app/WorkspaceSidebarTitleLayout.tsx +3 -0
  252. package/src/layouts/app/app-header-layout.tsx +45 -0
  253. package/src/layouts/app/app-sidebar-layout.tsx +56 -0
  254. package/src/layouts/app/layout-context.tsx +44 -0
  255. package/src/layouts/app/layout-types.ts +47 -0
  256. package/src/layouts/app/partials/Footer.tsx +35 -0
  257. package/src/layouts/app/partials/HeaderTopbar.tsx +96 -0
  258. package/src/layouts/app/partials/Navbar.tsx +85 -0
  259. package/src/layouts/app/partials/Toolbar.tsx +47 -0
  260. package/src/layouts/app-layout.tsx +29 -0
  261. package/src/layouts/auth/AuthBrandedLayout.tsx +58 -0
  262. package/src/layouts/auth/AuthCardLayout.tsx +31 -0
  263. package/src/layouts/auth/AuthCenteredLayout.tsx +41 -0
  264. package/src/layouts/auth/AuthClassicLayout.tsx +41 -0
  265. package/src/layouts/auth/AuthSimpleLayout.tsx +33 -0
  266. package/src/layouts/auth/AuthSplitLayout.tsx +89 -0
  267. package/src/layouts/web-app-layout.tsx +162 -0
  268. package/src/layouts/web-layout.tsx +23 -0
  269. package/src/lib/datetime.ts +188 -0
  270. package/src/lib/google-maps-loader.ts +99 -0
  271. package/src/lib/location.ts +127 -0
  272. package/src/lib/lucide-icon-map.ts +132 -0
  273. package/src/lib/map-markers.ts +124 -0
  274. package/src/lib/map-styles.ts +351 -0
  275. package/src/lib/utils.ts +11 -0
  276. package/src/platform/adapters/default.tsx +156 -0
  277. package/src/platform/adapters/inertia.tsx +88 -0
  278. package/src/platform/adapters/nextjs.ts +86 -0
  279. package/src/platform/context.tsx +106 -0
  280. package/src/platform/index.ts +27 -0
  281. package/src/platform/types.ts +105 -0
  282. package/src/styles/layouts/sidebar-fixed.css +161 -0
  283. package/src/styles/themes.css +583 -0
  284. package/src/types/assets.d.ts +5 -0
  285. package/src/types/auth.ts +25 -0
  286. package/src/types/global.d.ts +13 -0
  287. package/src/types/index.ts +9 -0
  288. package/src/types/navigation.ts +15 -0
  289. package/src/types/ui.ts +32 -0
@@ -0,0 +1,256 @@
1
+ import { PlatformLink } from '../../../platform/context';
2
+ import { ArrowRight, Calendar } from 'lucide-react';
3
+
4
+ import { BlurredImage } from '../blurred-image';
5
+ import { SectionButtons } from '../section-button';
6
+ import type { CmsButton } from '../section-button';
7
+ import { Badge } from '../../ui/badge';
8
+ import { cn } from '../../../lib/utils';
9
+
10
+ export type FeaturedBlogSliderContent = {
11
+ eyebrow?: string | null;
12
+ title?: string | null;
13
+ subtitle?: string | null;
14
+ featured_blog_slug?: string | null;
15
+ list_count?: number | null;
16
+ buttons?: CmsButton[] | null;
17
+ };
18
+
19
+ export type BlogPostCard = {
20
+ id: number;
21
+ title: string;
22
+ slug: string;
23
+ excerpt: string | null;
24
+ cover_image: string | null;
25
+ published_at: string | null;
26
+ author: { name: string } | null;
27
+ tags: { name: string; slug: string; color: string | null }[];
28
+ };
29
+
30
+ /**
31
+ * Featured blog slider — large hero post on the left + vertical
32
+ * scroll-snap list of recent posts on the right. The "featured" post
33
+ * is content.featured_blog_slug if set, otherwise the newest published
34
+ * post; the list contains up to `list_count` newer-first remaining posts.
35
+ */
36
+ export function FeaturedBlogSliderSection({
37
+ content,
38
+ posts,
39
+ identifier,
40
+ }: {
41
+ content?: FeaturedBlogSliderContent;
42
+ posts?: BlogPostCard[];
43
+ identifier?: string | null;
44
+ }) {
45
+ const listCount = content?.list_count ?? 5;
46
+ const pool = posts ?? [];
47
+
48
+ if (pool.length === 0) {
49
+ return null;
50
+ }
51
+
52
+ const explicit = content?.featured_blog_slug
53
+ ? pool.find((p) => p.slug === content.featured_blog_slug)
54
+ : null;
55
+ const featured = explicit ?? pool[0];
56
+ const list = pool.filter((p) => p.id !== featured.id).slice(0, listCount);
57
+
58
+ return (
59
+ <section
60
+ id={identifier ?? undefined}
61
+ className="bg-background py-20 sm:py-24"
62
+ >
63
+ <div className="mx-auto max-w-6xl px-6">
64
+ {(content?.eyebrow || content?.title || content?.subtitle) && (
65
+ <header className="mb-10 max-w-2xl">
66
+ {content?.eyebrow && (
67
+ <p className="mb-3 text-xs font-medium tracking-widest text-muted-foreground uppercase">
68
+ {content.eyebrow}
69
+ </p>
70
+ )}
71
+ {content?.title && (
72
+ <h2 className="text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
73
+ {content.title}
74
+ </h2>
75
+ )}
76
+ {content?.subtitle && (
77
+ <p className="mt-3 text-base leading-relaxed text-pretty text-muted-foreground sm:text-lg">
78
+ {content.subtitle}
79
+ </p>
80
+ )}
81
+ </header>
82
+ )}
83
+
84
+ <div className="grid gap-6 lg:grid-cols-12">
85
+ <FeaturedPost post={featured} />
86
+ <PostList posts={list} />
87
+ </div>
88
+
89
+ {content?.buttons && content.buttons.length > 0 && (
90
+ <div className="mt-10">
91
+ <SectionButtons
92
+ buttons={content.buttons}
93
+ align="left"
94
+ size="md"
95
+ />
96
+ </div>
97
+ )}
98
+ </div>
99
+ </section>
100
+ );
101
+ }
102
+
103
+ function FeaturedPost({ post }: { post: BlogPostCard }) {
104
+ return (
105
+ <PlatformLink href={`/blog/${post.slug}`} className="group block lg:col-span-7">
106
+ <article className="flex h-full flex-col overflow-hidden rounded-2xl border border-border/60 bg-card transition-colors hover:border-primary/40">
107
+ <div className="relative aspect-[16/10] overflow-hidden bg-muted">
108
+ <BlurredImage
109
+ src={post.cover_image}
110
+ alt={post.title}
111
+ padding="p-0"
112
+ rounded="md"
113
+ fit="cover"
114
+ whiteWash={0}
115
+ className="h-full w-full transition-transform duration-500 group-hover:scale-[1.02]"
116
+ />
117
+ </div>
118
+ <div className="flex flex-1 flex-col p-6">
119
+ <TagsRow tags={post.tags} />
120
+ <h3 className="mt-2 text-2xl font-semibold tracking-tight text-balance text-card-foreground sm:text-3xl">
121
+ {post.title}
122
+ </h3>
123
+ {post.excerpt && (
124
+ <p className="mt-3 line-clamp-3 flex-1 leading-relaxed text-pretty text-muted-foreground">
125
+ {post.excerpt}
126
+ </p>
127
+ )}
128
+ <PostMeta post={post} className="mt-5" />
129
+ </div>
130
+ </article>
131
+ </PlatformLink>
132
+ );
133
+ }
134
+
135
+ function PostList({ posts }: { posts: BlogPostCard[] }) {
136
+ if (posts.length === 0) {
137
+ return null;
138
+ }
139
+
140
+ return (
141
+ <div
142
+ className={cn(
143
+ 'lg:col-span-5',
144
+ // Vertical scroll-snap on tall screens; auto-height otherwise.
145
+ 'lg:max-h-[640px] lg:snap-y lg:snap-mandatory lg:overflow-y-auto',
146
+ 'lg:pr-2 lg:[scrollbar-width:thin]',
147
+ )}
148
+ >
149
+ <ul className="flex flex-col gap-3">
150
+ {posts.map((p) => (
151
+ <li key={p.id} className="lg:snap-start">
152
+ <a
153
+ href={`/blog/${p.slug}`}
154
+ className="group flex items-start gap-4 rounded-xl border border-border/60 bg-card p-4 transition-colors hover:border-primary/40"
155
+ >
156
+ <div className="size-16 shrink-0 overflow-hidden rounded-md bg-muted">
157
+ <BlurredImage
158
+ src={p.cover_image}
159
+ alt=""
160
+ padding="p-0"
161
+ rounded="md"
162
+ fit="cover"
163
+ whiteWash={0}
164
+ className="h-full w-full"
165
+ />
166
+ </div>
167
+ <div className="flex min-w-0 flex-1 flex-col">
168
+ <TagsRow tags={p.tags.slice(0, 1)} />
169
+ <h4 className="mt-1 line-clamp-2 text-sm font-semibold text-card-foreground group-hover:text-primary">
170
+ {p.title}
171
+ </h4>
172
+ {p.published_at && (
173
+ <time
174
+ dateTime={p.published_at}
175
+ className="mt-1 text-xs text-muted-foreground"
176
+ >
177
+ {formatDate(p.published_at)}
178
+ </time>
179
+ )}
180
+ </div>
181
+ <ArrowRight className="mt-1 size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-primary" />
182
+ </a>
183
+ </li>
184
+ ))}
185
+ </ul>
186
+ </div>
187
+ );
188
+ }
189
+
190
+ function TagsRow({ tags }: { tags: BlogPostCard['tags'] }) {
191
+ if (!tags || tags.length === 0) {
192
+ return null;
193
+ }
194
+
195
+ return (
196
+ <div className="flex flex-wrap gap-1.5">
197
+ {tags.map((t) => (
198
+ <Badge
199
+ key={t.slug}
200
+ variant="secondary"
201
+ className="text-[10px] font-medium"
202
+ style={
203
+ t.color
204
+ ? {
205
+ backgroundColor: `${t.color}1a`,
206
+ color: t.color,
207
+ }
208
+ : undefined
209
+ }
210
+ >
211
+ {t.name}
212
+ </Badge>
213
+ ))}
214
+ </div>
215
+ );
216
+ }
217
+
218
+ function PostMeta({
219
+ post,
220
+ className,
221
+ }: {
222
+ post: BlogPostCard;
223
+ className?: string;
224
+ }) {
225
+ return (
226
+ <div
227
+ className={cn(
228
+ 'flex items-center gap-3 text-xs text-muted-foreground',
229
+ className,
230
+ )}
231
+ >
232
+ {post.author && <span>{post.author.name}</span>}
233
+ {post.author && post.published_at && <span>·</span>}
234
+ {post.published_at && (
235
+ <span className="inline-flex items-center gap-1">
236
+ <Calendar className="size-3" />
237
+ <time dateTime={post.published_at}>
238
+ {formatDate(post.published_at)}
239
+ </time>
240
+ </span>
241
+ )}
242
+ </div>
243
+ );
244
+ }
245
+
246
+ function formatDate(iso: string): string {
247
+ try {
248
+ return new Date(iso).toLocaleDateString(undefined, {
249
+ month: 'short',
250
+ day: 'numeric',
251
+ year: 'numeric',
252
+ });
253
+ } catch {
254
+ return iso;
255
+ }
256
+ }
@@ -0,0 +1,173 @@
1
+ 'use client';
2
+
3
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
4
+ import { useEffect, useRef, useState } from 'react';
5
+
6
+ import { SectionButtons } from '../section-button';
7
+ import type { CmsButton } from '../section-button';
8
+ import { ProductCard } from '../../products/product-card';
9
+ import type { ProductCardData } from '../../products/product-card';
10
+ import { Button } from '../../ui/button';
11
+ import { cn } from '../../../lib/utils';
12
+
13
+ export type FeaturedProductsGridContent = {
14
+ eyebrow?: string | null;
15
+ title?: string | null;
16
+ subtitle?: string | null;
17
+ columns?: 2 | 3 | 4 | null;
18
+ max_items?: number | null;
19
+ show_buttons_on_cards?: boolean | null;
20
+ card_button_label?: string | null;
21
+ buttons?: CmsButton[] | null;
22
+ };
23
+
24
+ export type ProductCard = ProductCardData;
25
+
26
+ /**
27
+ * Featured products carousel. Pulls from `featured_products` shared prop
28
+ * (DeviceType::featured()). Renders a horizontal scroll-snap row with
29
+ * arrow controls — same `ProductCard` component used everywhere else,
30
+ * so cards stay visually consistent across surfaces.
31
+ */
32
+ export function FeaturedProductsGridSection({
33
+ content,
34
+ products,
35
+ identifier,
36
+ }: {
37
+ content?: FeaturedProductsGridContent;
38
+ products?: ProductCardData[];
39
+ identifier?: string | null;
40
+ }) {
41
+ const max = content?.max_items ?? 8;
42
+ const items = (products ?? []).slice(0, max > 0 ? max : undefined);
43
+
44
+ const scrollerRef = useRef<HTMLDivElement>(null);
45
+ const [canScrollLeft, setCanScrollLeft] = useState(false);
46
+ const [canScrollRight, setCanScrollRight] = useState(false);
47
+
48
+ useEffect(() => {
49
+ const el = scrollerRef.current;
50
+
51
+ if (!el) {
52
+ return;
53
+ }
54
+
55
+ const update = () => {
56
+ setCanScrollLeft(el.scrollLeft > 4);
57
+ setCanScrollRight(
58
+ el.scrollLeft + el.clientWidth < el.scrollWidth - 4,
59
+ );
60
+ };
61
+ update();
62
+ el.addEventListener('scroll', update, { passive: true });
63
+ window.addEventListener('resize', update);
64
+
65
+ return () => {
66
+ el.removeEventListener('scroll', update);
67
+ window.removeEventListener('resize', update);
68
+ };
69
+ }, [items.length]);
70
+
71
+ function scrollBy(direction: 1 | -1) {
72
+ const el = scrollerRef.current;
73
+
74
+ if (!el) {
75
+ return;
76
+ }
77
+
78
+ const card = el.querySelector<HTMLElement>('[data-product-card]');
79
+ const step = card ? card.offsetWidth + 20 : el.clientWidth * 0.8;
80
+ el.scrollBy({ left: direction * step, behavior: 'smooth' });
81
+ }
82
+
83
+ if (items.length === 0) {
84
+ return null;
85
+ }
86
+
87
+ return (
88
+ <section
89
+ id={identifier ?? undefined}
90
+ className="bg-muted/30 py-20 sm:py-24"
91
+ >
92
+ <div className="mx-auto max-w-7xl px-6">
93
+ {(content?.eyebrow || content?.title || content?.subtitle) && (
94
+ <header className="mx-auto mb-10 max-w-2xl text-center">
95
+ {content?.eyebrow && (
96
+ <p className="mb-3 text-xs font-medium tracking-widest text-muted-foreground uppercase">
97
+ {content.eyebrow}
98
+ </p>
99
+ )}
100
+ {content?.title && (
101
+ <h2 className="text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
102
+ {content.title}
103
+ </h2>
104
+ )}
105
+ {content?.subtitle && (
106
+ <p className="mt-3 text-base leading-relaxed text-pretty text-muted-foreground sm:text-lg">
107
+ {content.subtitle}
108
+ </p>
109
+ )}
110
+ </header>
111
+ )}
112
+
113
+ <div className="relative">
114
+ {/* Arrow controls */}
115
+ <Button
116
+ type="button"
117
+ variant="outline"
118
+ size="icon"
119
+ aria-label="Previous"
120
+ onClick={() => scrollBy(-1)}
121
+ disabled={!canScrollLeft}
122
+ className={cn(
123
+ 'absolute top-1/2 -left-2 z-10 hidden -translate-y-1/2 rounded-full bg-background shadow-md sm:flex',
124
+ !canScrollLeft && 'opacity-0',
125
+ )}
126
+ >
127
+ <ChevronLeft className="size-5" />
128
+ </Button>
129
+ <Button
130
+ type="button"
131
+ variant="outline"
132
+ size="icon"
133
+ aria-label="Next"
134
+ onClick={() => scrollBy(1)}
135
+ disabled={!canScrollRight}
136
+ className={cn(
137
+ 'absolute top-1/2 -right-2 z-10 hidden -translate-y-1/2 rounded-full bg-background shadow-md sm:flex',
138
+ !canScrollRight && 'opacity-0',
139
+ )}
140
+ >
141
+ <ChevronRight className="size-5" />
142
+ </Button>
143
+
144
+ {/* Scroll-snap carousel */}
145
+ <div
146
+ ref={scrollerRef}
147
+ className="flex snap-x snap-mandatory gap-5 overflow-x-auto pb-4 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
148
+ >
149
+ {items.map((p) => (
150
+ <div
151
+ key={p.id}
152
+ data-product-card
153
+ className="w-[260px] shrink-0 snap-start sm:w-[280px] lg:w-[300px]"
154
+ >
155
+ <ProductCard product={p} />
156
+ </div>
157
+ ))}
158
+ </div>
159
+ </div>
160
+
161
+ {content?.buttons && content.buttons.length > 0 && (
162
+ <div className="mt-12">
163
+ <SectionButtons
164
+ buttons={content.buttons}
165
+ align="center"
166
+ size="lg"
167
+ />
168
+ </div>
169
+ )}
170
+ </div>
171
+ </section>
172
+ );
173
+ }
@@ -0,0 +1,183 @@
1
+ import { PlatformLink } from '../../../platform/context';
2
+ import { ArrowRight } from 'lucide-react';
3
+
4
+ import { SectionButtons } from '../section-button';
5
+ import type { CmsButton } from '../section-button';
6
+ import { Card } from '../../ui/card';
7
+ import { Icon } from '../../ui/icon';
8
+ import { lucideIcon } from '../../../lib/lucide-icon-map';
9
+ import { cn } from '../../../lib/utils';
10
+
11
+ export type FeaturedSolutionsGridContent = {
12
+ eyebrow?: string | null;
13
+ title?: string | null;
14
+ subtitle?: string | null;
15
+ columns?: 2 | 3 | 4 | null;
16
+ max_items?: number | null;
17
+ show_buttons_on_cards?: boolean | null;
18
+ card_button_label?: string | null;
19
+ buttons?: CmsButton[] | null;
20
+ };
21
+
22
+ export type SolutionCard = {
23
+ id: number;
24
+ title: string;
25
+ description: string | null;
26
+ icon_name: string | null;
27
+ gradient_from?: string | null;
28
+ gradient_to?: string | null;
29
+ cta_label?: string | null;
30
+ cta_href?: string | null;
31
+ slug?: string;
32
+ };
33
+
34
+ const COLS: Record<number, string> = {
35
+ 2: 'sm:grid-cols-2',
36
+ 3: 'sm:grid-cols-2 lg:grid-cols-3',
37
+ 4: 'sm:grid-cols-2 lg:grid-cols-4',
38
+ };
39
+
40
+ /**
41
+ * Featured solutions grid. Pulls from `solutions` shared prop
42
+ * (Solution::featured()). Each card renders icon + title + description
43
+ * with an optional per-card CTA, plus an optional row of section-level
44
+ * buttons below the grid.
45
+ */
46
+ export function FeaturedSolutionsGridSection({
47
+ content,
48
+ solutions,
49
+ identifier,
50
+ }: {
51
+ content?: FeaturedSolutionsGridContent;
52
+ solutions?: SolutionCard[];
53
+ identifier?: string | null;
54
+ }) {
55
+ const columns = content?.columns ?? 3;
56
+ const max = content?.max_items ?? 0;
57
+ const showCta = content?.show_buttons_on_cards ?? true;
58
+ const ctaLabel = content?.card_button_label ?? 'Learn more';
59
+
60
+ const items = (solutions ?? []).slice(0, max > 0 ? max : undefined);
61
+
62
+ if (items.length === 0) {
63
+ return null;
64
+ }
65
+
66
+ return (
67
+ <section
68
+ id={identifier ?? undefined}
69
+ className="bg-background py-20 sm:py-24"
70
+ >
71
+ <div className="mx-auto max-w-6xl px-6">
72
+ {(content?.eyebrow || content?.title || content?.subtitle) && (
73
+ <header className="mx-auto mb-12 max-w-2xl text-center">
74
+ {content?.eyebrow && (
75
+ <p className="mb-3 text-xs font-medium tracking-widest text-muted-foreground uppercase">
76
+ {content.eyebrow}
77
+ </p>
78
+ )}
79
+ {content?.title && (
80
+ <h2 className="text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
81
+ {content.title}
82
+ </h2>
83
+ )}
84
+ {content?.subtitle && (
85
+ <p className="mt-3 text-base leading-relaxed text-pretty text-muted-foreground sm:text-lg">
86
+ {content.subtitle}
87
+ </p>
88
+ )}
89
+ </header>
90
+ )}
91
+
92
+ <div
93
+ className={cn(
94
+ 'grid grid-cols-1 gap-4',
95
+ COLS[columns] ?? COLS[3],
96
+ )}
97
+ >
98
+ {items.map((s) => (
99
+ <SolutionCardCell
100
+ key={s.id}
101
+ solution={s}
102
+ showCta={showCta}
103
+ ctaLabel={ctaLabel}
104
+ />
105
+ ))}
106
+ </div>
107
+
108
+ {content?.buttons && content.buttons.length > 0 && (
109
+ <div className="mt-12">
110
+ <SectionButtons
111
+ buttons={content.buttons}
112
+ align="center"
113
+ size="lg"
114
+ />
115
+ </div>
116
+ )}
117
+ </div>
118
+ </section>
119
+ );
120
+ }
121
+
122
+ function SolutionCardCell({
123
+ solution,
124
+ showCta,
125
+ ctaLabel,
126
+ }: {
127
+ solution: SolutionCard;
128
+ showCta: boolean;
129
+ ctaLabel: string;
130
+ }) {
131
+ const Icon$ = lucideIcon(solution.icon_name);
132
+ const href =
133
+ solution.cta_href ??
134
+ (solution.slug ? `/solutions/${solution.slug}` : null);
135
+
136
+ const card = (
137
+ <Card className="group flex h-full flex-col border-border/60 bg-card p-6 transition-colors hover:border-primary/40">
138
+ {Icon$ && (
139
+ <div
140
+ className={cn(
141
+ 'mb-4 inline-flex size-12 items-center justify-center rounded-xl text-white',
142
+ // Use the brand gradient if both stops set; otherwise default to primary.
143
+ solution.gradient_from && solution.gradient_to
144
+ ? 'bg-gradient-to-br'
145
+ : 'bg-primary',
146
+ )}
147
+ style={
148
+ solution.gradient_from && solution.gradient_to
149
+ ? {
150
+ backgroundImage: `linear-gradient(to bottom right, var(--color-${solution.gradient_from}, #2563eb), var(--color-${solution.gradient_to}, #1d4ed8))`,
151
+ }
152
+ : undefined
153
+ }
154
+ >
155
+ <Icon iconNode={Icon$} className="size-5" />
156
+ </div>
157
+ )}
158
+
159
+ <h3 className="text-lg font-semibold text-card-foreground">
160
+ {solution.title}
161
+ </h3>
162
+
163
+ {solution.description && (
164
+ <p className="mt-2 line-clamp-3 flex-1 text-sm leading-relaxed text-muted-foreground">
165
+ {solution.description}
166
+ </p>
167
+ )}
168
+
169
+ {showCta && href && (
170
+ <div className="mt-4 inline-flex items-center gap-1 text-sm font-medium text-primary group-hover:gap-2">
171
+ {solution.cta_label ?? ctaLabel}
172
+ <ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" />
173
+ </div>
174
+ )}
175
+ </Card>
176
+ );
177
+
178
+ if (href) {
179
+ return <PlatformLink href={href}>{card}</PlatformLink>;
180
+ }
181
+
182
+ return card;
183
+ }