@flamingo-stack/openframe-frontend-core 0.0.584 → 0.0.585-snapshot.20260903083645

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 (177) hide show
  1. package/dist/{chunk-3ELW4VML.js → chunk-2Y5UT5VF.js} +3 -3
  2. package/dist/{chunk-YFZDIEVC.js → chunk-3HYHYKNL.js} +6 -6
  3. package/dist/{chunk-5CJKZHWO.js → chunk-3JVYM4ZN.js} +5 -5
  4. package/dist/{chunk-P2VRLSAP.cjs → chunk-3WR5R6H6.cjs} +21 -21
  5. package/dist/{chunk-P2VRLSAP.cjs.map → chunk-3WR5R6H6.cjs.map} +1 -1
  6. package/dist/{chunk-UCNEQKIT.cjs → chunk-4VZFJKPT.cjs} +5 -5
  7. package/dist/{chunk-UCNEQKIT.cjs.map → chunk-4VZFJKPT.cjs.map} +1 -1
  8. package/dist/{chunk-VEHZNQ77.cjs → chunk-5LUPIYPH.cjs} +30 -30
  9. package/dist/{chunk-VEHZNQ77.cjs.map → chunk-5LUPIYPH.cjs.map} +1 -1
  10. package/dist/{chunk-TLKHLGAB.js → chunk-5OIXAV4P.js} +3 -3
  11. package/dist/{chunk-LNKULR4V.js → chunk-6EZGJZVH.js} +2 -2
  12. package/dist/{chunk-JWKAJTNA.cjs → chunk-6ZNWY6WW.cjs} +28 -28
  13. package/dist/{chunk-JWKAJTNA.cjs.map → chunk-6ZNWY6WW.cjs.map} +1 -1
  14. package/dist/{chunk-QJR32JM2.cjs → chunk-A6ETQEWK.cjs} +615 -485
  15. package/dist/chunk-A6ETQEWK.cjs.map +1 -0
  16. package/dist/{chunk-JFKK3ZOA.cjs → chunk-BMORFCT2.cjs} +57 -57
  17. package/dist/{chunk-JFKK3ZOA.cjs.map → chunk-BMORFCT2.cjs.map} +1 -1
  18. package/dist/{chunk-33DHEEMP.cjs → chunk-C3MRLZUI.cjs} +33 -33
  19. package/dist/{chunk-33DHEEMP.cjs.map → chunk-C3MRLZUI.cjs.map} +1 -1
  20. package/dist/{chunk-XWBND4G5.js → chunk-D7YISHXC.js} +17 -2
  21. package/dist/{chunk-XWBND4G5.js.map → chunk-D7YISHXC.js.map} +1 -1
  22. package/dist/{chunk-EJKEDCTH.js → chunk-DQUEVRDU.js} +7 -7
  23. package/dist/{chunk-HCKEDTKJ.cjs → chunk-EAYMX22G.cjs} +15 -15
  24. package/dist/{chunk-HCKEDTKJ.cjs.map → chunk-EAYMX22G.cjs.map} +1 -1
  25. package/dist/{chunk-NNFQLTJ3.cjs → chunk-EMF64FWA.cjs} +31 -5
  26. package/dist/chunk-EMF64FWA.cjs.map +1 -0
  27. package/dist/{chunk-IKUSKKFZ.cjs → chunk-G45RGWHF.cjs} +159 -144
  28. package/dist/chunk-G45RGWHF.cjs.map +1 -0
  29. package/dist/{chunk-TOUD3AGK.js → chunk-KLWJRGNN.js} +5 -5
  30. package/dist/{chunk-MYEG5ZDT.js → chunk-L4WLYGB4.js} +3 -3
  31. package/dist/{chunk-VZSTL5VG.js → chunk-LV6CORVJ.js} +3 -3
  32. package/dist/{chunk-MEJFHD5N.cjs → chunk-M36XSGZV.cjs} +19 -19
  33. package/dist/{chunk-MEJFHD5N.cjs.map → chunk-M36XSGZV.cjs.map} +1 -1
  34. package/dist/{chunk-MCWAZ5XI.js → chunk-NFWAL6UV.js} +2 -2
  35. package/dist/{chunk-3ZA2OYRE.cjs → chunk-NISAGFEM.cjs} +3 -3
  36. package/dist/{chunk-3ZA2OYRE.cjs.map → chunk-NISAGFEM.cjs.map} +1 -1
  37. package/dist/{chunk-7X5TCK4R.cjs → chunk-NYLWMAHP.cjs} +10 -10
  38. package/dist/{chunk-7X5TCK4R.cjs.map → chunk-NYLWMAHP.cjs.map} +1 -1
  39. package/dist/{chunk-RURZ6QLN.js → chunk-OZNFPQT5.js} +3 -3
  40. package/dist/{chunk-VOAZU46B.cjs → chunk-P2CKLIOM.cjs} +80 -80
  41. package/dist/{chunk-VOAZU46B.cjs.map → chunk-P2CKLIOM.cjs.map} +1 -1
  42. package/dist/{chunk-KP6VJP36.js → chunk-PLMJVA2H.js} +4 -4
  43. package/dist/{chunk-JAJQ5P2Q.cjs → chunk-QZYYNBMI.cjs} +28 -28
  44. package/dist/{chunk-JAJQ5P2Q.cjs.map → chunk-QZYYNBMI.cjs.map} +1 -1
  45. package/dist/{chunk-4T5KOD3G.js → chunk-S7YXLZNG.js} +6 -6
  46. package/dist/{chunk-Q66AMADB.cjs → chunk-SSGM2NKA.cjs} +6 -6
  47. package/dist/{chunk-Q66AMADB.cjs.map → chunk-SSGM2NKA.cjs.map} +1 -1
  48. package/dist/{chunk-IIT5HWLY.cjs → chunk-V7ZJTUV2.cjs} +11 -11
  49. package/dist/{chunk-IIT5HWLY.cjs.map → chunk-V7ZJTUV2.cjs.map} +1 -1
  50. package/dist/{chunk-3LMYQGAJ.js → chunk-X7YSF6A3.js} +28 -2
  51. package/dist/chunk-X7YSF6A3.js.map +1 -0
  52. package/dist/{chunk-ZHUBNWEX.cjs → chunk-XMK24INA.cjs} +6 -6
  53. package/dist/{chunk-ZHUBNWEX.cjs.map → chunk-XMK24INA.cjs.map} +1 -1
  54. package/dist/{chunk-XPJKQ63S.js → chunk-XMTGLQSD.js} +3 -3
  55. package/dist/{chunk-6BJYPG2V.js → chunk-YEQAA6I4.js} +4 -4
  56. package/dist/{chunk-JVWRMLQZ.js → chunk-YYC7V7YC.js} +2059 -1929
  57. package/dist/chunk-YYC7V7YC.js.map +1 -0
  58. package/dist/components/case-studies/index.cjs +10 -10
  59. package/dist/components/case-studies/index.js +4 -4
  60. package/dist/components/chat/entity-cards/program-card.d.ts.map +1 -1
  61. package/dist/components/chat/index.cjs +3 -3
  62. package/dist/components/chat/index.js +2 -2
  63. package/dist/components/contact/index.cjs +5 -5
  64. package/dist/components/contact/index.js +4 -4
  65. package/dist/components/docs/index.cjs +8 -8
  66. package/dist/components/docs/index.js +7 -7
  67. package/dist/components/embeds/index.cjs +5 -5
  68. package/dist/components/embeds/index.js +4 -4
  69. package/dist/components/faq/index.cjs +5 -5
  70. package/dist/components/faq/index.js +4 -4
  71. package/dist/components/features/auth/account-details-fields.d.ts +34 -0
  72. package/dist/components/features/auth/account-details-fields.d.ts.map +1 -0
  73. package/dist/components/features/auth/complete-account-form.d.ts.map +1 -1
  74. package/dist/components/features/auth/create-organization-form.d.ts +43 -6
  75. package/dist/components/features/auth/create-organization-form.d.ts.map +1 -1
  76. package/dist/components/features/auth/index.d.ts +2 -0
  77. package/dist/components/features/auth/index.d.ts.map +1 -1
  78. package/dist/components/features/auth/labeled-divider.d.ts +6 -0
  79. package/dist/components/features/auth/labeled-divider.d.ts.map +1 -0
  80. package/dist/components/features/auth/login-form.d.ts +32 -9
  81. package/dist/components/features/auth/login-form.d.ts.map +1 -1
  82. package/dist/components/features/auth/openframe-sso-signup-form.d.ts.map +1 -1
  83. package/dist/components/features/auth/sso-providers.d.ts +1 -1
  84. package/dist/components/features/auth/sso-providers.d.ts.map +1 -1
  85. package/dist/components/features/index.cjs +8 -4
  86. package/dist/components/features/index.cjs.map +1 -1
  87. package/dist/components/features/index.js +7 -3
  88. package/dist/components/features/media-gallery-manager.d.ts.map +1 -1
  89. package/dist/components/features/push-button-selector.d.ts +9 -7
  90. package/dist/components/features/push-button-selector.d.ts.map +1 -1
  91. package/dist/components/features/release-media-manager.d.ts.map +1 -1
  92. package/dist/components/features/video.d.ts +8 -0
  93. package/dist/components/features/video.d.ts.map +1 -1
  94. package/dist/components/help-center-pages/index.cjs +24 -24
  95. package/dist/components/help-center-pages/index.js +15 -15
  96. package/dist/components/index.cjs +57 -51
  97. package/dist/components/index.cjs.map +1 -1
  98. package/dist/components/index.js +21 -15
  99. package/dist/components/index.js.map +1 -1
  100. package/dist/components/loading/unified-skeleton.d.ts.map +1 -1
  101. package/dist/components/meeting-scheduler/index.cjs +31 -31
  102. package/dist/components/meeting-scheduler/index.js +3 -3
  103. package/dist/components/navigation/index.cjs +7 -7
  104. package/dist/components/navigation/index.js +6 -6
  105. package/dist/components/onboarding-guides/index.cjs +7 -7
  106. package/dist/components/onboarding-guides/index.js +6 -6
  107. package/dist/components/related-content/index.cjs +3 -3
  108. package/dist/components/related-content/index.js +2 -2
  109. package/dist/components/tickets/index.cjs +8 -8
  110. package/dist/components/tickets/index.js +7 -7
  111. package/dist/components/ui/index.cjs +6 -4
  112. package/dist/components/ui/index.cjs.map +1 -1
  113. package/dist/components/ui/index.d.ts +1 -0
  114. package/dist/components/ui/index.d.ts.map +1 -1
  115. package/dist/components/ui/index.js +5 -3
  116. package/dist/components/ui/lazy-mount.d.ts +21 -0
  117. package/dist/components/ui/lazy-mount.d.ts.map +1 -0
  118. package/dist/index.cjs +24 -8
  119. package/dist/index.cjs.map +1 -1
  120. package/dist/index.js +23 -7
  121. package/dist/index.js.map +1 -1
  122. package/dist/types/marketing.d.ts +1 -1
  123. package/dist/types/marketing.d.ts.map +1 -1
  124. package/dist/utils/index.cjs +26 -0
  125. package/dist/utils/index.cjs.map +1 -1
  126. package/dist/utils/index.d.ts +1 -0
  127. package/dist/utils/index.d.ts.map +1 -1
  128. package/dist/utils/index.js +22 -1
  129. package/dist/utils/index.js.map +1 -1
  130. package/dist/utils/media-type.d.ts +26 -0
  131. package/dist/utils/media-type.d.ts.map +1 -0
  132. package/package.json +1 -1
  133. package/src/components/chat/entity-cards/program-card.tsx +2 -2
  134. package/src/components/features/auth/account-details-fields.tsx +124 -0
  135. package/src/components/features/auth/complete-account-form.tsx +13 -52
  136. package/src/components/features/auth/create-organization-form.tsx +128 -38
  137. package/src/components/features/auth/index.ts +2 -0
  138. package/src/components/features/auth/labeled-divider.tsx +16 -0
  139. package/src/components/features/auth/login-form.tsx +102 -23
  140. package/src/components/features/auth/openframe-sso-signup-form.tsx +11 -43
  141. package/src/components/features/auth/sso-providers.tsx +3 -3
  142. package/src/components/features/media-gallery-manager.tsx +4 -3
  143. package/src/components/features/push-button-selector.tsx +15 -12
  144. package/src/components/features/release-media-manager.tsx +3 -2
  145. package/src/components/features/video.tsx +28 -0
  146. package/src/components/loading/__tests__/unified-skeleton.test.tsx +16 -0
  147. package/src/components/loading/unified-skeleton.tsx +5 -1
  148. package/src/components/ui/__tests__/lazy-mount.test.tsx +46 -0
  149. package/src/components/ui/index.ts +3 -0
  150. package/src/components/ui/lazy-mount.tsx +38 -0
  151. package/src/stories/auth/Login.stories.tsx +15 -5
  152. package/src/types/.marketing.md +0 -1
  153. package/src/types/marketing.ts +4 -2
  154. package/src/utils/__tests__/media-type.test.ts +30 -0
  155. package/src/utils/index.ts +3 -0
  156. package/src/utils/media-type.ts +39 -0
  157. package/src/utils/vendor-media-stub.ts +7 -7
  158. package/dist/chunk-3LMYQGAJ.js.map +0 -1
  159. package/dist/chunk-IKUSKKFZ.cjs.map +0 -1
  160. package/dist/chunk-JVWRMLQZ.js.map +0 -1
  161. package/dist/chunk-NNFQLTJ3.cjs.map +0 -1
  162. package/dist/chunk-QJR32JM2.cjs.map +0 -1
  163. /package/dist/{chunk-3ELW4VML.js.map → chunk-2Y5UT5VF.js.map} +0 -0
  164. /package/dist/{chunk-YFZDIEVC.js.map → chunk-3HYHYKNL.js.map} +0 -0
  165. /package/dist/{chunk-5CJKZHWO.js.map → chunk-3JVYM4ZN.js.map} +0 -0
  166. /package/dist/{chunk-TLKHLGAB.js.map → chunk-5OIXAV4P.js.map} +0 -0
  167. /package/dist/{chunk-LNKULR4V.js.map → chunk-6EZGJZVH.js.map} +0 -0
  168. /package/dist/{chunk-EJKEDCTH.js.map → chunk-DQUEVRDU.js.map} +0 -0
  169. /package/dist/{chunk-TOUD3AGK.js.map → chunk-KLWJRGNN.js.map} +0 -0
  170. /package/dist/{chunk-MYEG5ZDT.js.map → chunk-L4WLYGB4.js.map} +0 -0
  171. /package/dist/{chunk-VZSTL5VG.js.map → chunk-LV6CORVJ.js.map} +0 -0
  172. /package/dist/{chunk-MCWAZ5XI.js.map → chunk-NFWAL6UV.js.map} +0 -0
  173. /package/dist/{chunk-RURZ6QLN.js.map → chunk-OZNFPQT5.js.map} +0 -0
  174. /package/dist/{chunk-KP6VJP36.js.map → chunk-PLMJVA2H.js.map} +0 -0
  175. /package/dist/{chunk-4T5KOD3G.js.map → chunk-S7YXLZNG.js.map} +0 -0
  176. /package/dist/{chunk-XPJKQ63S.js.map → chunk-XMTGLQSD.js.map} +0 -0
  177. /package/dist/{chunk-6BJYPG2V.js.map → chunk-YEQAA6I4.js.map} +0 -0
