sapient-ai 0.1.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 (55) hide show
  1. package/README.md +48 -0
  2. package/bin/sapient-ai.js +623 -0
  3. package/local-registry/README.md +59 -0
  4. package/local-registry/r/accordion.json +65 -0
  5. package/local-registry/r/alert.json +64 -0
  6. package/local-registry/r/badge.json +64 -0
  7. package/local-registry/r/button.json +66 -0
  8. package/local-registry/r/checkbox.json +65 -0
  9. package/local-registry/r/customer-satisfaction.json +61 -0
  10. package/local-registry/r/input.json +61 -0
  11. package/local-registry/r/label.json +64 -0
  12. package/local-registry/r/multiple-choice-card.json +66 -0
  13. package/local-registry/r/multiple-choice-grid.json +64 -0
  14. package/local-registry/r/multiple-choice-list.json +64 -0
  15. package/local-registry/r/news-card.json +61 -0
  16. package/local-registry/r/privacy-consent.json +61 -0
  17. package/local-registry/r/product-card.json +64 -0
  18. package/local-registry/r/profile-card.json +64 -0
  19. package/local-registry/r/progress.json +64 -0
  20. package/local-registry/r/promo-card.json +64 -0
  21. package/local-registry/r/radio-group.json +65 -0
  22. package/local-registry/r/separator.json +64 -0
  23. package/local-registry/r/switch.json +64 -0
  24. package/local-registry/r/tabs.json +64 -0
  25. package/local-registry/r/textarea.json +61 -0
  26. package/local-registry/r/video-card.json +69 -0
  27. package/local-registry/scripts/build-registry.mjs +283 -0
  28. package/local-registry/scripts/sync-to-design-system-public.mjs +43 -0
  29. package/local-registry/src/components/ui/sapient-accordion.tsx +89 -0
  30. package/local-registry/src/components/ui/sapient-alert.tsx +68 -0
  31. package/local-registry/src/components/ui/sapient-badge.tsx +28 -0
  32. package/local-registry/src/components/ui/sapient-button.tsx +31 -0
  33. package/local-registry/src/components/ui/sapient-checkbox.tsx +35 -0
  34. package/local-registry/src/components/ui/sapient-customer-satisfaction.tsx +189 -0
  35. package/local-registry/src/components/ui/sapient-icon.tsx +40 -0
  36. package/local-registry/src/components/ui/sapient-input.tsx +23 -0
  37. package/local-registry/src/components/ui/sapient-label.tsx +25 -0
  38. package/local-registry/src/components/ui/sapient-multiple-choice-card.tsx +172 -0
  39. package/local-registry/src/components/ui/sapient-multiple-choice-grid.tsx +94 -0
  40. package/local-registry/src/components/ui/sapient-multiple-choice-list.tsx +74 -0
  41. package/local-registry/src/components/ui/sapient-news-card.tsx +227 -0
  42. package/local-registry/src/components/ui/sapient-privacy-consent.tsx +197 -0
  43. package/local-registry/src/components/ui/sapient-product-card.tsx +468 -0
  44. package/local-registry/src/components/ui/sapient-profile-card.tsx +193 -0
  45. package/local-registry/src/components/ui/sapient-progress.tsx +32 -0
  46. package/local-registry/src/components/ui/sapient-promo-card.tsx +247 -0
  47. package/local-registry/src/components/ui/sapient-radio-button.tsx +82 -0
  48. package/local-registry/src/components/ui/sapient-radio-group.tsx +54 -0
  49. package/local-registry/src/components/ui/sapient-separator.tsx +28 -0
  50. package/local-registry/src/components/ui/sapient-switch.tsx +36 -0
  51. package/local-registry/src/components/ui/sapient-tabs.tsx +82 -0
  52. package/local-registry/src/components/ui/sapient-textarea.tsx +23 -0
  53. package/local-registry/src/components/ui/sapient-video-card.tsx +159 -0
  54. package/local-registry/src/components/ui/sapient-video-controller.tsx +214 -0
  55. package/package.json +25 -0
