chordia-ui 3.2.1 → 3.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/style.css CHANGED
@@ -1 +1 @@
1
- :root{--font-sans: "Averta", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-display: "Tomato Grotesk", "Averta", ui-sans-serif, system-ui, sans-serif;--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-font-family: var(--font-sans);--default-mono-font-family: var(--font-mono);--bg-chordia: #F4F1E6;--paper: rgba(255, 255, 255, .78);--paper-secondary: rgba(255, 255, 255, .55);--paper-elevated: rgba(255, 255, 255, .82);--paper-high: rgba(255, 255, 255, .85);--text-ink: #1E2125;--text-base: rgba(30, 33, 37, .78);--text-strong: rgba(30, 33, 37, .92);--text-muted: rgba(30, 33, 37, .56);--text-faint: rgba(30, 33, 37, .36);--text-subtle: rgba(30, 33, 37, .52);--text-xfaint: rgba(30, 33, 37, .28);--border: rgba(52, 58, 64, .12);--border-hover: rgba(52, 58, 64, .18);--border-strong: rgba(52, 58, 64, .22);--border-subtle: rgba(52, 58, 64, .08);--hover-warm: rgba(231, 212, 162, .12);--hover-warm-strong: rgba(231, 212, 162, .18);--hover-warm-subtle: rgba(231, 212, 162, .08);--hover-cool: rgba(210, 220, 235, .18);--focus: rgba(231, 212, 162, .55);--state-present: #25A372;--state-absent: rgba(30, 33, 37, .28);--state-unknown: #E7BF33;--rail-compliance: #C98A5A;--rail-orange: #C98A5A;--rail-tone: #9B7AA8;--rail-purple: #9B7AA8;--rail-discovery: #5E88B0;--rail-blue: #5E88B0;--rail-outcome: #6B7C93;--rail-slate: #6B7C93;--rail-signal-churn: #D17B6B;--rail-coral: #D17B6B;--rail-signal-upsell: #7BA89D;--rail-teal: #B8976A;--rail-signal-satisfaction: #9B8E6F;--rail-olive: #9B8E6F;--rail-quality: #8A9BAF;--card-customer: rgba(94, 136, 176, .06);--card-agent: var(--paper-elevated);--card-assistant: rgba(155, 122, 168, .05);--rail-width-thin: 4px;--rail-width: 5px;--rail-width-thick: 6px;--deviation-dot-size: 6px;--deviation-gap: 3px;--timestamp-bg: rgba(255, 255, 255, .7);--timestamp-border: rgba(52, 58, 64, .16);--bar-h: 3px;--bar-w: 45px;--bar-bg: rgba(30, 33, 37, .12);--bar-fill: rgba(30, 33, 37, .45);--tooltip-bg: rgba(30, 33, 37, .95);--tooltip-text: rgba(255, 255, 255, .95);--background: var(--bg-chordia);--foreground: var(--text-ink);--card: var(--paper-elevated);--card-foreground: var(--text-ink);--popover: var(--paper-high);--popover-foreground: var(--text-ink);--primary: #030213;--primary-foreground: #ffffff;--secondary: rgba(249, 250, 251, 1);--secondary-foreground: #030213;--accent: rgba(233, 235, 239, 1);--accent-foreground: #030213;--destructive: #C98A5A;--destructive-foreground: #ffffff;--input: transparent;--input-background: rgba(243, 243, 245, 1);--switch-background: rgba(203, 206, 212, 1);--ring: rgba(113, 113, 122, .5);--chart-1: #25A372;--chart-2: #44C090;--chart-3: #B7D89E;--chart-4: #E7BF33;--chart-5: #C98A5A;--sidebar: var(--paper-high);--sidebar-foreground: var(--text-ink);--sidebar-primary: #030213;--sidebar-primary-foreground: #ffffff;--sidebar-accent: var(--hover-warm-subtle);--sidebar-accent-foreground: var(--text-ink);--sidebar-border: var(--border);--sidebar-ring: var(--ring);--bg: var(--bg-chordia);--paper2: var(--paper-secondary);--ink: var(--text-ink);--text: var(--text-base);--muted: var(--text-muted);--faint: var(--text-faint);--b1: var(--border);--b2: var(--border-hover);--warm: var(--hover-warm);--cool: var(--hover-cool);--present: var(--state-present);--absent: var(--state-absent);--unknown: var(--state-unknown);--s1: var(--shadow-lg);--s2: var(--shadow-md);--r1: var(--radius-lg);--r2: var(--radius-md);--font-size: .875rem;--color-green: #00A66E;--color-green-hover: #009160;--color-green-disabled: #80d3b7;--color-green-ring: rgba(0, 166, 110, .12);--surface-dark: #1D1E20;--surface-card-dark: linear-gradient(180deg, #48473B 0%, #26261F 100%);--surface-card-dark-border: #333A42;--color-card-icon: #000000;--color-card-text: #ffffff;--color-text: #2E3236;--color-text-secondary: #808183;--color-text-inverse: #E8E6E1;--color-text-overlay: rgba(255, 255, 255, .5);--color-input-border: #ACADAD;--color-divider: #E5E7EB;--color-error: #E15448;--color-error-border: #ED8077;--color-error-ring: rgba(237, 128, 119, .12);--color-error-bg: #F3F7F7;--gradient-card-active: linear-gradient(160deg, #4A5330 0%, #252C14 100%);--gradient-card-inactive: linear-gradient(160deg, rgba(74, 83, 48, .25) 0%, rgba(37, 44, 20, .35) 100%)}.custom-thin-scrollbar-library::-webkit-scrollbar{width:4px;background:transparent;border-radius:2px}.custom-thin-scrollbar-library::-webkit-scrollbar-track{background:transparent}.custom-thin-scrollbar-library::-webkit-scrollbar-thumb{background:rgb(180,178,178);border-radius:2px}.custom-thin-scrollbar-library::-webkit-scrollbar-thumb:hover{background:var(--border-hover)}.custom-thin-scrollbar-library-hidden::-webkit-scrollbar{width:0px;background:transparent}.custom-thin-scrollbar-library-hidden::-webkit-scrollbar-track{background:transparent}.custom-thin-scrollbar-library-hidden::-webkit-scrollbar-thumb{background:transparent}.custom-thin-scrollbar-hidden{scrollbar-width:none;-ms-overflow-style:none}.custom-thin-scrollbar-ultra::-webkit-scrollbar{width:2px;background:transparent}.custom-thin-scrollbar-ultra::-webkit-scrollbar-track{background:transparent}.custom-thin-scrollbar-ultra::-webkit-scrollbar-thumb{background:var(--border);border-radius:1px}.custom-thin-scrollbar-ultra::-webkit-scrollbar-thumb:hover{background:var(--border-hover)}.custom-thin-scrollbar-ultra{scrollbar-width:thin;scrollbar-color:var(--border) transparent}
1
+ :root{--font-sans: "Averta", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-display: "Tomato Grotesk", "Averta", ui-sans-serif, system-ui, sans-serif;--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-font-family: var(--font-sans);--default-mono-font-family: var(--font-mono);--bg-chordia: #F4F1E6;--paper: rgba(255, 255, 255, .78);--paper-secondary: rgba(255, 255, 255, .55);--paper-elevated: rgba(255, 255, 255, .82);--paper-high: rgba(255, 255, 255, .85);--text-ink: #1E2125;--text-base: rgba(30, 33, 37, .78);--text-strong: rgba(30, 33, 37, .92);--text-muted: rgba(30, 33, 37, .56);--text-faint: rgba(30, 33, 37, .36);--text-subtle: rgba(30, 33, 37, .52);--text-xfaint: rgba(30, 33, 37, .28);--border: rgba(52, 58, 64, .12);--border-hover: rgba(52, 58, 64, .18);--border-strong: rgba(52, 58, 64, .22);--border-subtle: rgba(52, 58, 64, .08);--hover-warm: rgba(231, 212, 162, .12);--hover-warm-strong: rgba(231, 212, 162, .18);--hover-warm-subtle: rgba(231, 212, 162, .08);--hover-cool: rgba(210, 220, 235, .18);--focus: rgba(231, 212, 162, .55);--state-present: #25A372;--state-absent: rgba(30, 33, 37, .28);--state-unknown: #E7BF33;--rail-compliance: #C98A5A;--rail-orange: #C98A5A;--rail-tone: #9B7AA8;--rail-purple: #9B7AA8;--rail-discovery: #5E88B0;--rail-blue: #5E88B0;--rail-outcome: #6B7C93;--rail-slate: #6B7C93;--rail-signal-churn: #D17B6B;--rail-coral: #D17B6B;--rail-signal-upsell: #7BA89D;--rail-teal: #B8976A;--rail-signal-satisfaction: #9B8E6F;--rail-olive: #9B8E6F;--rail-quality: #8A9BAF;--card-customer: rgba(94, 136, 176, .06);--card-agent: var(--paper-elevated);--card-assistant: rgba(155, 122, 168, .05);--rail-width-thin: 4px;--rail-width: 5px;--rail-width-thick: 6px;--deviation-dot-size: 6px;--deviation-gap: 3px;--timestamp-bg: rgba(255, 255, 255, .7);--timestamp-border: rgba(52, 58, 64, .16);--bar-h: 3px;--bar-w: 45px;--bar-bg: rgba(30, 33, 37, .12);--bar-fill: rgba(30, 33, 37, .45);--tooltip-bg: rgba(30, 33, 37, .95);--tooltip-text: rgba(255, 255, 255, .95);--background: var(--bg-chordia);--foreground: var(--text-ink);--card: var(--paper-elevated);--card-foreground: var(--text-ink);--popover: var(--paper-high);--popover-foreground: var(--text-ink);--primary: #030213;--primary-foreground: #ffffff;--secondary: rgba(249, 250, 251, 1);--secondary-foreground: #030213;--accent: rgba(233, 235, 239, 1);--accent-foreground: #030213;--destructive: #C98A5A;--destructive-foreground: #ffffff;--input: transparent;--input-background: rgba(243, 243, 245, 1);--switch-background: rgba(203, 206, 212, 1);--ring: rgba(113, 113, 122, .5);--chart-1: #25A372;--chart-2: #44C090;--chart-3: #B7D89E;--chart-4: #E7BF33;--chart-5: #C98A5A;--sidebar: var(--paper-high);--sidebar-foreground: var(--text-ink);--sidebar-primary: #030213;--sidebar-primary-foreground: #ffffff;--sidebar-accent: var(--hover-warm-subtle);--sidebar-accent-foreground: var(--text-ink);--sidebar-border: var(--border);--sidebar-ring: var(--ring);--bg: var(--bg-chordia);--paper2: var(--paper-secondary);--ink: var(--text-ink);--text: var(--text-base);--muted: var(--text-muted);--faint: var(--text-faint);--b1: var(--border);--b2: var(--border-hover);--warm: var(--hover-warm);--cool: var(--hover-cool);--present: var(--state-present);--absent: var(--state-absent);--unknown: var(--state-unknown);--s1: var(--shadow-lg);--s2: var(--shadow-md);--r1: var(--radius-lg);--r2: var(--radius-md);--font-size: .875rem;--color-green: #00A66E;--color-green-hover: #009160;--color-green-disabled: #80d3b7;--color-green-ring: rgba(0, 166, 110, .12);--surface-dark: #1D1E20;--surface-card-dark: linear-gradient(180deg, #48473B 0%, #26261F 100%);--surface-card-dark-border: #333A42;--color-card-icon: #000000;--color-card-text: #ffffff;--color-text: #2E3236;--color-text-secondary: #808183;--color-text-inverse: #E8E6E1;--color-text-overlay: rgba(255, 255, 255, .5);--color-input-border: #ACADAD;--color-divider: #E5E7EB;--color-error: #E15448;--color-error-border: #ED8077;--color-error-ring: rgba(237, 128, 119, .12);--color-error-bg: #F3F7F7;--grey-white: #FFFFFF;--grey-absent: #D9D9D9;--grey-muted: #BDBDBD;--grey-strong: var(--color-text);--focus-2: #FAF5E9;--gradient-card-active: linear-gradient(160deg, #4A5330 0%, #252C14 100%);--gradient-card-inactive: linear-gradient(160deg, rgba(74, 83, 48, .25) 0%, rgba(37, 44, 20, .35) 100%)}.custom-thin-scrollbar-library::-webkit-scrollbar{width:4px;background:transparent;border-radius:2px}.custom-thin-scrollbar-library::-webkit-scrollbar-track{background:transparent}.custom-thin-scrollbar-library::-webkit-scrollbar-thumb{background:rgb(180,178,178);border-radius:2px}.custom-thin-scrollbar-library::-webkit-scrollbar-thumb:hover{background:var(--border-hover)}.custom-thin-scrollbar-library-hidden::-webkit-scrollbar{width:0px;background:transparent}.custom-thin-scrollbar-library-hidden::-webkit-scrollbar-track{background:transparent}.custom-thin-scrollbar-library-hidden::-webkit-scrollbar-thumb{background:transparent}.custom-thin-scrollbar-hidden{scrollbar-width:none;-ms-overflow-style:none}.custom-thin-scrollbar-ultra::-webkit-scrollbar{width:2px;background:transparent}.custom-thin-scrollbar-ultra::-webkit-scrollbar-track{background:transparent}.custom-thin-scrollbar-ultra::-webkit-scrollbar-thumb{background:var(--border);border-radius:1px}.custom-thin-scrollbar-ultra::-webkit-scrollbar-thumb:hover{background:var(--border-hover)}.custom-thin-scrollbar-ultra{scrollbar-width:thin;scrollbar-color:var(--border) transparent}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chordia-ui",
3
- "version": "3.2.1",
3
+ "version": "3.2.3",
4
4
  "description": "Chordia Design System - UI components, tokens, and Tailwind preset",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs.js",
@@ -162,6 +162,8 @@ export default function LoginPage({
162
162
  }) {
163
163
  const [email, setEmail] = useState('');
164
164
  const [password, setPassword] = useState('');
165
+ const [emailAutofilled, setEmailAutofilled] = useState(false);
166
+ const [passwordAutofilled, setPasswordAutofilled] = useState(false);
165
167
  const [showPassword, setShowPassword] = useState(false);
166
168
  const [internalLoading, setInternalLoading] = useState(false);
167
169
  const [internalError, setInternalError] = useState(null);
@@ -191,17 +193,88 @@ export default function LoginPage({
191
193
  const emailRef = useRef(null);
192
194
  const passwordRef = useRef(null);
193
195
  useEffect(() => {
196
+ const isAutofilled = (el) => {
197
+ if (!el) return false;
198
+ try {
199
+ return el.matches(':-webkit-autofill, :autofill');
200
+ } catch {
201
+ // Some browsers may throw for unsupported pseudo-class selectors.
202
+ return false;
203
+ }
204
+ };
205
+
194
206
  const sync = () => {
195
207
  const e = emailRef.current;
196
208
  const p = passwordRef.current;
197
- if (e && e.value && !email) setEmail(e.value);
198
- if (p && p.value && !password) setPassword(p.value);
209
+ if (e && e.value) setEmail(e.value);
210
+ if (p && p.value) setPassword(p.value);
211
+ setEmailAutofilled(isAutofilled(e));
212
+ setPasswordAutofilled(isAutofilled(p));
213
+ };
214
+ // Poll for autofill values at multiple intervals
215
+ const timers = [100, 300, 600, 1000, 2000].map(ms => setTimeout(sync, ms));
216
+
217
+ // Detect Chrome/Safari autofill via the :-webkit-autofill animation
218
+ const handleAnimationStart = (e) => {
219
+ if (e.animationName === 'onAutoFillStart' || e.target.matches(':-webkit-autofill')) {
220
+ setTimeout(sync, 50);
221
+ }
222
+ };
223
+ const eEl = emailRef.current;
224
+ const pEl = passwordRef.current;
225
+ eEl?.addEventListener('animationstart', handleAnimationStart);
226
+ pEl?.addEventListener('animationstart', handleAnimationStart);
227
+ // Listen for events commonly used by password managers/autofill
228
+ const onInput = () => setTimeout(sync, 0);
229
+ const onChange = () => setTimeout(sync, 0);
230
+ eEl?.addEventListener('input', onInput);
231
+ pEl?.addEventListener('input', onInput);
232
+ eEl?.addEventListener('change', onChange);
233
+ pEl?.addEventListener('change', onChange);
234
+
235
+ return () => {
236
+ timers.forEach(clearTimeout);
237
+ eEl?.removeEventListener('animationstart', handleAnimationStart);
238
+ pEl?.removeEventListener('animationstart', handleAnimationStart);
239
+ eEl?.removeEventListener('input', onInput);
240
+ pEl?.removeEventListener('input', onInput);
241
+ eEl?.removeEventListener('change', onChange);
242
+ pEl?.removeEventListener('change', onChange);
199
243
  };
200
- // Check multiple times as autofill timing varies across browsers
201
- const timers = [100, 500, 1000, 2000].map(ms => setTimeout(sync, ms));
202
- return () => timers.forEach(clearTimeout);
203
244
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
204
245
 
246
+ useEffect(() => {
247
+ const isAutofilled = (el) => {
248
+ if (!el) return false;
249
+ try {
250
+ return el.matches(':-webkit-autofill, :autofill');
251
+ } catch {
252
+ return false;
253
+ }
254
+ };
255
+
256
+ const syncIfNeeded = () => {
257
+ const nextEmail = emailRef.current?.value || '';
258
+ const nextPassword = passwordRef.current?.value || '';
259
+
260
+ if (nextEmail && nextEmail !== email) setEmail(nextEmail);
261
+ if (nextPassword && nextPassword !== password) setPassword(nextPassword);
262
+ setEmailAutofilled(isAutofilled(emailRef.current));
263
+ setPasswordAutofilled(isAutofilled(passwordRef.current));
264
+ };
265
+
266
+ // Some browsers/password managers update input values without firing events.
267
+ const intervalId = setInterval(syncIfNeeded, 250);
268
+ window.addEventListener('focus', syncIfNeeded);
269
+ document.addEventListener('visibilitychange', syncIfNeeded);
270
+
271
+ return () => {
272
+ clearInterval(intervalId);
273
+ window.removeEventListener('focus', syncIfNeeded);
274
+ document.removeEventListener('visibilitychange', syncIfNeeded);
275
+ };
276
+ }, [email, password]);
277
+
205
278
  // Switch to full-name screen when host app signals unregistered email
206
279
  useEffect(() => {
207
280
  if (codeError && view === 'verifycode') {
@@ -212,7 +285,12 @@ export default function LoginPage({
212
285
 
213
286
  const loading = externalLoading ?? internalLoading;
214
287
  const error = externalError ?? internalError;
215
- const canSubmit = email && password && !loading;
288
+ const currentEmail = (email || emailRef.current?.value || '').trim();
289
+ const currentPassword = password || passwordRef.current?.value || '';
290
+ const hasEmail = Boolean(currentEmail || emailAutofilled);
291
+ const hasPassword = Boolean(currentPassword || passwordAutofilled);
292
+ const canSubmit = Boolean(hasEmail && hasPassword && !loading);
293
+ const otpError = (view === 'verifycode' || view === 'getcodewithname') ? error : null;
216
294
 
217
295
  // Error-aware focus handlers for sign-in fields
218
296
  const focusErr = (e) => {
@@ -224,8 +302,32 @@ export default function LoginPage({
224
302
  e.target.style.boxShadow = 'none';
225
303
  };
226
304
 
305
+ const applyOtpFromString = (startIndex, rawValue) => {
306
+ const clean = (rawValue || '').replace(/\D/g, '');
307
+ if (!clean) return;
308
+
309
+ const next = [...otpDigits];
310
+ let lastFilledIndex = startIndex;
311
+
312
+ for (let i = 0; i < clean.length && startIndex + i < next.length; i += 1) {
313
+ next[startIndex + i] = clean[i];
314
+ lastFilledIndex = startIndex + i;
315
+ }
316
+
317
+ setOtpDigits(next);
318
+
319
+ const nextIndex = Math.min(lastFilledIndex + 1, next.length - 1);
320
+ otpRefs.current[nextIndex]?.focus();
321
+ };
322
+
227
323
  const handleOtpChange = (index, value) => {
228
- const digit = value.replace(/\D/g, '').slice(-1);
324
+ const clean = value.replace(/\D/g, '');
325
+ if (clean.length > 1) {
326
+ applyOtpFromString(index, clean);
327
+ return;
328
+ }
329
+
330
+ const digit = clean.slice(-1);
229
331
  const next = [...otpDigits];
230
332
  next[index] = digit;
231
333
  setOtpDigits(next);
@@ -238,13 +340,23 @@ export default function LoginPage({
238
340
  }
239
341
  };
240
342
 
343
+ const handleOtpPaste = (index, e) => {
344
+ e.preventDefault();
345
+ const pasted = e.clipboardData?.getData('text') || '';
346
+ applyOtpFromString(index, pasted);
347
+ };
348
+
241
349
  const handleSubmit = async (e) => {
242
350
  e.preventDefault();
243
351
  if (!canSubmit) return;
352
+ // Read directly from DOM if React state missed autofill
353
+ const submitEmail = email || emailRef.current?.value || '';
354
+ const submitPassword = password || passwordRef.current?.value || '';
355
+ if (!submitEmail || !submitPassword) return;
244
356
  setInternalError(null);
245
357
  setInternalLoading(true);
246
358
  try {
247
- await onLogin?.(email, password);
359
+ await onLogin?.(submitEmail, submitPassword);
248
360
  } catch (err) {
249
361
  setInternalError(err?.message || 'Something went wrong. Please try again.');
250
362
  } finally {
@@ -487,17 +599,18 @@ export default function LoginPage({
487
599
 
488
600
  <div style={{ display: 'flex', flexDirection: 'column', width: '100%', gap: 16 }}>
489
601
  <Field label="Enter one-time code" gap={12}>
490
- <div style={{ display: 'flex', gap: 12 }}>
602
+ <div style={{ display: 'flex', gap: 8 }}>
491
603
  {otpDigits.map((digit, i) => (
492
604
  <input key={i} ref={(el) => { otpRefs.current[i] = el; }}
493
605
  type="text" inputMode="numeric" maxLength={1} value={digit}
494
606
  onChange={(e) => handleOtpChange(i, e.target.value)}
495
607
  onKeyDown={(e) => handleOtpKeyDown(i, e)}
608
+ onPaste={(e) => handleOtpPaste(i, e)}
496
609
  autoFocus={i === 0}
497
610
  style={{
498
- width: 56, height: 56, textAlign: 'center', fontSize: 20, fontWeight: 600,
611
+ flex: 1, minWidth: 0, height: 40, textAlign: 'center', fontSize: 16, fontWeight: 600,
499
612
  fontFamily: FF, color: 'var(--color-text)', border: '1px solid var(--color-input-border)',
500
- borderRadius: 8, outline: 'none', background: 'white', boxSizing: 'border-box',
613
+ borderRadius: 4, outline: 'none', background: 'white', boxSizing: 'border-box',
501
614
  transition: 'border-color 0.15s, box-shadow 0.15s', caretColor: GREEN,
502
615
  }}
503
616
  onFocus={focusGreen} onBlur={blurGray}
@@ -509,6 +622,14 @@ export default function LoginPage({
509
622
  <GreenButton onClick={() => onVerifyCode?.(verifyEmail, otpDigits.join(''))} disabled={otpDigits.some(d => !d)}>
510
623
  Verify Code
511
624
  </GreenButton>
625
+ {otpError && (
626
+ <div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', padding: 8, gap: 6, width: '100%', boxSizing: 'border-box', background: 'var(--color-error-bg)', borderRadius: 5, fontSize: 15, fontWeight: 400, lineHeight: '22px', color: 'var(--color-text)', fontFamily: FF }}>
627
+ <svg width="17" height="17" viewBox="0 0 24 24" fill="var(--color-text)" style={{ flexShrink: 0 }}>
628
+ <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
629
+ </svg>
630
+ {otpError}
631
+ </div>
632
+ )}
512
633
  <Divider />
513
634
  <NavRow text="Not received yet?" linkText="Resend" onClick={() => onResendCode?.(verifyEmail)} />
514
635
  <TermsFooter onTerms={onTerms} onPrivacyPolicy={onPrivacyPolicy} paddingTop={40} />
@@ -554,17 +675,18 @@ export default function LoginPage({
554
675
  </Field>
555
676
 
556
677
  <Field label="Enter one-time code" gap={12}>
557
- <div style={{ display: 'flex', gap: 12 }}>
678
+ <div style={{ display: 'flex', gap: 8 }}>
558
679
  {otpDigits.map((digit, i) => (
559
680
  <input key={i} ref={(el) => { otpRefs.current[i] = el; }}
560
681
  type="text" inputMode="numeric" maxLength={1} value={digit}
561
682
  onChange={(e) => handleOtpChange(i, e.target.value)}
562
683
  onKeyDown={(e) => handleOtpKeyDown(i, e)}
684
+ onPaste={(e) => handleOtpPaste(i, e)}
563
685
  autoFocus={i === 0}
564
686
  style={{
565
- width: 56, height: 56, textAlign: 'center', fontSize: 20, fontWeight: 600,
687
+ flex: 1, minWidth: 0, height: 40, textAlign: 'center', fontSize: 16, fontWeight: 600,
566
688
  fontFamily: FF, color: 'var(--color-text)', border: '1px solid var(--color-input-border)',
567
- borderRadius: 8, outline: 'none', background: 'white', boxSizing: 'border-box',
689
+ borderRadius: 4, outline: 'none', background: 'white', boxSizing: 'border-box',
568
690
  transition: 'border-color 0.15s, box-shadow 0.15s', caretColor: GREEN,
569
691
  }}
570
692
  onFocus={focusGreen} onBlur={blurGray}
@@ -576,6 +698,14 @@ export default function LoginPage({
576
698
  <GreenButton onClick={() => { setVerifyEmail(codeEmail); onOneTimeCode?.(codeEmail, codeFullName, otpDigits.join('')); }} disabled={!codeEmail || !codeFullName || otpDigits.some(d => !d)}>
577
699
  Verify & Sign In
578
700
  </GreenButton>
701
+ {otpError && (
702
+ <div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', padding: 8, gap: 6, width: '100%', boxSizing: 'border-box', background: 'var(--color-error-bg)', borderRadius: 5, fontSize: 15, fontWeight: 400, lineHeight: '22px', color: 'var(--color-text)', fontFamily: FF }}>
703
+ <svg width="17" height="17" viewBox="0 0 24 24" fill="var(--color-text)" style={{ flexShrink: 0 }}>
704
+ <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
705
+ </svg>
706
+ {otpError}
707
+ </div>
708
+ )}
579
709
  <Divider />
580
710
  <NavRow text="I have my password" linkText="Back to Sign in" onClick={() => { setView('signin'); onGoToLogin?.(); }} />
581
711
  <TermsFooter onTerms={onTerms} onPrivacyPolicy={onPrivacyPolicy} paddingTop={40} />
@@ -660,7 +790,7 @@ export default function LoginPage({
660
790
  </div>
661
791
 
662
792
  <NavRow text="Not a member yet?" linkText="Sign Up" onClick={() => { setView('signup'); onSignUp?.(); }} />
663
- <TermsFooter onTerms={onTerms} onPrivacyPolicy={onPrivacyPolicy} paddingTop={12} />
793
+ <TermsFooter onTerms={onTerms} onPrivacyPolicy={onPrivacyPolicy} paddingTop={16} />
664
794
  </div>
665
795
  </div>
666
796
  )}
@@ -0,0 +1,89 @@
1
+ import IntegrationCard from '../layout/IntegrationCard';
2
+
3
+ const FF = 'var(--font-sans)';
4
+
5
+ // ─── Styles ───
6
+
7
+ const containerStyle = {
8
+ fontFamily: FF,
9
+ };
10
+
11
+ const sectionTitleStyle = {
12
+ fontSize: 20,
13
+ fontWeight: 600,
14
+ fontStyle: 'normal',
15
+ fontFamily: FF,
16
+ color: 'var(--grey-strong)',
17
+ margin: 0,
18
+ lineHeight: 'normal',
19
+ };
20
+
21
+ const sectionSubtitleStyle = {
22
+ fontSize: 13,
23
+ fontWeight: 400,
24
+ fontStyle: 'normal',
25
+ color: 'var(--color-text-secondary)',
26
+ fontFamily: FF,
27
+ margin: '4px 0 0',
28
+ lineHeight: '140%',
29
+ };
30
+
31
+ const gridStyle = {
32
+ display: 'grid',
33
+ gridTemplateColumns: 'repeat(3, 1fr)',
34
+ gap: 16,
35
+ marginTop: 24,
36
+ };
37
+
38
+ // ─── Default Integrations ───
39
+
40
+ const DEFAULT_INTEGRATIONS = [
41
+ {
42
+ providerName: 'Five9',
43
+ description: 'Cloud contact center platform for voice and digital channels',
44
+ status: 'connected',
45
+ railColor: '#5E88B0',
46
+ },
47
+ {
48
+ providerName: 'Twilio Flex',
49
+ description: 'Programmable contact center with custom workflows',
50
+ status: 'available',
51
+ railColor: '#9B7AA8',
52
+ },
53
+ {
54
+ providerName: 'Zoom Phone',
55
+ description: 'Cloud phone system with recording capabilities',
56
+ status: 'coming-soon',
57
+ railColor: '#6B7C93',
58
+ },
59
+ ];
60
+
61
+ // ─── Component ───
62
+
63
+ const ConnectData = ({ integrations = DEFAULT_INTEGRATIONS, onConfigure }) => {
64
+ return (
65
+ <div style={containerStyle}>
66
+ <h2 style={sectionTitleStyle}>Connect Data Source</h2>
67
+ <p style={sectionSubtitleStyle}>
68
+ Choose your preferred platforms to synchronise data.
69
+ </p>
70
+
71
+ <div style={gridStyle}>
72
+ {integrations.map((integration) => (
73
+ <IntegrationCard
74
+ key={integration.providerName}
75
+ providerName={integration.providerName}
76
+ description={integration.description}
77
+ status={integration.status}
78
+ railColor={integration.railColor}
79
+ logoUrl={integration.logoUrl}
80
+ icon={integration.icon}
81
+ onConfigure={() => onConfigure?.(integration)}
82
+ />
83
+ ))}
84
+ </div>
85
+ </div>
86
+ );
87
+ };
88
+
89
+ export default ConnectData;