@@ -1,9 +1,12 @@
1
1
  'use client';
2
2
 
3
- import type { KeyboardEvent } from 'react';
3
+ import type { KeyboardEvent, ReactNode } from 'react';
4
4
  import { useDeferredError } from '../../../hooks/ui/use-deferred-error';
5
5
  import { cn } from '../../../utils/cn';
6
+ import { AlertTriangleIcon } from '../../icons-v2-generated/interface/alert-triangle-icon';
7
+ import { Button } from '../../ui/button';
6
8
  import { Input } from '../../ui/input';
9
+ import { LabeledDivider } from './labeled-divider';
7
10
  import type { AuthSsoProvider } from './sso-providers';
8
11
  import { SsoProviderButtons } from './sso-providers';
9
12
 
@@ -11,8 +14,6 @@ export interface LoginFormProps {
11
14
  /** Controlled email value */
12
15
  email: string;
13
16
  onEmailChange: (value: string) => void;
14
- /** Enter-key handler on the email field (optional). */
15
- onSubmit?: () => void;
16
17
  emailPlaceholder?: string;
17
18
  loading?: boolean;
18
19
  disabled?: boolean;
@@ -22,8 +23,8 @@ export interface LoginFormProps {
22
23
  /** Informational status under the email field (e.g. live tenant discovery). `errors.email` wins. */
23
24
  emailStatus?: { message: string; variant: 'error' | 'warning' | 'success' | 'muted' };
24
25
  /**
25
- * SSO providers rendered below the email field. Always visible; gate
26
- * clickability with `ssoDisabled` / `ssoEnabledProviders`.
26
+ * SSO providers, rendered ABOVE the email field with a divider between the two. Always visible;
27
+ * gate clickability with `ssoDisabled` / `ssoEnabledProviders`.
27
28
  */
28
29
  ssoProviders: AuthSsoProvider[];
29
30
  onSsoClick?: (provider: AuthSsoProvider) => void;
@@ -31,21 +32,44 @@ export interface LoginFormProps {
31
32
  ssoDisabled?: boolean;
32
33
  /** When set, only these providers are clickable; the rest stay disabled. */
33
34
  ssoEnabledProviders?: AuthSsoProvider[];
34
- /** Verb prefix for provider buttons, e.g. "Continue with". Ignored for "openframe". */
35
+ /** Verb prefix for provider buttons, e.g. "Continue with". */
35
36
  ssoActionLabel?: string;
37
+ /** Label on the divider between the provider buttons and the email field. */
38
+ dividerLabel?: string;
39
+ /**
40
+ * The discovered tenant's own SSO options, rendered UNDER the email field — unlike `ssoProviders`
41
+ * above, these depend on knowing the tenant, so they cannot be offered before an address is
42
+ * typed. Shares `onSsoClick`.
43
+ *
44
+ * Three states, and the empty array is not the same as nothing: `undefined` means discovery has
45
+ * not resolved a tenant yet and the slot stays empty, while `[]` means it resolved and the tenant
46
+ * has none — which is an answer worth showing rather than silence.
47
+ */
48
+ customSsoProviders?: AuthSsoProvider[];
49
+ /** Shown in place of the custom SSO buttons when the resolved tenant has none configured. */
50
+ noCustomSsoLabel?: string;
51
+ /** Primary submit under the email field. Omit the handler to hide the button. */
52
+ onSubmitClick?: () => void;
53
+ submitLabel?: string;
54
+ /** Disables just the primary submit (fields stay editable). */
55
+ submitDisabled?: boolean;
56
+ /** Overrides the default heading. */
57
+ title?: string;
58
+ /** Overrides the default sub-heading. */
59
+ subtitle?: ReactNode;
36
60
  className?: string;
37
61
  }
38
62
 
39
63
  /**
40
64
  * Login form (Login tab). Presentational + controlled — the consumer owns
41
- * state, validation and discovery. Single-screen design: the email field and
42
- * the SSO provider buttons are always visible; the buttons unlock once the
43
- * consumer validates the email (real-time tenant discovery).
65
+ * state, validation and discovery. Single-screen design: the provider buttons come first and need
66
+ * nothing typed, then the email field below them. Whether the providers are gated is the
67
+ * consumer's call via `ssoDisabled`; the identity-first flows leave them open, because the server
68
+ * resolves the tenant from the identity the provider asserts.
44
69
  */
45
70
  export function LoginForm({
46
71
  email,
47
72
  onEmailChange,
48
- onSubmit,
49
73
  emailPlaceholder = 'username@mail.com',
50
74
  loading = false,
51
75
  disabled = false,
@@ -56,6 +80,14 @@ export function LoginForm({
56
80
  ssoDisabled = false,
57
81
  ssoEnabledProviders,
58
82
  ssoActionLabel = 'Continue with',
83
+ dividerLabel = 'or continue with OpenFrame SSO',
84
+ customSsoProviders,
85
+ noCustomSsoLabel = 'No additional SSO configurations for this domain',
86
+ onSubmitClick,
87
+ submitLabel = 'Continue',
88
+ submitDisabled = false,
89
+ title = 'Login to OpenFrame',
90
+ subtitle = 'Enter your email to access your organization.',
59
91
  className,
60
92
  }: LoginFormProps) {
61
93
  const fieldDisabled = disabled || loading;
@@ -63,10 +95,11 @@ export function LoginForm({
63
95
  // Validation messages are deferred while the user is typing (shown on blur or after a pause).
64
96
  const emailErr = useDeferredError(errors?.email, email);
65
97
 
98
+ // Enter drives the same action as the visible button, under the same gate. A form with no
99
+ // submit (the Login tab, where the providers are the only way on) simply does nothing.
66
100
  const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
67
- if (event.key === 'Enter' && !fieldDisabled) {
68
- onSubmit?.();
69
- }
101
+ if (event.key !== 'Enter' || fieldDisabled || submitDisabled) return;
102
+ onSubmitClick?.();
70
103
  };
71
104
 
72
105
  return (
@@ -78,10 +111,25 @@ export function LoginForm({
78
111
  >
79
112
  {/* Header */}
80
113
  <div className="flex flex-col">
81
- <h1 className="tracking-[-0.64px] text-ods-text-primary text-h2">Login to OpenFrame</h1>
82
- <p className="text-ods-text-secondary text-h4">Enter your email to access your organization.</p>
114
+ <h1 className="tracking-[-0.64px] text-ods-text-primary text-h2">{title}</h1>
115
+ <p className="text-ods-text-secondary text-h4">{subtitle}</p>
83
116
  </div>
84
117
 
118
+ {/* Providers come FIRST. They need nothing typed, and putting the email above them taught
119
+ people to fill it in before tapping one — the behaviour that made a provider button look
120
+ broken while it waited on a field it never needed. */}
121
+ {ssoProviders.length > 0 && (
122
+ <SsoProviderButtons
123
+ providers={ssoProviders}
124
+ onSsoClick={onSsoClick}
125
+ actionLabel={ssoActionLabel}
126
+ disabled={disabled || loading || ssoDisabled}
127
+ enabledProviders={ssoEnabledProviders}
128
+ />
129
+ )}
130
+
131
+ {ssoProviders.length > 0 && <LabeledDivider label={dividerLabel} />}
132
+
85
133
  {/* Email */}
86
134
  <Input
87
135
  type="email"
@@ -96,14 +144,45 @@ export function LoginForm({
96
144
  onKeyDown={handleKeyDown}
97
145
  />
98
146
 
99
- {/* SSO providers — always visible, unlocked by the consumer */}
100
- <SsoProviderButtons
101
- providers={ssoProviders}
102
- onSsoClick={onSsoClick}
103
- actionLabel={ssoActionLabel}
104
- disabled={disabled || loading || ssoDisabled}
105
- enabledProviders={ssoEnabledProviders}
106
- />
147
+ {customSsoProviders &&
148
+ (customSsoProviders.length > 0 ? (
149
+ <SsoProviderButtons
150
+ providers={customSsoProviders}
151
+ onSsoClick={onSsoClick}
152
+ actionLabel={ssoActionLabel}
153
+ disabled={disabled || loading}
154
+ />
155
+ ) : (
156
+ // Same footprint as the button it stands in for, so nothing shifts when a tenant does
157
+ // have options. Tokens are the design's: soft-grey surface, grey text and icon, the
158
+ // bold-body step, a 24px icon, and 12/16 for the inset and the gap.
159
+ <div
160
+ role="status"
161
+ className="flex h-11 w-full items-center gap-[var(--spacing-system-m)] rounded-md bg-ods-bg-surface px-[var(--spacing-system-s)] text-ods-text-secondary text-h3 md:h-12"
162
+ >
163
+ <AlertTriangleIcon className="h-6 w-6 shrink-0" />
164
+ {noCustomSsoLabel}
165
+ </div>
166
+ ))}
167
+
168
+ {onSubmitClick && (
169
+ // Full width on a phone, half the row and right-aligned from `md` up, matching the design.
170
+ // The spacer, rather than a width on the button, keeps it flush with the fields' right edge.
171
+ <div className="flex items-center gap-[var(--spacing-system-l)]">
172
+ <div className="hidden flex-1 md:block" />
173
+ <Button
174
+ type="button"
175
+ variant="accent"
176
+ fullWidth
177
+ className="md:flex-1"
178
+ loading={loading}
179
+ disabled={submitDisabled || fieldDisabled}
180
+ onClick={onSubmitClick}
181
+ >
182
+ {submitLabel}
183
+ </Button>
184
+ </div>
185
+ )}
107
186
  </div>
108
187
  );
109
188
  }
@@ -5,7 +5,7 @@ import { useDeferredError } from '../../../hooks/ui/use-deferred-error';
5
5
  import { cn } from '../../../utils/cn';
6
6
  import { Button } from '../../ui/button';
7
7
  import { Input } from '../../ui/input';
8
- import { PasswordInput } from '../../ui/password-input';
8
+ import { AccountDetailsFields } from './account-details-fields';
9
9
 
10
10
  export interface OpenFrameSsoSignUpFormProps {
11
11
  /** Controlled field values */
@@ -75,10 +75,6 @@ export function OpenFrameSsoSignUpForm({
75
75
 
76
76
  // Validation messages are deferred while the user is typing (shown on blur or after a pause).
77
77
  const emailErr = useDeferredError(errors?.email, email);
78
- const firstNameErr = useDeferredError(errors?.firstName, firstName);
79
- const lastNameErr = useDeferredError(errors?.lastName, lastName);
80
- const passwordErr = useDeferredError(errors?.password, password);
81
- const confirmErr = useDeferredError(errors?.confirmPassword, confirmPassword);
82
78
 
83
79
  const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
84
80
  if (event.key === 'Enter' && !fieldsDisabled && !submitDisabled) {
@@ -111,45 +107,17 @@ export function OpenFrameSsoSignUpForm({
111
107
  onKeyDown={handleKeyDown}
112
108
  />
113
109
 
114
- {/* Name + password fields — single column on every breakpoint */}
115
- <Input
116
- label="First Name"
117
- placeholder="Enter First Name"
118
- value={firstName}
119
- error={firstNameErr.error}
120
- disabled={fieldsDisabled}
121
- onBlur={firstNameErr.onBlur}
122
- onChange={event => onFirstNameChange(event.target.value)}
123
- onKeyDown={handleKeyDown}
124
- />
125
- <Input
126
- label="Last Name"
127
- placeholder="Enter Last Name"
128
- value={lastName}
129
- error={lastNameErr.error}
130
- disabled={fieldsDisabled}
131
- onBlur={lastNameErr.onBlur}
132
- onChange={event => onLastNameChange(event.target.value)}
133
- onKeyDown={handleKeyDown}
134
- />
135
- <PasswordInput
136
- label="Password"
137
- placeholder="Enter Password"
138
- value={password}
139
- error={passwordErr.error}
140
- disabled={fieldsDisabled}
141
- onBlur={passwordErr.onBlur}
142
- onChange={event => onPasswordChange(event.target.value)}
143
- onKeyDown={handleKeyDown}
144
- />
145
- <PasswordInput
146
- label="Confirm Password"
147
- placeholder="Confirm Password"
148
- value={confirmPassword}
149
- error={confirmErr.error}
110
+ <AccountDetailsFields
111
+ firstName={firstName}
112
+ lastName={lastName}
113
+ password={password}
114
+ confirmPassword={confirmPassword}
115
+ onFirstNameChange={onFirstNameChange}
116
+ onLastNameChange={onLastNameChange}
117
+ onPasswordChange={onPasswordChange}
118
+ onConfirmPasswordChange={onConfirmPasswordChange}
119
+ errors={errors}
150
120
  disabled={fieldsDisabled}
151
- onBlur={confirmErr.onBlur}
152
- onChange={event => onConfirmPasswordChange(event.target.value)}
153
121
  onKeyDown={handleKeyDown}
154
122
  />
155
123
 
@@ -12,7 +12,7 @@ export type AuthSsoProvider = 'openframe' | 'google' | 'microsoft' | 'apple';
12
12
 
13
13
  export const PROVIDER_META: Record<AuthSsoProvider, { name: string; Icon: ComponentType<{ className?: string }> }> = {
14
14
  openframe: {
15
- name: 'OpenFrame SSO',
15
+ name: 'OpenFrame',
16
16
  Icon: ({ className }) => (
17
17
  <OpenFrameLogo
18
18
  className={className}
@@ -29,7 +29,7 @@ export const PROVIDER_META: Record<AuthSsoProvider, { name: string; Icon: Compon
29
29
  export interface SsoProviderButtonsProps {
30
30
  providers: AuthSsoProvider[];
31
31
  onSsoClick?: (provider: AuthSsoProvider) => void;
32
- /** Verb prefix for provider buttons, e.g. "Continue with". Ignored for "openframe". */
32
+ /** Verb prefix for provider buttons, e.g. "Continue with". */
33
33
  actionLabel?: string;
34
34
  disabled?: boolean;
35
35
  /**
@@ -65,7 +65,7 @@ export function SsoProviderButtons({
65
65
  leftIcon={<Icon className="h-5 w-5" />}
66
66
  onClick={() => onSsoClick?.(provider)}
67
67
  >
68
- {provider === 'openframe' ? meta.name : `${actionLabel} ${meta.name}`}
68
+ {`${actionLabel} ${meta.name}`}
69
69
  </Button>
70
70
  );
71
71
  })}
@@ -4,9 +4,10 @@ import { Upload, Image as ImageIcon, Video as VideoIcon, Trash2, Loader2, GripVe
4
4
  import type React from 'react';
5
5
  import { useState, useRef, useCallback } from 'react';
6
6
  import Image from '../../embed-shims/next-image';
7
+ import { isVideoMedia } from '../../utils/media-type';
7
8
  import { Button, Card } from '../ui';
8
- import { Video } from './video';
9
9
 
10
+ import { Video } from './video';
10
11
  export interface MediaItem {
11
12
  id?: string | number; // Optional for new items
12
13
  media_type: 'image' | 'video' | 'screenshot' | 'demo';
@@ -157,7 +158,7 @@ export function MediaGalleryManager({
157
158
 
158
159
  {/* Media Content */}
159
160
  <div className="relative aspect-video overflow-hidden rounded-lg bg-ods-bg">
160
- {mediaItem.media_type === 'video' || mediaItem.media_type === 'demo' ? (
161
+ {isVideoMedia(mediaItem) || mediaItem.media_type === 'demo' ? (
161
162
  // <Video> SSOT (MuxPlayer) — plays Mux HLS + MP4 alike;
162
163
  // fit="cover" crops to the aspect-video cell.
163
164
  <Video kind="file" url={mediaItem.media_url} fit="cover" className="h-full w-full" />
@@ -175,7 +176,7 @@ export function MediaGalleryManager({
175
176
  {/* Media Info */}
176
177
  <div className="p-3">
177
178
  <div className="mb-1 flex items-center gap-2">
178
- {mediaItem.media_type === 'video' || mediaItem.media_type === 'demo' ? (
179
+ {isVideoMedia(mediaItem) || mediaItem.media_type === 'demo' ? (
179
180
  <VideoIcon className="h-4 w-4 text-ods-text-secondary" />
180
181
  ) : (
181
182
  <ImageIcon className="h-4 w-4 text-ods-text-secondary" />
@@ -7,8 +7,11 @@ import { UnifiedSkeleton } from '../loading';
7
7
  import { Badge, Input } from '../ui';
8
8
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
9
9
 
10
- export interface SelectableOption {
11
- id: string; // Selection ID (UUID for platforms, value for others)
10
+ /** Option ids are whatever the consumer keys its rows by — string (UUID / enum value) or numeric row id. */
11
+ export type SelectableOptionId = string | number;
12
+
13
+ export interface SelectableOption<Id extends SelectableOptionId = string> {
14
+ id: Id; // Selection ID (UUID / value for platforms, numeric row id for DB-keyed rows)
12
15
  name: string; // Primary identifier (platform enum or item name)
13
16
  displayName?: string; // Optional display name (for platforms)
14
17
  description?: string;
@@ -51,10 +54,10 @@ export interface PushButtonSelectorLazyLoad {
51
54
  maxHeight?: string;
52
55
  }
53
56
 
54
- interface PushButtonSelectorProps {
55
- options: SelectableOption[];
56
- selectedIds: string[];
57
- onSelectionChange: (selectedIds: string[]) => void;
57
+ interface PushButtonSelectorProps<Id extends SelectableOptionId = string> {
58
+ options: SelectableOption<Id>[];
59
+ selectedIds: Id[];
60
+ onSelectionChange: (selectedIds: Id[]) => void;
58
61
  multiSelect?: boolean;
59
62
  title?: string;
60
63
  helpText?: string;
@@ -116,7 +119,7 @@ function PushButtonSelectorError({ message, title }: { message: string; title?:
116
119
  );
117
120
  }
118
121
 
119
- export function PushButtonSelector({
122
+ export function PushButtonSelector<Id extends SelectableOptionId = string>({
120
123
  options,
121
124
  selectedIds,
122
125
  onSelectionChange,
@@ -134,7 +137,7 @@ export function PushButtonSelector({
134
137
  lazyLoad,
135
138
  emptyMessage,
136
139
  footer,
137
- }: PushButtonSelectorProps) {
140
+ }: PushButtonSelectorProps<Id>) {
138
141
  // Lazy-load sentinel: observe within the scroll viewport so onLoadMore
139
142
  // fires as the user approaches the bottom. Hooks run unconditionally
140
143
  // (before the early returns) per the rules of hooks. Pass a STABLE
@@ -209,7 +212,7 @@ export function PushButtonSelector({
209
212
  console.warn('[PushButtonSelector] Invalid selected IDs filtered:', invalidIds);
210
213
  }
211
214
 
212
- const toggleSelection = (optionId: string) => {
215
+ const toggleSelection = (optionId: Id) => {
213
216
  if (multiSelect) {
214
217
  const isSelected = validSelectedIds.includes(optionId);
215
218
  if (isSelected) {
@@ -226,7 +229,7 @@ export function PushButtonSelector({
226
229
  const getSelectedOptions = () => options.filter(option => validSelectedIds.includes(option.id));
227
230
 
228
231
  // Helper to render a single option
229
- const renderOption = (option: SelectableOption) => {
232
+ const renderOption = (option: SelectableOption<Id>) => {
230
233
  const isSelected = validSelectedIds.includes(option.id);
231
234
 
232
235
  const optionEl = (
@@ -297,8 +300,8 @@ export function PushButtonSelector({
297
300
  }
298
301
 
299
302
  // Group options by section
300
- const optionsBySection = new Map<string, SelectableOption[]>();
301
- const ungroupedOptions: SelectableOption[] = [];
303
+ const optionsBySection = new Map<string, SelectableOption<Id>[]>();
304
+ const ungroupedOptions: SelectableOption<Id>[] = [];
302
305
 
303
306
  options.forEach(option => {
304
307
  if (option.section) {
@@ -4,9 +4,10 @@ import { Trash2, Plus, Image as ImageIcon, Video as VideoIcon, Upload, Loader2,
4
4
  import { useState, useRef } from 'react';
5
5
  import type { ChangeEvent, DragEvent } from 'react';
6
6
  import Image from '../../embed-shims/next-image';
7
+ import { isVideoMedia } from '../../utils/media-type';
7
8
  import { Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui';
8
- import { Video } from './video';
9
9
 
10
+ import { Video } from './video';
10
11
  export interface ReleaseMediaItem {
11
12
  media_type: 'image' | 'video' | 'screenshot' | 'demo';
12
13
  media_url: string;
@@ -199,7 +200,7 @@ export function ReleaseMediaManager({ media, onChange, onUpload, className = ''
199
200
  {/* Media Preview */}
200
201
  {item.media_url && (
201
202
  <div className="relative aspect-video bg-ods-bg">
202
- {item.media_type === 'video' || item.media_type === 'demo' ? (
203
+ {isVideoMedia(item) || item.media_type === 'demo' ? (
203
204
  // <Video> SSOT (MuxPlayer) — plays Mux HLS + MP4 alike;
204
205
  // fit="cover" crops to the aspect-video cell.
205
206
  <Video kind="file" url={item.media_url} fit="cover" className="h-full w-full" />
@@ -34,6 +34,7 @@ import MuxPlayer from '@mux/mux-player-react';
34
34
  import type React from 'react';
35
35
  import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
36
36
  import { useAuthedAssetSrc } from '../../hooks/use-authed-asset-src';
37
+ import { useNearViewport } from '../../hooks/use-near-viewport';
37
38
  import { fetchPriorityProp } from '../../utils/fetch-priority';
38
39
  import { Button } from '../ui/button';
39
40
  import { useIosNativeVideoFullscreen } from './use-ios-native-video-fullscreen';
@@ -396,6 +397,14 @@ interface VideoCommonProps {
396
397
  className?: string;
397
398
  /** Accessible label (used as YT facade title; ignored for file branch). */
398
399
  title?: string;
400
+ /**
401
+ * Mount the player only once its box comes near the viewport (shared
402
+ * fire-once IntersectionObserver, `NEAR_VIEWPORT_ROOT_MARGIN` lookahead).
403
+ * Until then the box shows the poster (or nothing) — the caller's container
404
+ * still owns the size, so nothing shifts when the player mounts. For grids
405
+ * of players: a library of 20 clips costs 20 posters, not 20 players.
406
+ */
407
+ lazy?: boolean;
399
408
  /**
400
409
  * YouTube-only: hide YT player chrome (controls, info, fullscreen, related
401
410
  * videos, keyboard shortcuts). Used for marketing/landing-page embeds that
@@ -552,12 +561,31 @@ export type VideoProps = VideoFileProps | VideoYouTubeProps | VideoAutoProps;
552
561
  // =============================================================================
553
562
 
554
563
  export function Video(props: VideoProps): React.ReactElement | null {
564
+ // Hooks run unconditionally; the gate only applies when `lazy` is set.
565
+ const { ref: nearRef, isNear } = useNearViewport<HTMLDivElement>();
555
566
  const url = props.url;
556
567
  if (!url) return null;
557
568
 
558
569
  const effectiveKind = resolveKind(props, url);
559
570
  const layout = props.layout ?? 'native';
560
571
 
572
+ if (props.lazy && !isNear) {
573
+ // Same box, poster only: the observer target IS the reserved space.
574
+ return wrapWithLayout(
575
+ <div
576
+ ref={nearRef}
577
+ className={props.className}
578
+ style={
579
+ props.poster
580
+ ? { backgroundImage: `url(${props.poster})`, backgroundSize: 'cover', backgroundPosition: 'center' }
581
+ : undefined
582
+ }
583
+ aria-hidden="true"
584
+ />,
585
+ layout,
586
+ );
587
+ }
588
+
561
589
  const inner =
562
590
  effectiveKind === 'youtube' ? (
563
591
  <YouTubeFacade
@@ -0,0 +1,16 @@
1
+ import { render } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { UnifiedSkeleton } from '../unified-skeleton';
4
+
5
+ describe('UnifiedSkeleton sizing', () => {
6
+ it('applies the default height when nothing sizes the box', () => {
7
+ const { container } = render(<UnifiedSkeleton className="w-full" />);
8
+ expect(container.firstElementChild?.className).toMatch(/\bh-4\b/);
9
+ });
10
+
11
+ it('lets an aspect-* utility size the box instead of the default height', () => {
12
+ const { container } = render(<UnifiedSkeleton className="aspect-video w-full" />);
13
+ expect(container.firstElementChild?.className).not.toMatch(/\bh-4\b/);
14
+ expect(container.firstElementChild?.className).toMatch(/aspect-video/);
15
+ });
16
+ });
@@ -60,6 +60,10 @@ export function UnifiedSkeleton({
60
60
 
61
61
  const animationClasses = animate ? 'animate-pulse motion-reduce:animate-none' : '';
62
62
 
63
+ // An `aspect-*` utility sizes the box itself; a default height would win
64
+ // over it (explicit height beats aspect-ratio) and collapse the box to 16px.
65
+ const sizedByAspect = /(?:^|\s)aspect-/.test(className ?? '');
66
+
63
67
  const style: React.CSSProperties = {
64
68
  width: width,
65
69
  height: height,
@@ -70,7 +74,7 @@ export function UnifiedSkeleton({
70
74
  className={cn(
71
75
  baseClasses,
72
76
  variantClasses[variant],
73
- !height && !style.height && defaultHeights[variant],
77
+ !height && !style.height && !sizedByAspect && defaultHeights[variant],
74
78
  !width && !style.width && variant !== 'circular' && 'w-full',
75
79
  animationClasses,
76
80
  className,
@@ -0,0 +1,46 @@
1
+ import { act, render, screen } from '@testing-library/react';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { LazyMount } from '../lazy-mount';
4
+
5
+ type IOCallback = (entries: Array<{ isIntersecting: boolean; intersectionRatio: number; target: Element }>) => void;
6
+
7
+ describe('LazyMount', () => {
8
+ let callbacks: IOCallback[];
9
+ let observed: Element[];
10
+ beforeEach(() => {
11
+ callbacks = [];
12
+ observed = [];
13
+ vi.stubGlobal(
14
+ 'IntersectionObserver',
15
+ class {
16
+ constructor(cb: IOCallback) {
17
+ callbacks.push(cb);
18
+ }
19
+ observe(el: Element) {
20
+ observed.push(el);
21
+ }
22
+ unobserve() {}
23
+ disconnect() {}
24
+ },
25
+ );
26
+ });
27
+ afterEach(() => vi.unstubAllGlobals());
28
+
29
+ it('renders the placeholder until the box intersects, then the content, and stays mounted', () => {
30
+ render(
31
+ <LazyMount rootMargin="1px" placeholder={<span>placeholder</span>}>
32
+ <span>player</span>
33
+ </LazyMount>,
34
+ );
35
+ expect(screen.getByText('placeholder')).toBeTruthy();
36
+ expect(screen.queryByText('player')).toBeNull();
37
+ // The observed element IS the wrapper the hook registered — no DOM walking.
38
+ const target = observed[observed.length - 1];
39
+ const fire = callbacks[callbacks.length - 1];
40
+ act(() => fire([{ isIntersecting: false, intersectionRatio: 0, target }]));
41
+ expect(screen.queryByText('player')).toBeNull();
42
+ act(() => fire([{ isIntersecting: true, intersectionRatio: 1, target }]));
43
+ expect(screen.getByText('player')).toBeTruthy();
44
+ expect(screen.queryByText('placeholder')).toBeNull();
45
+ });
46
+ });
@@ -218,3 +218,6 @@ export * from './ticket-notes-section';
218
218
  export * from './marquee-wall';
219
219
  export * from './markdown';
220
220
  export * from './filter-pill-row';
221
+
222
+ // Viewport-gated mounting for heavy previews (players, large images)
223
+ export { LazyMount, type LazyMountProps } from './lazy-mount';
@@ -0,0 +1,38 @@
1
+ 'use client';
2
+
3
+ import type { CSSProperties, ReactNode } from 'react';
4
+ import { useNearViewport, NEAR_VIEWPORT_ROOT_MARGIN } from '../../hooks/use-near-viewport';
5
+
6
+ export interface LazyMountProps {
7
+ /** Mounted the first time the box comes within `rootMargin` of the viewport; never unmounted after. */
8
+ children: ReactNode;
9
+ /** What fills the box until then. The box itself must already have its final size (aspect-ratio / min-height). */
10
+ placeholder?: ReactNode;
11
+ className?: string;
12
+ style?: CSSProperties;
13
+ /** Lookahead — defaults to the shared `NEAR_VIEWPORT_ROOT_MARGIN`. */
14
+ rootMargin?: string;
15
+ }
16
+
17
+ /**
18
+ * Sized box whose CONTENT mounts only near the viewport (shared fire-once
19
+ * IntersectionObserver via `useNearViewport`). The box owns the layout —
20
+ * pass the aspect-ratio / min-height on it — so lazy mounting never shifts
21
+ * anything: the content appears inside a space that was already reserved.
22
+ * Grids of players or large images render their chrome immediately and pay
23
+ * for each preview only when it is about to be seen.
24
+ */
25
+ export function LazyMount({
26
+ children,
27
+ placeholder = null,
28
+ className,
29
+ style,
30
+ rootMargin = NEAR_VIEWPORT_ROOT_MARGIN,
31
+ }: LazyMountProps) {
32
+ const { ref, isNear } = useNearViewport<HTMLDivElement>(rootMargin);
33
+ return (
34
+ <div ref={ref} className={className} style={style}>
35
+ {isNear ? children : placeholder}
36
+ </div>
37
+ );
38
+ }
@@ -48,8 +48,7 @@ function LoginPage(initial: Partial<LoginFormProps>) {
48
48
  email={email}
49
49
  onEmailChange={setEmail}
50
50
  submitDisabled={!isValid}
51
- onSubmit={() => {}}
52
- onForgotPassword={() => {}}
51
+ onSubmitClick={() => {}}
53
52
  />
54
53
  ) : (
55
54
  <div className="flex min-h-[240px] items-center justify-center rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-xl)] text-ods-text-secondary text-h4">
@@ -70,9 +69,20 @@ export const Filled: Story = {
70
69
  render: () => <LoginPage email="roman@mail.com" />,
71
70
  };
72
71
 
73
- const SSO_PROVIDERS: AuthSsoProvider[] = ['openframe', 'google', 'microsoft', 'apple'];
72
+ /** The generic providers, which need nothing typed and render above the email field. */
73
+ const SSO_PROVIDERS: AuthSsoProvider[] = ['google', 'microsoft', 'apple'];
74
74
 
75
- /** SSO configured: email filled, submit replaced by provider buttons. */
75
+ /** Providers above the email field, before anything is typed. */
76
76
  export const SSO: Story = {
77
- render: () => <LoginPage email="roman@mail.com" ssoProviders={SSO_PROVIDERS} />,
77
+ render: () => <LoginPage ssoProviders={SSO_PROVIDERS} />,
78
+ };
79
+
80
+ /** Discovery resolved a tenant that has its own SSO: its options appear under the email. */
81
+ export const CustomSso: Story = {
82
+ render: () => <LoginPage email="roman@mail.com" ssoProviders={SSO_PROVIDERS} customSsoProviders={['openframe']} />,
83
+ };
84
+
85
+ /** Discovery resolved a tenant with none configured — the empty array is the answer, not silence. */
86
+ export const NoCustomSso: Story = {
87
+ render: () => <LoginPage email="roman@mail.com" ssoProviders={SSO_PROVIDERS} customSsoProviders={[]} />,
78
88
  };
@@ -51,7 +51,6 @@ const generatedPost: Partial<AIContent> = {
51
51
  status: 'draft',
52
52
  social_account_id: 7,
53
53
  url_injection_preference: 'as_comment',
54
- selected_for_publish: false,
55
54
  };
56
55
 
57
56
  const provider: MediaProvider = 'flux-pro';