@linktr.ee/messaging-react 2.0.1 → 2.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 (64) hide show
  1. package/dist/Card-CFFNq49v.js +163 -0
  2. package/dist/Card-CFFNq49v.js.map +1 -0
  3. package/dist/Card-CsJvUF_b.js +107 -0
  4. package/dist/Card-CsJvUF_b.js.map +1 -0
  5. package/dist/Card-D32U6KfZ.js +85 -0
  6. package/dist/Card-D32U6KfZ.js.map +1 -0
  7. package/dist/Card-DlMSDSdm.js +132 -0
  8. package/dist/Card-DlMSDSdm.js.map +1 -0
  9. package/dist/Card-DlSSJPip.js +60 -0
  10. package/dist/Card-DlSSJPip.js.map +1 -0
  11. package/dist/Card-zGbhRBwv.js +48 -0
  12. package/dist/Card-zGbhRBwv.js.map +1 -0
  13. package/dist/CardThumbnail-DTBuRQHF.js +239 -0
  14. package/dist/CardThumbnail-DTBuRQHF.js.map +1 -0
  15. package/dist/LockedThumbnail-DpJx169C.js +220 -0
  16. package/dist/LockedThumbnail-DpJx169C.js.map +1 -0
  17. package/dist/{index-Bex7eg3v.js → index-DfcRe-Hj.js} +618 -607
  18. package/dist/index-DfcRe-Hj.js.map +1 -0
  19. package/dist/index.d.ts +217 -28
  20. package/dist/index.js +16 -15
  21. package/package.json +1 -1
  22. package/src/components/CustomMessage/index.tsx +2 -3
  23. package/src/components/LinkAttachment/LinkAttachment.stories.tsx +307 -0
  24. package/src/components/LinkAttachment/components/Composer/Card.tsx +117 -0
  25. package/src/components/LinkAttachment/components/Composer/index.ts +2 -0
  26. package/src/components/LinkAttachment/components/Received/Card.tsx +132 -0
  27. package/src/components/LinkAttachment/components/Received/index.ts +2 -0
  28. package/src/components/LinkAttachment/components/Sent/Card.tsx +57 -0
  29. package/src/components/LinkAttachment/components/Sent/index.ts +2 -0
  30. package/src/components/LinkAttachment/components/_shared/CardBody.tsx +117 -0
  31. package/src/components/LinkAttachment/components/_shared/CardCta.tsx +69 -0
  32. package/src/components/LinkAttachment/components/_shared/CardShell.tsx +120 -0
  33. package/src/components/LinkAttachment/components/_shared/CardThumbnail.tsx +156 -0
  34. package/src/components/LinkAttachment/components/_shared/normalizeExternalHref.ts +56 -0
  35. package/src/components/LinkAttachment/index.tsx +68 -0
  36. package/src/components/LinkAttachment/types.ts +69 -0
  37. package/src/components/LockedAttachment/LockedAttachment.stories.tsx +230 -89
  38. package/src/components/LockedAttachment/components/Composer/Card.tsx +221 -0
  39. package/src/components/LockedAttachment/components/Composer/index.ts +2 -0
  40. package/src/components/LockedAttachment/components/Received/Card.tsx +191 -0
  41. package/src/components/LockedAttachment/components/Received/CardActions.tsx +91 -0
  42. package/src/components/LockedAttachment/components/Received/index.ts +2 -0
  43. package/src/components/LockedAttachment/components/Sent/Card.tsx +177 -0
  44. package/src/components/LockedAttachment/components/Sent/index.ts +2 -0
  45. package/src/components/LockedAttachment/components/_shared/CardBody.tsx +94 -0
  46. package/src/components/LockedAttachment/components/_shared/GalleryThumbnail.tsx +178 -0
  47. package/src/components/LockedAttachment/components/_shared/LockBadge.tsx +39 -0
  48. package/src/components/LockedAttachment/components/_shared/LockedCardShell.tsx +36 -0
  49. package/src/components/LockedAttachment/components/_shared/LockedThumbnail.tsx +128 -0
  50. package/src/components/LockedAttachment/index.tsx +43 -12
  51. package/src/components/LockedAttachment/types.ts +17 -0
  52. package/src/components/MediaMessage/index.tsx +8 -2
  53. package/src/index.ts +15 -1
  54. package/dist/Card-BKP9ml9O.js +0 -138
  55. package/dist/Card-BKP9ml9O.js.map +0 -1
  56. package/dist/Card-Bk_4lVzP.js +0 -127
  57. package/dist/Card-Bk_4lVzP.js.map +0 -1
  58. package/dist/index-Bex7eg3v.js.map +0 -1
  59. package/src/components/LockedAttachment/components/Creator/Card.tsx +0 -210
  60. package/src/components/LockedAttachment/components/Creator/index.tsx +0 -2
  61. package/src/components/LockedAttachment/components/Visitor/Card.tsx +0 -155
  62. package/src/components/LockedAttachment/components/Visitor/CardActions.tsx +0 -62
  63. package/src/components/LockedAttachment/components/Visitor/LockBadge.tsx +0 -12
  64. package/src/components/LockedAttachment/components/Visitor/index.ts +0 -2