@@ -0,0 +1,468 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { cn } from '@/lib/utils';
5
+ import { SapientIcon } from '@/components/ui/sapient-icon';
6
+
7
+ // ─── Icon helper ─────────────────────────────────────────────────────────────
8
+ function Icon({
9
+ name,
10
+ size = 24,
11
+ className,
12
+ alt = '',
13
+ }: {
14
+ name: string;
15
+ size?: number;
16
+ className?: string;
17
+ alt?: string;
18
+ }) {
19
+ return (
20
+ <SapientIcon
21
+ name={name}
22
+ label={alt}
23
+ size={size}
24
+ className={className}
25
+ />
26
+ );
27
+ }
28
+
29
+ // ─── Badge ────────────────────────────────────────────────────────────────────
30
+ function Badge({ label = 'Label' }: { label?: string }) {
31
+ return (
32
+ <div className="inline-flex items-center gap-[var(--spacing-xs4,4px)] h-5 px-[var(--spacing-xs2,8px)] rounded-[var(--radius-token-full,100px)] bg-[hsl(var(--secondary-600))]">
33
+ <span className="text-[length:var(--text-label,12px)] leading-[var(--leading-label,18px)] font-[var(--font-weight-regular,400)] text-white whitespace-nowrap">
34
+ {label}
35
+ </span>
36
+ </div>
37
+ );
38
+ }
39
+
40
+ // ─── Price row ────────────────────────────────────────────────────────────────
41
+ function PriceRow({
42
+ price = '1.234€',
43
+ showInfo = true,
44
+ inverted = false,
45
+ }: {
46
+ price?: string;
47
+ showInfo?: boolean;
48
+ inverted?: boolean;
49
+ }) {
50
+ return (
51
+ <div className="flex items-center gap-[var(--spacing-xs3,6px)] w-full">
52
+ <span
53
+ className={cn(
54
+ 'text-[length:var(--text-subheading,20px)] leading-[var(--leading-subheading,24px)] font-[var(--font-weight-medium,500)] whitespace-nowrap',
55
+ inverted ? 'text-white' : 'text-[hsl(var(--neutral-950))]'
56
+ )}
57
+ >
58
+ {price}
59
+ </span>
60
+ {showInfo && (
61
+ <Icon
62
+ name="info-circle"
63
+ size={24}
64
+ alt="Price info"
65
+ className={cn(
66
+ 'opacity-70',
67
+ inverted ? 'brightness-0 invert' : ''
68
+ )}
69
+ />
70
+ )}
71
+ </div>
72
+ );
73
+ }
74
+
75
+ // ─── Expand/Zoom icon button ──────────────────────────────────────────────────
76
+ function ZoomButton({ inverted = false }: { inverted?: boolean }) {
77
+ return (
78
+ <button
79
+ type="button"
80
+ aria-label="Expand"
81
+ className={cn(
82
+ 'inline-flex items-center justify-center shrink-0 size-[var(--sizing-s,40px)] rounded-[var(--radius-token-full,100px)] border backdrop-blur-[12px] transition-colors',
83
+ inverted
84
+ ? 'border-white bg-white/10 text-white hover:bg-white/20'
85
+ : 'border-[hsl(var(--neutral-800))] bg-white/10 text-[hsl(var(--neutral-950))] hover:bg-[hsl(var(--state-hover))]'
86
+ )}
87
+ >
88
+ <Icon name="expand-05" size={24} alt="Expand" className={cn(inverted ? 'brightness-0 invert' : '')} />
89
+ </button>
90
+ );
91
+ }
92
+
93
+ // ─── Card image ───────────────────────────────────────────────────────────────
94
+ function CardImage({
95
+ src,
96
+ alt = '',
97
+ aspectClass,
98
+ className,
99
+ }: {
100
+ src: string;
101
+ alt?: string;
102
+ aspectClass?: string;
103
+ className?: string;
104
+ }) {
105
+ return (
106
+ <div className={cn('relative w-full overflow-hidden', aspectClass, className)}>
107
+ <img
108
+ src={src}
109
+ alt={alt}
110
+ className="absolute inset-0 w-full h-full object-cover"
111
+ />
112
+ </div>
113
+ );
114
+ }
115
+
116
+ // ─── Types ────────────────────────────────────────────────────────────────────
117
+
118
+ export type ImageRatio = '4:5' | '4:3' | '16:9';
119
+
120
+ /** Aspect-ratio utility classes keyed by ratio */
121
+ const ASPECT_CLASS: Record<ImageRatio, string> = {
122
+ '16:9': 'aspect-video', // 16/9
123
+ '4:3': 'aspect-[4/3]',
124
+ '4:5': 'aspect-[4/5]',
125
+ };
126
+
127
+ /** Card width per ratio (mirrors Figma) */
128
+ const CARD_WIDTH: Record<ImageRatio, string> = {
129
+ '16:9': 'w-[353px]',
130
+ '4:3': 'w-[353px]',
131
+ '4:5': 'w-[353px]',
132
+ };
133
+
134
+ // ─── Variant A: No image (info card) ─────────────────────────────────────────
135
+
136
+ export interface ProductCardNoImageProps {
137
+ className?: string;
138
+ headline?: string;
139
+ body?: string;
140
+ price?: string;
141
+ badgeLabel?: string;
142
+ imageRatio?: ImageRatio;
143
+ showBadge?: boolean;
144
+ showBody?: boolean;
145
+ showPrice?: boolean;
146
+ showPriceInfo?: boolean;
147
+ /** When false renders without background (ghost) */
148
+ withBackground?: boolean;
149
+ }
150
+
151
+ /**
152
+ * **Product Card – No Image**
153
+ *
154
+ * Displays product info text and price without a hero image.
155
+ * Supports 4:5, 4:3, and 16:9 layouts.
156
+ */
157
+ export function ProductCardNoImage({
158
+ className,
159
+ headline = 'Headline lorem ipsum',
160
+ body = 'Body lorem ipsum dolor sit amet, consectetur adipiscing',
161
+ price = '1.234€',
162
+ badgeLabel = 'Label',
163
+ imageRatio = '4:3',
164
+ showBadge = true,
165
+ showBody = true,
166
+ showPrice = true,
167
+ showPriceInfo = true,
168
+ withBackground = true,
169
+ }: ProductCardNoImageProps) {
170
+ const is45 = imageRatio === '4:5';
171
+
172
+ return (
173
+ <div
174
+ className={cn(
175
+ 'flex flex-col items-start p-[var(--spacing-l,24px)] rounded-[var(--radius-token-lg,32px)] relative',
176
+ CARD_WIDTH[imageRatio],
177
+ withBackground && 'bg-white',
178
+ // height
179
+ is45 ? 'h-[442px] justify-between' : 'gap-[var(--spacing-l,24px)]',
180
+ imageRatio === '4:3' && 'h-[283px] justify-between',
181
+ className
182
+ )}
183
+ >
184
+ {/* Content up */}
185
+ <div
186
+ className={cn(
187
+ 'flex flex-col items-start w-full',
188
+ is45 ? 'gap-[var(--spacing-m,16px)] shrink-0' : 'gap-[var(--spacing-l,24px)] flex-1 min-h-0'
189
+ )}
190
+ >
191
+ {showBadge && <Badge label={badgeLabel} />}
192
+
193
+ {/* Heading + body – only for 4:3 and 16:9 */}
194
+ {!is45 && (
195
+ <div className="flex flex-col gap-[var(--spacing-xs2,8px)] items-start w-full">
196
+ <p className="text-[length:var(--text-subheading,20px)] leading-[var(--leading-subheading,24px)] font-[var(--font-weight-medium,500)] text-[hsl(var(--neutral-950))] w-full overflow-hidden text-ellipsis whitespace-pre-wrap">
197
+ {headline}
198
+ </p>
199
+ {showBody && (
200
+ <p className="text-[length:var(--text-body-small,14px)] leading-[var(--leading-body-small,22px)] font-[var(--font-weight-regular,400)] text-[hsl(var(--neutral-600))] w-full overflow-hidden text-ellipsis whitespace-pre-wrap">
201
+ {body}
202
+ </p>
203
+ )}
204
+ </div>
205
+ )}
206
+
207
+ {/* Headline only for 4:5 */}
208
+ {is45 && (
209
+ <p className="text-[length:var(--text-subheading,20px)] leading-[var(--leading-subheading,24px)] font-[var(--font-weight-medium,500)] text-[hsl(var(--neutral-950))] w-full overflow-hidden text-ellipsis whitespace-pre-wrap">
210
+ {headline}
211
+ </p>
212
+ )}
213
+ </div>
214
+
215
+ {/* Price */}
216
+ {showPrice && (
217
+ <PriceRow price={price} showInfo={showPriceInfo} />
218
+ )}
219
+
220
+ {/* Icon button – top-right (only 4:3, 16:9) */}
221
+ {!is45 && (
222
+ <div className="absolute top-4 right-4">
223
+ <ZoomButton />
224
+ </div>
225
+ )}
226
+ </div>
227
+ );
228
+ }
229
+
230
+ // ─── Variant B: Full image (image fills the whole card) ───────────────────────
231
+
232
+ export interface ProductCardFullImageProps {
233
+ className?: string;
234
+ imageSrc: string;
235
+ imageAlt?: string;
236
+ headline?: string;
237
+ body?: string;
238
+ price?: string;
239
+ badgeLabel?: string;
240
+ imageRatio?: ImageRatio;
241
+ showBadge?: boolean;
242
+ showBody?: boolean;
243
+ showPrice?: boolean;
244
+ showPriceInfo?: boolean;
245
+ showZoomButton?: boolean;
246
+ state?: 'enabled' | 'hover';
247
+ }
248
+
249
+ /**
250
+ * **Product Card – Full Image**
251
+ *
252
+ * Hero image fills the entire card. Text is overlaid at the bottom.
253
+ * Supports 4:5, 4:3, and 16:9 ratios.
254
+ */
255
+ export function ProductCardFullImage({
256
+ className,
257
+ imageSrc,
258
+ imageAlt = '',
259
+ headline = 'Headline lorem ipsum',
260
+ body = 'Body lorem ipsum dolor sit amet, consectetur',
261
+ price = '1.234€',
262
+ badgeLabel = 'Label',
263
+ imageRatio = '4:5',
264
+ showBadge = true,
265
+ showBody = true,
266
+ showPrice = true,
267
+ showPriceInfo = true,
268
+ showZoomButton = true,
269
+ state = 'enabled',
270
+ }: ProductCardFullImageProps) {
271
+ const is169 = imageRatio === '16:9';
272
+ const is45 = imageRatio === '4:5';
273
+ const showTextOverlay = is45 || imageRatio === '4:3';
274
+
275
+ return (
276
+ <div
277
+ className={cn(
278
+ 'relative flex flex-col items-start overflow-hidden rounded-[var(--radius-token-lg,32px)]',
279
+ CARD_WIDTH[imageRatio],
280
+ ASPECT_CLASS[imageRatio],
281
+ className
282
+ )}
283
+ >
284
+ {/* Full-bleed image */}
285
+ <img
286
+ src={imageSrc}
287
+ alt={imageAlt}
288
+ className="absolute inset-0 w-full h-full object-cover"
289
+ />
290
+
291
+ {/* Top gradient + badge/zoom overlay */}
292
+ <div
293
+ className={cn(
294
+ 'absolute inset-x-0 top-0 flex items-start justify-between px-[var(--spacing-l,24px)] pt-[var(--spacing-l,24px)] pb-[var(--spacing-xl,32px)]',
295
+ !is169 && 'bg-gradient-to-b from-black/15 to-transparent'
296
+ )}
297
+ >
298
+ {showBadge && <Badge label={badgeLabel} />}
299
+ {(is45 || imageRatio === '4:3') && showZoomButton && (
300
+ <ZoomButton inverted />
301
+ )}
302
+ </div>
303
+
304
+ {/* Bottom text overlay – 4:5 and 4:3 */}
305
+ {showTextOverlay && (
306
+ <div className="absolute inset-x-0 bottom-0 bg-gradient-to-b from-transparent to-black/30 px-[var(--spacing-l,24px)] pt-[var(--spacing-xl2,40px)] pb-[var(--spacing-l,24px)] flex flex-col gap-[var(--spacing-xs2,8px)]">
307
+ <p
308
+ className={cn(
309
+ 'text-[length:var(--text-subheading,20px)] leading-[var(--leading-subheading,24px)] font-[var(--font-weight-medium,500)] text-white w-full overflow-hidden text-ellipsis whitespace-pre-wrap',
310
+ state === 'hover' && 'text-[hsl(var(--foreground-strong))]'
311
+ )}
312
+ >
313
+ {headline}
314
+ </p>
315
+ {showBody && (
316
+ <p className="text-[length:var(--text-body-small,14px)] leading-[var(--leading-body-small,22px)] font-[var(--font-weight-regular,400)] text-white max-h-[44px] overflow-hidden text-ellipsis whitespace-pre-wrap w-full">
317
+ {body}
318
+ </p>
319
+ )}
320
+ {showPrice && (
321
+ <PriceRow price={price} showInfo={showPriceInfo} inverted />
322
+ )}
323
+ </div>
324
+ )}
325
+
326
+ {/* Bottom text overlay – 16:9 */}
327
+ {is169 && (
328
+ <div className="absolute inset-x-0 bottom-0 bg-gradient-to-b from-transparent to-black/30 px-[var(--spacing-l,24px)] pt-[var(--spacing-xl2,40px)] pb-[var(--spacing-l,24px)] flex flex-col gap-[var(--spacing-xs2,8px)]">
329
+ <p
330
+ className={cn(
331
+ 'text-[length:var(--text-subheading,20px)] leading-[var(--leading-subheading,24px)] font-[var(--font-weight-medium,500)] text-white w-full overflow-hidden text-ellipsis whitespace-pre-wrap',
332
+ state === 'hover' && 'text-[hsl(var(--foreground-strong))]'
333
+ )}
334
+ >
335
+ {headline}
336
+ </p>
337
+ {showPrice && (
338
+ <PriceRow price={price} showInfo={showPriceInfo} inverted />
339
+ )}
340
+ </div>
341
+ )}
342
+ </div>
343
+ );
344
+ }
345
+
346
+ // ─── Variant C: Split content (image top + text bottom) ───────────────────────
347
+
348
+ export interface ProductCardSplitProps {
349
+ className?: string;
350
+ imageSrc: string;
351
+ imageAlt?: string;
352
+ headline?: string;
353
+ body?: string;
354
+ price?: string;
355
+ badgeLabel?: string;
356
+ showBadge?: boolean;
357
+ showBody?: boolean;
358
+ showImage?: boolean;
359
+ showPrice?: boolean;
360
+ showPriceInfo?: boolean;
361
+ showZoomButton?: boolean;
362
+ /** Wraps content in a white card background */
363
+ withBackground?: boolean;
364
+ state?: 'enabled' | 'hover';
365
+ }
366
+
367
+ /**
368
+ * **Product Card – Split Content**
369
+ *
370
+ * Image on top, text panel below. Comes in two layouts:
371
+ * - `withBackground=true` → rounded card with white text panel
372
+ * - `withBackground=false` → transparent, text directly below image
373
+ */
374
+ export function ProductCardSplit({
375
+ className,
376
+ imageSrc,
377
+ imageAlt = '',
378
+ headline = 'Headline lorem ipsum dolor sit amet, consectetur adipiscing elit',
379
+ body = 'Body lorem ipsum dolor sit amet, consectetur adipiscing elit',
380
+ price = '1.234€',
381
+ badgeLabel = 'Label',
382
+ showBadge = true,
383
+ showBody = true,
384
+ showImage = true,
385
+ showPrice = true,
386
+ showPriceInfo = true,
387
+ showZoomButton = true,
388
+ withBackground = true,
389
+ state = 'enabled',
390
+ }: ProductCardSplitProps) {
391
+ const isHover = state === 'hover';
392
+
393
+ return (
394
+ <div
395
+ className={cn(
396
+ 'flex flex-col items-start w-[353px] relative',
397
+ withBackground && 'overflow-hidden rounded-[var(--radius-token-lg,32px)]',
398
+ className
399
+ )}
400
+ >
401
+ {/* Image section */}
402
+ {showImage && (
403
+ <div
404
+ className={cn(
405
+ 'relative w-full overflow-hidden shrink-0',
406
+ withBackground
407
+ ? 'rounded-tl-[var(--radius-token-lg,32px)] rounded-tr-[var(--radius-token-lg,32px)]'
408
+ : 'rounded-[var(--radius-token-lg,32px)]',
409
+ 'aspect-[4/3]'
410
+ )}
411
+ >
412
+ <img
413
+ src={imageSrc}
414
+ alt={imageAlt}
415
+ className="absolute inset-0 w-full h-full object-cover"
416
+ />
417
+
418
+ {/* Top overlay: badge + zoom */}
419
+ <div className="absolute inset-x-0 top-0 flex items-start justify-between px-[var(--spacing-l,24px)] pt-[var(--spacing-l,24px)] pb-[var(--spacing-xl,32px)] bg-gradient-to-b from-black/15 to-transparent">
420
+ {showBadge && <Badge label={badgeLabel} />}
421
+ {showZoomButton && <ZoomButton inverted />}
422
+ </div>
423
+ </div>
424
+ )}
425
+
426
+ {/* Text section */}
427
+ <div
428
+ className={cn(
429
+ 'flex flex-col gap-[var(--spacing-xs2,8px)] items-start w-full shrink-0',
430
+ withBackground
431
+ ? 'bg-white p-[var(--spacing-l,24px)]'
432
+ : 'py-[var(--spacing-l,24px)]'
433
+ )}
434
+ >
435
+ <p
436
+ className={cn(
437
+ 'text-[length:var(--text-subheading,20px)] leading-[var(--leading-subheading,24px)] font-[var(--font-weight-medium,500)] w-full overflow-hidden text-ellipsis whitespace-pre-wrap',
438
+ isHover
439
+ ? 'text-[hsl(var(--foreground-strong))]'
440
+ : 'text-[hsl(var(--neutral-950))]'
441
+ )}
442
+ >
443
+ {headline}
444
+ </p>
445
+
446
+ {showBody && (
447
+ <p className="text-[length:var(--text-body-small,14px)] leading-[var(--leading-body-small,22px)] font-[var(--font-weight-regular,400)] text-[hsl(var(--neutral-600))] max-h-[44px] overflow-hidden text-ellipsis whitespace-pre-wrap w-full">
448
+ {body}
449
+ </p>
450
+ )}
451
+
452
+ {showPrice && (
453
+ <PriceRow price={price} showInfo={showPriceInfo} />
454
+ )}
455
+ </div>
456
+ </div>
457
+ );
458
+ }
459
+
460
+ // ─── Combined export (convenience) ────────────────────────────────────────────
461
+
462
+ export const ProductCard = {
463
+ NoImage: ProductCardNoImage,
464
+ FullImage: ProductCardFullImage,
465
+ Split: ProductCardSplit,
466
+ };
467
+
468
+ export default ProductCard;
@@ -0,0 +1,193 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { cn } from '@/lib/utils';
5
+ import { SapientIcon } from '@/components/ui/sapient-icon';
6
+
7
+ // ─── Types ────────────────────────────────────────────────────────────────────
8
+
9
+ /**
10
+ * L – Full-bleed portrait photo card (353 × 441 px), white text overlaid at bottom.
11
+ * M – White card with circular avatar, dark text name + contacts below.
12
+ * S – White card, no photo, name + contacts only (compact).
13
+ */
14
+ export type ProfileCardSize = 'L' | 'M' | 'S';
15
+
16
+ export interface ProfileCardProps {
17
+ className?: string;
18
+ size?: ProfileCardSize;
19
+ /** First name (L/M show on separate line from surname) */
20
+ firstName?: string;
21
+ /** Last name */
22
+ lastName?: string;
23
+ /** Full name string – used by S (single line) */
24
+ fullName?: string;
25
+ email?: string;
26
+ phone?: string;
27
+ address?: string;
28
+ /** Photo URL. Used in L (full-bleed) and M (circular avatar). */
29
+ imageSrc?: string;
30
+ imageAlt?: string;
31
+ }
32
+
33
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
34
+
35
+ const PLACEHOLDER_PHOTO =
36
+ 'https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=800&q=80';
37
+
38
+ function ContactRow({
39
+ iconName,
40
+ label,
41
+ inverted = false,
42
+ alignTop = false,
43
+ }: {
44
+ iconName: string;
45
+ label: string;
46
+ inverted?: boolean;
47
+ alignTop?: boolean;
48
+ }) {
49
+ return (
50
+ <div
51
+ className={cn(
52
+ 'flex gap-[var(--spacing-xs2,8px)] w-full shrink-0',
53
+ alignTop ? 'items-start' : 'items-center'
54
+ )}
55
+ >
56
+ <SapientIcon
57
+ name={iconName}
58
+ label=""
59
+ size={24}
60
+ className={cn(
61
+ 'size-6 shrink-0',
62
+ inverted && 'brightness-0 invert'
63
+ )}
64
+ />
65
+ <p
66
+ className={cn(
67
+ 'text-[length:var(--text-body,16px)] leading-[1.4] font-[var(--font-weight-regular,400)] shrink-0',
68
+ inverted
69
+ ? 'text-white'
70
+ : 'text-[hsl(var(--neutral-950))]'
71
+ )}
72
+ >
73
+ {label}
74
+ </p>
75
+ </div>
76
+ );
77
+ }
78
+
79
+ // ─── ProfileCard ──────────────────────────────────────────────────────────────
80
+
81
+ export function ProfileCard({
82
+ className,
83
+ size = 'L',
84
+ firstName = 'Name',
85
+ lastName = 'Surname',
86
+ fullName = 'Name Surname',
87
+ email = 'name.surname@email.com',
88
+ phone = '+00 123 456 7890',
89
+ address = 'Lorem Ipsum Street, 20154, Milan, Italy',
90
+ imageSrc = PLACEHOLDER_PHOTO,
91
+ imageAlt = '',
92
+ }: ProfileCardProps) {
93
+
94
+ // ── Size L ───────────────────────────────────────────────────────────────
95
+ if (size === 'L') {
96
+ return (
97
+ <div
98
+ className={cn(
99
+ 'relative w-[353px] h-[441px] overflow-hidden rounded-[var(--radius-token-lg,32px)] flex flex-col justify-end',
100
+ className
101
+ )}
102
+ >
103
+ {/* Full-bleed photo */}
104
+ <img
105
+ src={imageSrc}
106
+ alt={imageAlt}
107
+ className="absolute inset-0 w-full h-full object-cover"
108
+ />
109
+
110
+ {/* Bottom gradient scrim */}
111
+ <div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-black/70 pointer-events-none" />
112
+
113
+ {/* Content */}
114
+ <div className="relative z-10 flex flex-col gap-[var(--spacing-l,24px)] p-[var(--spacing-l,24px)]">
115
+ {/* Name */}
116
+ <p className="text-[length:var(--text-display,32px)] leading-[1.2] font-[var(--font-weight-medium,500)] tracking-[0.32px] text-white whitespace-pre-wrap">
117
+ {firstName}{'\n'}{lastName}
118
+ </p>
119
+
120
+ {/* Contact rows */}
121
+ <div className="flex flex-col gap-[var(--spacing-m,16px)]">
122
+ <ContactRow iconName="mail-01" label={email} inverted />
123
+ <ContactRow iconName="phone" label={phone} inverted alignTop />
124
+ <ContactRow iconName="home-02" label={address} inverted alignTop />
125
+ </div>
126
+ </div>
127
+ </div>
128
+ );
129
+ }
130
+
131
+ // ── Size M ───────────────────────────────────────────────────────────────
132
+ if (size === 'M') {
133
+ return (
134
+ <div
135
+ className={cn(
136
+ 'flex flex-col items-start gap-[var(--spacing-l,24px)] p-[var(--spacing-l,24px)] w-[353px] bg-[hsl(var(--neutral-50))] rounded-[var(--radius-token-lg,32px)]',
137
+ className
138
+ )}
139
+ >
140
+ {/* Circular avatar */}
141
+ <div className="relative size-[124px] rounded-full overflow-hidden shrink-0">
142
+ <img
143
+ src={imageSrc}
144
+ alt={imageAlt}
145
+ className="absolute inset-0 w-full h-full object-cover"
146
+ />
147
+ </div>
148
+
149
+ {/* Name */}
150
+ <p className="text-[length:var(--text-display,32px)] leading-[1.2] font-[var(--font-weight-medium,500)] text-[hsl(var(--neutral-950))] whitespace-pre-wrap min-w-full">
151
+ {firstName}{'\n'}{lastName}
152
+ </p>
153
+
154
+ {/* Contact rows */}
155
+ <div className="flex flex-col gap-[var(--spacing-m,16px)] w-full">
156
+ <ContactRow iconName="mail-01" label={email} />
157
+ <ContactRow iconName="phone" label={phone} alignTop />
158
+ <ContactRow iconName="home-02" label={address} alignTop />
159
+ </div>
160
+ </div>
161
+ );
162
+ }
163
+
164
+ // ── Size S ───────────────────────────────────────────────────────────────
165
+ return (
166
+ <div
167
+ className={cn(
168
+ 'flex flex-col items-start gap-[var(--spacing-l,24px)] p-[var(--spacing-l,24px)] w-[353px] bg-[hsl(var(--neutral-50))] rounded-[var(--radius-token-lg,32px)]',
169
+ className
170
+ )}
171
+ >
172
+ {/* Name — single line for S */}
173
+ <p className="text-[length:var(--text-heading,24px)] leading-[1.2] font-[var(--font-weight-medium,500)] tracking-[-0.48px] text-[hsl(var(--neutral-950))] w-[305px] whitespace-pre-wrap">
174
+ {fullName}
175
+ </p>
176
+
177
+ {/* Contact rows */}
178
+ <div className="flex flex-col gap-[12px] w-full">
179
+ <ContactRow iconName="mail-01" label={email} />
180
+ <ContactRow iconName="phone" label={phone} alignTop />
181
+ <ContactRow iconName="home-02" label={address} alignTop />
182
+ </div>
183
+ </div>
184
+ );
185
+ }
186
+
187
+ export const ProfileCardComponent = {
188
+ L: (props: Omit<ProfileCardProps, 'size'>) => <ProfileCard {...props} size="L" />,
189
+ M: (props: Omit<ProfileCardProps, 'size'>) => <ProfileCard {...props} size="M" />,
190
+ S: (props: Omit<ProfileCardProps, 'size'>) => <ProfileCard {...props} size="S" />,
191
+ };
192
+
193
+ export default ProfileCard;
@@ -0,0 +1,32 @@
1
+ import * as React from "react";
2
+ import * as ProgressPrimitive from "@radix-ui/react-progress";
3
+ import { cn } from "@/lib/utils";
4
+
5
+ export interface SapientProgressProps
6
+ extends React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> {
7
+ value?: number;
8
+ }
9
+
10
+ export const SapientProgress = React.forwardRef<
11
+ React.ElementRef<typeof ProgressPrimitive.Root>,
12
+ SapientProgressProps
13
+ >(({ className, value = 0, ...props }, ref) => {
14
+ return (
15
+ <ProgressPrimitive.Root
16
+ ref={ref}
17
+ className={cn(
18
+ "relative h-2 w-full overflow-hidden rounded-full bg-secondary",
19
+ className
20
+ )}
21
+ value={value}
22
+ {...props}
23
+ >
24
+ <ProgressPrimitive.Indicator
25
+ className="h-full w-full flex-1 bg-primary transition-all"
26
+ style={{ transform: `translateX(-${100 - value}%)` }}
27
+ />
28
+ </ProgressPrimitive.Root>
29
+ );
30
+ });
31
+
32
+ SapientProgress.displayName = "SapientProgress";