package/dist/index.d.ts CHANGED
@@ -241,15 +241,38 @@ export declare interface ChannelViewProps {
241
241
  sendButton?: ComponentType<any>;
242
242
  }
243
243
 
244
- export declare interface CreatorCardProps extends LockedAttachmentBaseProps {
244
+ export declare interface ComposerCardProps extends LockedAttachmentBaseProps {
245
+ /** Placeholder shown in the title slot before the composer types one. */
245
246
  placeholderTitle?: string;
247
+ /** Placeholder shown in the amount slot before one is configured. */
246
248
  placeholderAmountText?: string;
247
- isUnlocking?: boolean;
249
+ /**
250
+ * When provided, renders a dismiss X in the thumbnail corner. Called when
251
+ * the composer clicks it to remove the attachment.
252
+ */
248
253
  onDismiss?: () => void;
254
+ /** Fired the first time the composer taps the thumbnail to preview. */
249
255
  onPreviewClick?: () => void;
256
+ /**
257
+ * Lazily loads the underlying source so the composer can preview the
258
+ * attachment they're about to send. Called the first time the thumbnail is
259
+ * tapped; the returned source is cached and reused on subsequent toggles.
260
+ */
250
261
  onFetchSource?: () => Promise<LockedAttachmentSource | void>;
262
+ /**
263
+ * When provided, renders a pencil button in the body bottom-right that the
264
+ * composer can use to edit the attachment metadata (e.g. open the price /
265
+ * gallery editor). Matches the Composer "Button" instance in Figma.
266
+ */
267
+ onEditClick?: () => void;
251
268
  }
252
269
 
270
+ /**
271
+ * @deprecated Renamed to `SentCardProps`. Drafting usages (with `onDismiss`)
272
+ * should migrate to `ComposerCardProps`.
273
+ */
274
+ export declare type CreatorCardProps = SentCardProps;
275
+
253
276
  export declare const CustomMessageProvider: Provider<Partial<CustomMessageRegistry>>;
254
277
 
255
278
  export declare interface CustomMessageRegistry {
@@ -300,9 +323,142 @@ export declare function getMessageDisplayText({ message, viewerLanguage, }: {
300
323
 
301
324
  export declare function isLinkAttachment(a: Attachment): boolean;
302
325
 
326
+ /**
327
+ * Link attachments (image / file media + 1P/3P Link Apps) shown in the chat
328
+ * thread. Mirrors the `LockedAttachment` API — render `LinkAttachment.Composer`
329
+ * while drafting, `LinkAttachment.Sent` after posting, and
330
+ * `LinkAttachment.Received` in the recipient's thread. Maps to the
331
+ * "Attachments" and "LinkApps" sections of the messaging design system.
332
+ *
333
+ * Two visual layouts via the `layout` prop:
334
+ * - **Featured** (default) — 180px hero thumbnail above the body. Used by
335
+ * image / file Attachments and by hero-image LinkApps (Spotify with
336
+ * cover art, TikTok with a frame, etc.).
337
+ * - **Classic** — compact card with no hero thumbnail; title /
338
+ * description / URL / CTA only. Used for LinkApp embeds without
339
+ * artwork (FAQ, Form) and any link preview that lacks OG imagery.
340
+ *
341
+ * Image / file Attachments use `layout="featured"` and skip the title /
342
+ * description / URL body entirely (`CardBody` collapses to nothing when no
343
+ * text content is provided). LinkApps always carry a title + description
344
+ * and prefix the title with an `appIcon` brand badge.
345
+ */
346
+ export declare const LinkAttachment: {
347
+ Composer: (props: LinkAttachmentComposerCardProps) => JSX_2.Element;
348
+ Sent: (props: LinkAttachmentSentCardProps) => JSX_2.Element;
349
+ Received: (props: LinkAttachmentReceivedCardProps) => JSX_2.Element;
350
+ };
351
+
352
+ /**
353
+ * Shared props for the three `LinkAttachment.*` variants (Composer, Sent,
354
+ * Received). Maps to the "Attachments" + "LinkApps" sections of the messaging
355
+ * design system in Figma — a 280px-wide card with an optional 180px hero
356
+ * thumbnail, a title (optionally prefixed with a brand badge for Link Apps),
357
+ * a description, and either a URL footer or a CTA button.
358
+ */
359
+ export declare interface LinkAttachmentBaseProps {
360
+ title?: string;
361
+ /** Placeholder shown in the title slot before one is configured (dark variants only). */
362
+ placeholderTitle?: string;
363
+ /** Secondary description rendered below the title. */
364
+ description?: string;
365
+ /**
366
+ * Optional URL displayed at the bottom of the card (e.g. `tr.ee/briemix`).
367
+ * Ignored when `cta` is provided. Also used as the navigation target for
368
+ * the Received card when no `cta` is set.
369
+ */
370
+ url?: string;
371
+ /** MIME type of the hero thumbnail — drives the type icon for empty states. */
372
+ mimeType?: string;
373
+ /** Hero thumbnail (180px tall) shown above the title block. */
374
+ thumbnailUrl?: string;
375
+ /**
376
+ * Source URL for playable media (video, audio). When provided alongside a
377
+ * video / audio `mimeType`, the hero region renders an inline player with
378
+ * native controls instead of the static thumbnail / type-icon.
379
+ */
380
+ sourceUrl?: string;
381
+ /**
382
+ * Visual layout — `'featured'` keeps the 180px hero thumbnail above the
383
+ * body, `'classic'` drops the hero entirely for a compact text-only
384
+ * card. Defaults to `'featured'`.
385
+ */
386
+ layout?: LinkAttachmentLayout;
387
+ /**
388
+ * Optional 16x16 brand badge rendered before the title (used by Link Apps:
389
+ * Spotify, TikTok, FAQ, Form, etc.). Consumers render whatever they want
390
+ * — typically a colored 4px-rounded square containing a glyph or `<img>`.
391
+ */
392
+ appIcon?: default_2.ReactNode;
393
+ /**
394
+ * Optional call-to-action rendered below the description. When set,
395
+ * replaces the URL footer (e.g. FAQ "View FAQs", Form "Complete form").
396
+ */
397
+ cta?: LinkAttachmentCta;
398
+ }
399
+
400
+ export declare interface LinkAttachmentComposerCardProps extends LinkAttachmentBaseProps {
401
+ /**
402
+ * When provided, renders a dismiss X in the thumbnail corner. Called when
403
+ * the composer clicks it to remove the attachment.
404
+ */
405
+ onDismiss?: () => void;
406
+ /**
407
+ * When provided, renders a pencil button to the right of the description
408
+ * that the composer can use to edit the attachment metadata.
409
+ */
410
+ onEditClick?: () => void;
411
+ }
412
+
413
+ export declare interface LinkAttachmentCta {
414
+ label: string;
415
+ /** When set, the CTA renders as an `<a>` opening in a new tab. */
416
+ href?: string;
417
+ /** When set, called on click (in addition to `href` navigation if provided). */
418
+ onClick?: () => void;
419
+ }
420
+
421
+ /**
422
+ * Visual layout for a `LinkAttachment.*` card.
423
+ *
424
+ * - `featured` — full card with a 180px hero thumbnail above the body.
425
+ * The default; matches the "Attachments" frames and the hero-image
426
+ * "LinkApps" frames in Figma.
427
+ * - `classic` — compact card without a hero thumbnail. Title /
428
+ * description / URL / CTA only. Used for Link App embeds that don't
429
+ * carry artwork (and for plain link previews without OG imagery).
430
+ */
431
+ export declare type LinkAttachmentLayout = 'featured' | 'classic';
432
+
433
+ export declare interface LinkAttachmentReceivedCardProps extends LinkAttachmentBaseProps {
434
+ /**
435
+ * Fired when the recipient activates the card. Behavior depends on how
436
+ * the card is configured:
437
+ * - **Link app with a CTA** (FAQ / Form): the CTA owns navigation;
438
+ * `onClick` fires when the recipient taps the CTA itself, alongside
439
+ * `cta.onClick` (use for analytics).
440
+ * - **Link app with a URL** (Spotify / TikTok / generic link): the card
441
+ * chrome is an `<a target="_blank">` opening `url` — `onClick` fires
442
+ * alongside the navigation (use for analytics).
443
+ * - **Image / file / placeholder attachment**: the card has no URL, so
444
+ * it renders as a button. `onClick` is the consumer's hook for
445
+ * opening an image / file preview (lightbox).
446
+ * - **Video / audio attachment**: the shell stays non-interactive so
447
+ * the native media controls remain operable — `onClick` is ignored
448
+ * in this configuration.
449
+ */
450
+ onClick?: () => void;
451
+ }
452
+
453
+ export declare interface LinkAttachmentSentCardProps extends LinkAttachmentBaseProps {
454
+ }
455
+
303
456
  export declare const LockedAttachment: {
304
- Creator: (props: CreatorCardProps) => JSX_2.Element;
305
- Visitor: (props: VisitorCardProps) => JSX_2.Element;
457
+ Composer: (props: ComposerCardProps) => JSX_2.Element;
458
+ Sent: (props: SentCardProps) => JSX_2.Element;
459
+ Received: (props: ReceivedCardProps) => JSX_2.Element;
460
+ Creator: (props: SentCardProps) => JSX_2.Element;
461
+ Visitor: (props: ReceivedCardProps) => JSX_2.Element;
306
462
  };
307
463
 
308
464
  declare interface LockedAttachmentBaseProps {
@@ -312,6 +468,14 @@ declare interface LockedAttachmentBaseProps {
312
468
  detail?: string;
313
469
  amountText?: string;
314
470
  paymentStatus?: PaymentStatus;
471
+ /**
472
+ * When provided with 2+ items, the card renders as a mixed-media carousel
473
+ * (e.g. a couple of photos + a video) instead of a single thumbnail. Each
474
+ * item brings its own thumbnail and optional source so that
475
+ * `LockedAttachment.Composer` / `.Sent` / `.Received` can all share the
476
+ * same carousel chrome.
477
+ */
478
+ gallery?: LockedAttachmentGalleryItem[];
315
479
  }
316
480
 
317
481
  export declare interface LockedAttachmentContextValue {
@@ -321,6 +485,15 @@ export declare interface LockedAttachmentContextValue {
321
485
  onFetchSource?: (message: LocalMessage, channel: Channel) => Promise<LockedAttachmentSource | void>;
322
486
  }
323
487
 
488
+ export declare interface LockedAttachmentGalleryItem {
489
+ /** MIME type of this carousel item — drives the per-item play / lock affordance. */
490
+ mimeType: string;
491
+ /** Poster image used for both the locked (blurred) and unlocked preview state. */
492
+ thumbnailUrl?: string;
493
+ /** Underlying source (image or video URL) — shown only when unlocked. */
494
+ sourceUrl?: string;
495
+ }
496
+
324
497
  export declare interface LockedAttachmentSource {
325
498
  /** Proxied URL used by the media player for in-app playback. */
326
499
  sourceUrl: string;
@@ -501,10 +674,48 @@ export declare interface Participant {
501
674
  */
502
675
  declare type PaymentStatus = 'pending' | 'paid' | 'failed' | 'refunded';
503
676
 
677
+ export declare interface ReceivedCardProps extends LockedAttachmentBaseProps {
678
+ /**
679
+ * Called when the recipient clicks Unlock on an unpaid attachment.
680
+ * Use this to open a checkout flow. Omit to hide the Unlock button.
681
+ */
682
+ onUnlockClick?: () => void;
683
+ /**
684
+ * Called to fetch the attachment source — fired automatically when
685
+ * `paymentStatus` transitions to `'paid'`, or immediately on click when
686
+ * `paymentStatus` is already `'paid'`. Return a `LockedAttachmentSource`
687
+ * to unlock the card.
688
+ */
689
+ onFetchSource?: () => Promise<LockedAttachmentSource | void>;
690
+ /**
691
+ * Called when the recipient clicks Download on an unlocked card.
692
+ * Omit to hide the Download button.
693
+ */
694
+ onDownloadClick?: () => void;
695
+ /**
696
+ * When true, shows a loading spinner on the Unlock button.
697
+ * Driven by the LockedAttachmentContext (e.g. checkout in progress).
698
+ */
699
+ isUnlocking?: boolean;
700
+ }
701
+
504
702
  export declare function resolveLinkAttachment(message: LocalMessage): Attachment | undefined;
505
703
 
506
704
  export declare function resolveMediaFromMessage(message: LocalMessage): MediaMessageResolved | null;
507
705
 
706
+ export declare interface SentCardProps extends LockedAttachmentBaseProps {
707
+ /** Placeholder shown in the title slot when no title is set. */
708
+ placeholderTitle?: string;
709
+ /** Fired the first time the sender taps the thumbnail to preview their own attachment. */
710
+ onPreviewClick?: () => void;
711
+ /**
712
+ * Lazily loads the underlying source so the sender can preview the attachment.
713
+ * Called the first time the thumbnail is tapped; the returned source is cached
714
+ * and reused on subsequent toggles.
715
+ */
716
+ onFetchSource?: () => Promise<LockedAttachmentSource | void>;
717
+ }
718
+
508
719
  export declare function useCustomMessage<K extends keyof CustomMessageRegistry>(key: K): CustomMessageRegistry[K];
509
720
 
510
721
  /**
@@ -528,30 +739,8 @@ declare interface UseMessageVoteResult {
528
739
  */
529
740
  export declare const useMessaging: () => MessagingContextValue;
530
741
 
531
- export declare interface VisitorCardProps extends LockedAttachmentBaseProps {
532
- /**
533
- * Called when the visitor clicks Unlock on an unpaid attachment.
534
- * Use this to open a checkout flow. Omit to hide the Unlock button.
535
- */
536
- onUnlockClick?: () => void;
537
- /**
538
- * Called to fetch the attachment source — fired automatically when
539
- * paymentStatus transitions to 'paid', or immediately on click when
540
- * paymentStatus is already 'paid'. Return a LockedAttachmentSource to
541
- * unlock the card.
542
- */
543
- onFetchSource?: () => Promise<LockedAttachmentSource | void>;
544
- /**
545
- * Called when the visitor clicks Download on an unlocked card.
546
- * Omit to hide the Download button.
547
- */
548
- onDownloadClick?: () => void;
549
- /**
550
- * When true, shows loading dots on the Unlock button.
551
- * Driven by the LockedAttachmentContext (e.g. checkout in progress, payment processing).
552
- */
553
- isUnlocking?: boolean;
554
- }
742
+ /** @deprecated Renamed to `ReceivedCardProps`. */
743
+ export declare type VisitorCardProps = ReceivedCardProps;
555
744
 
556
745
  export declare type VoteSelection = 'up' | 'down' | null;
557
746
 
package/dist/index.js CHANGED
@@ -1,26 +1,27 @@
1
- import { b as e, c as t, C as i, d as n, e as o, f as g, F as m, g as r, L as M, M as l, h as u, i as h, j as L, k as d, l as C, m as c, n as v, r as A, o as k, u as p, p as F, q as f } from "./index-Bex7eg3v.js";
1
+ import { a as e, b as t, C as n, c as i, d as o, e as g, F as m, f as M, L as r, h as l, M as h, i as u, j as L, k as c, l as d, m as C, n as v, o as A, p as k, q as p, u as F, s as f, t as q } from "./index-DfcRe-Hj.js";
2
2
  export {
3
3
  e as ActionButton,
4
4
  t as Avatar,
5
- i as ChannelEmptyState,
6
- n as ChannelList,
5
+ n as ChannelEmptyState,
6
+ i as ChannelList,
7
7
  o as ChannelView,
8
8
  g as CustomMessageProvider,
9
9
  m as FaqList,
10
- r as FaqListItem,
11
- M as LockedAttachment,
12
- l as MediaMessage,
10
+ M as FaqListItem,
11
+ r as LinkAttachment,
12
+ l as LockedAttachment,
13
+ h as MediaMessage,
13
14
  u as MessageVoteButtons,
14
- h as MessagingProvider,
15
- L as MessagingShell,
15
+ L as MessagingProvider,
16
+ c as MessagingShell,
16
17
  d as formatRelativeTime,
17
18
  C as getMessageDisplayText,
18
- c as isLinkAttachment,
19
- v as normalizeLanguageCode,
20
- A as resolveLinkAttachment,
21
- k as resolveMediaFromMessage,
22
- p as useCustomMessage,
23
- F as useMessageVote,
24
- f as useMessaging
19
+ v as isLinkAttachment,
20
+ A as normalizeLanguageCode,
21
+ k as resolveLinkAttachment,
22
+ p as resolveMediaFromMessage,
23
+ F as useCustomMessage,
24
+ f as useMessageVote,
25
+ q as useMessaging
25
26
  };
26
27
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -227,21 +227,20 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
227
227
  {isAttachment ? (
228
228
  <div className="str-chat__message-bubble-wrapper">
229
229
  {isMine ? (
230
- <LockedAttachment.Creator
230
+ <LockedAttachment.Sent
231
231
  title={message.metadata?.attachment_title}
232
232
  mimeType={message.metadata?.attachment_mime_type}
233
233
  thumbnailUrl={message.metadata?.attachment_thumbnail}
234
234
  amountText={message.metadata?.amount_text}
235
235
  detail={message.metadata?.attachment_detail}
236
236
  paymentStatus={message.metadata?.payment_status}
237
- isUnlocking={isUnlocking(message.id)}
238
237
  onPreviewClick={() => onUnlockClick?.(message, channel)}
239
238
  onFetchSource={async () =>
240
239
  await onFetchSource?.(message, channel)
241
240
  }
242
241
  />
243
242
  ) : (
244
- <LockedAttachment.Visitor
243
+ <LockedAttachment.Received
245
244
  title={message.metadata?.attachment_title}
246
245
  mimeType={message.metadata?.attachment_mime_type}
247
246
  thumbnailUrl={message.metadata?.attachment_thumbnail}
@@ -0,0 +1,307 @@
1
+ import {
2
+ ArticleIcon,
3
+ MusicNotesIcon,
4
+ PlayIcon,
5
+ QuestionIcon,
6
+ } from '@phosphor-icons/react'
7
+ import type { Meta, StoryFn } from '@storybook/react'
8
+ import React from 'react'
9
+
10
+ import LinkAttachment from '.'
11
+
12
+ const IMAGE_THUMBNAIL = '/image-thumbnail.jpg'
13
+ const VIDEO_SOURCE = '/video-source.mp4'
14
+ const VIDEO_POSTER = '/video-thumbnail.jpg'
15
+ const AUDIO_SOURCE = '/audio-source.mp3'
16
+ const PDF_SOURCE = '/document-source.pdf'
17
+
18
+ const meta: Meta = {
19
+ title: 'LinkAttachment',
20
+ parameters: { layout: 'fullscreen' },
21
+ }
22
+ export default meta
23
+
24
+ const Table = ({ children }: { children: React.ReactNode }) => (
25
+ <div className="min-h-screen w-full bg-[#F9F7F4] p-12">
26
+ <table className="border-separate border-spacing-4">{children}</table>
27
+ </div>
28
+ )
29
+
30
+ const TableHead = ({ columns }: { columns: string[] }) => (
31
+ <thead>
32
+ <tr>
33
+ <th className="pb-2 text-left text-xs font-medium text-black/40" />
34
+ {columns.map((column) => (
35
+ <th
36
+ key={column}
37
+ className="pb-2 text-left text-xs font-medium text-black/40"
38
+ >
39
+ {column}
40
+ </th>
41
+ ))}
42
+ </tr>
43
+ </thead>
44
+ )
45
+
46
+ const RowLabel = ({ children }: { children: React.ReactNode }) => (
47
+ <td className="pr-4 pt-2 text-right align-top text-xs font-medium text-black/40">
48
+ {children}
49
+ </td>
50
+ )
51
+
52
+ const APP_ICON_BASE =
53
+ 'inline-flex size-4 items-center justify-center overflow-hidden rounded-[4px]'
54
+
55
+ const SpotifyBadge = () => (
56
+ <span
57
+ aria-hidden
58
+ className={`${APP_ICON_BASE} bg-[#1ed760] text-white`}
59
+ title="Spotify"
60
+ >
61
+ <MusicNotesIcon className="size-3" weight="fill" />
62
+ </span>
63
+ )
64
+
65
+ const TikTokBadge = () => (
66
+ <span
67
+ aria-hidden
68
+ className={`${APP_ICON_BASE} bg-[#101211] text-white`}
69
+ title="TikTok"
70
+ >
71
+ <PlayIcon className="size-3" weight="fill" />
72
+ </span>
73
+ )
74
+
75
+ const FaqBadge = () => (
76
+ <span
77
+ aria-hidden
78
+ className={`${APP_ICON_BASE} bg-[#061492] text-white`}
79
+ title="FAQ"
80
+ >
81
+ <QuestionIcon className="size-3" weight="bold" />
82
+ </span>
83
+ )
84
+
85
+ const FormBadge = () => (
86
+ <span
87
+ aria-hidden
88
+ className={`${APP_ICON_BASE} bg-[#2665d6] text-white`}
89
+ title="Form"
90
+ >
91
+ <ArticleIcon className="size-3" weight="fill" />
92
+ </span>
93
+ )
94
+
95
+ const LINK_APPS: Array<{
96
+ key: string
97
+ appIcon: React.ReactNode
98
+ title: string
99
+ description: string
100
+ url?: string
101
+ ctaLabel?: string
102
+ }> = [
103
+ {
104
+ key: 'spotify',
105
+ appIcon: <SpotifyBadge />,
106
+ title: 'My Playlist',
107
+ description: 'A handpicked workout mix I made for my clients.',
108
+ url: 'tr.ee/briemix',
109
+ },
110
+ {
111
+ key: 'tiktok',
112
+ appIcon: <TikTokBadge />,
113
+ title: 'My TikTok',
114
+ description: 'New form-check clips every week — follow along.',
115
+ url: 'tr.ee/brietok',
116
+ },
117
+ {
118
+ key: 'faq',
119
+ appIcon: <FaqBadge />,
120
+ title: 'Brie’s FAQ',
121
+ description: 'Get answers on my process and what to expect.',
122
+ ctaLabel: 'View FAQs',
123
+ },
124
+ {
125
+ key: 'form',
126
+ appIcon: <FormBadge />,
127
+ title: 'Fitness Assessment Fillout',
128
+ description: 'Share information about your journey and I’ll work on...',
129
+ ctaLabel: 'Complete form',
130
+ },
131
+ ]
132
+
133
+ /**
134
+ * "Attachments" section of the Figma board — image / video / audio /
135
+ * file attachments shown across the three messaging states. These are
136
+ * pure media cards: the hero fills the card and there's no title /
137
+ * description / link metadata. Playable types use inline native controls:
138
+ * - **Image** — full-bleed thumbnail; Received opens an image preview.
139
+ * - **Video** — inline `<video controls>` with a poster.
140
+ * - **Audio** — inline `<audio controls>` over the audio type-icon.
141
+ * - **PDF** — type-icon placeholder; Received opens the PDF in the
142
+ * browser's native viewer.
143
+ * - **File** — generic file type-icon; Received opens a preview.
144
+ * - **Placeholder** — empty draft state with the image type-icon.
145
+ */
146
+ const ATTACHMENT_ROWS: Array<{
147
+ label: string
148
+ /** Drives the placeholder type-icon and the inline player switch. */
149
+ mimeType?: string
150
+ /** Thumbnail (image source or video poster). */
151
+ thumbnailUrl?: string
152
+ /** Playable media URL — when set with a video/audio mime, renders inline. */
153
+ sourceUrl?: string
154
+ /**
155
+ * Override the Received `onClick` handler. Used by the PDF row to open
156
+ * the PDF in the browser's native viewer.
157
+ */
158
+ onReceivedClick?: () => void
159
+ }> = [
160
+ { label: 'Image', mimeType: 'image/jpeg', thumbnailUrl: IMAGE_THUMBNAIL },
161
+ {
162
+ label: 'Video',
163
+ mimeType: 'video/mp4',
164
+ thumbnailUrl: VIDEO_POSTER,
165
+ sourceUrl: VIDEO_SOURCE,
166
+ },
167
+ { label: 'Audio', mimeType: 'audio/mpeg', sourceUrl: AUDIO_SOURCE },
168
+ {
169
+ label: 'PDF',
170
+ mimeType: 'application/pdf',
171
+ onReceivedClick: () => window.open(PDF_SOURCE, '_blank', 'noopener'),
172
+ },
173
+ { label: 'File', mimeType: 'application/octet-stream' },
174
+ { label: 'Placeholder' },
175
+ ]
176
+
177
+ export const Attachments: StoryFn = () => (
178
+ <Table>
179
+ <TableHead columns={['Composer', 'Sent', 'Received']} />
180
+ <tbody>
181
+ {ATTACHMENT_ROWS.map(
182
+ ({ label, mimeType, thumbnailUrl, sourceUrl, onReceivedClick }) => (
183
+ <tr key={label}>
184
+ <RowLabel>{label}</RowLabel>
185
+ <td className="align-top">
186
+ <LinkAttachment.Composer
187
+ mimeType={mimeType}
188
+ thumbnailUrl={thumbnailUrl}
189
+ sourceUrl={sourceUrl}
190
+ onDismiss={() => alert(`Dismissed ${label}`)}
191
+ />
192
+ </td>
193
+ <td className="align-top">
194
+ <LinkAttachment.Sent
195
+ mimeType={mimeType}
196
+ thumbnailUrl={thumbnailUrl}
197
+ sourceUrl={sourceUrl}
198
+ />
199
+ </td>
200
+ <td className="align-top">
201
+ <LinkAttachment.Received
202
+ mimeType={mimeType}
203
+ thumbnailUrl={thumbnailUrl}
204
+ sourceUrl={sourceUrl}
205
+ onClick={
206
+ onReceivedClick ?? (() => alert(`Open ${label} preview`))
207
+ }
208
+ />
209
+ </td>
210
+ </tr>
211
+ )
212
+ )}
213
+ </tbody>
214
+ </Table>
215
+ )
216
+
217
+ type LinkAppLayout = 'featured' | 'classic'
218
+ type LinkAppState = 'Received' | 'Sent' | 'Composer'
219
+
220
+ const LINK_APP_ROWS: Array<{ layout: LinkAppLayout; state: LinkAppState }> = [
221
+ { layout: 'featured', state: 'Received' },
222
+ { layout: 'featured', state: 'Sent' },
223
+ { layout: 'featured', state: 'Composer' },
224
+ { layout: 'classic', state: 'Received' },
225
+ { layout: 'classic', state: 'Sent' },
226
+ { layout: 'classic', state: 'Composer' },
227
+ ]
228
+
229
+ const renderLinkAppCard = (
230
+ layout: LinkAppLayout,
231
+ state: LinkAppState,
232
+ app: (typeof LINK_APPS)[number]
233
+ ) => {
234
+ const { key, appIcon, title, description, url, ctaLabel } = app
235
+ const thumbnailUrl = layout === 'featured' ? IMAGE_THUMBNAIL : undefined
236
+ const ctaWithHandler = ctaLabel
237
+ ? { label: ctaLabel, onClick: () => alert(`Tapped ${ctaLabel}`) }
238
+ : undefined
239
+ const ctaSilent = ctaLabel ? { label: ctaLabel } : undefined
240
+
241
+ if (state === 'Received') {
242
+ return (
243
+ <LinkAttachment.Received
244
+ layout={layout}
245
+ appIcon={appIcon}
246
+ title={title}
247
+ description={description}
248
+ thumbnailUrl={thumbnailUrl}
249
+ url={url}
250
+ cta={ctaWithHandler}
251
+ />
252
+ )
253
+ }
254
+ if (state === 'Sent') {
255
+ return (
256
+ <LinkAttachment.Sent
257
+ layout={layout}
258
+ appIcon={appIcon}
259
+ title={title}
260
+ description={description}
261
+ thumbnailUrl={thumbnailUrl}
262
+ url={url}
263
+ cta={ctaSilent}
264
+ />
265
+ )
266
+ }
267
+ return (
268
+ <LinkAttachment.Composer
269
+ layout={layout}
270
+ appIcon={appIcon}
271
+ title={title}
272
+ description={description}
273
+ thumbnailUrl={thumbnailUrl}
274
+ url={url}
275
+ cta={ctaSilent}
276
+ onDismiss={() => alert(`Dismissed ${key}`)}
277
+ />
278
+ )
279
+ }
280
+
281
+ /**
282
+ * "LinkApps" section of the Figma board — link previews with a brand
283
+ * badge prefixing the title and either a URL footer (Spotify, TikTok) or
284
+ * a CTA button (FAQ, Form). Each app is shown in both the **Featured**
285
+ * (hero image) and **Classic** (compact, no hero) layouts across all
286
+ * three messaging states.
287
+ */
288
+ export const LinkApps: StoryFn = () => (
289
+ <Table>
290
+ <TableHead columns={LINK_APPS.map(({ key }) => key)} />
291
+ <tbody>
292
+ {LINK_APP_ROWS.map(({ layout, state }) => {
293
+ const label = `${layout === 'featured' ? 'Featured' : 'Classic'} (${state})`
294
+ return (
295
+ <tr key={label}>
296
+ <RowLabel>{label}</RowLabel>
297
+ {LINK_APPS.map((app) => (
298
+ <td key={app.key} className="align-top">
299
+ {renderLinkAppCard(layout, state, app)}
300
+ </td>
301
+ ))}
302
+ </tr>
303
+ )
304
+ })}
305
+ </tbody>
306
+ </Table>
307
+ )