@huaqiu/dsh-auth 0.4.13 → 0.5.2

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.
@@ -26,7 +26,7 @@
26
26
  import { memo, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type CSSProperties } from 'react'
27
27
  import { createPortal } from 'react-dom'
28
28
  import { getAuth, getAuthState, getHqEdge, setProfile, subscribeAuth } from '../auth-state.js'
29
- import { buildProfileUrl } from '../lib.js'
29
+ import { buildProfileUrl, ERC_PRODUCT_CODE, GEN_PRODUCT_CODE, type SubscriptionQuota } from '../lib.js'
30
30
  import { useIsDark, useLocale } from '../ui-env.js'
31
31
  import { useT } from '../i18n.js'
32
32
  import { HQ_ICON } from './hq-icon.jsx'
@@ -49,6 +49,25 @@ interface Palette {
49
49
  dangerHover: string
50
50
  avatarBg: string
51
51
  shadow: string
52
+ /** Profile-header card background (prototype `bg-white/[0.03]`). */
53
+ card: string
54
+ /** 1px hairline used on cards inside the popup. */
55
+ cardBorder: string
56
+ /** Quota metric card background (prototype `bg-black/20`). */
57
+ metric: string
58
+ metricHover: string
59
+ /** Huaqiu brand red (#e62e2d). */
60
+ primary: string
61
+ primaryHover: string
62
+ /** Badge tint: primary at ~10% (prototype `bg-hq-primary/10`). */
63
+ primarySoft: string
64
+ /** Progress-bar track (prototype `dark:bg-gray-800`). */
65
+ track: string
66
+ /** GEN / ERC progress gradients (prototype blue→indigo, purple→pink). */
67
+ genGradient: string
68
+ ercGradient: string
69
+ /** "Online" dot green. */
70
+ online: string
52
71
  }
53
72
 
54
73
  const LIGHT_PALETTE: Palette = {
@@ -60,19 +79,41 @@ const LIGHT_PALETTE: Palette = {
60
79
  danger: 'var(--dsw-alias-state-error-primary, #d4380d)',
61
80
  dangerHover: 'rgba(216, 56, 13, 0.08)',
62
81
  avatarBg: 'var(--dsw-alias-bg-layer-2, #eef2f7)',
63
- shadow: '0 12px 32px rgba(15, 23, 42, 0.16)',
82
+ shadow: '0 20px 40px -15px rgba(0, 0, 0, 0.12), 0 0 1px 1px rgba(0, 0, 0, 0.05)',
83
+ card: '#f7f8fa',
84
+ cardBorder: '#e8ebf0',
85
+ metric: '#f7f8fa',
86
+ metricHover: '#eef1f5',
87
+ primary: '#e62e2d',
88
+ primaryHover: '#cc2423',
89
+ primarySoft: 'rgba(230, 46, 45, 0.08)',
90
+ track: '#e4e7ec',
91
+ genGradient: 'linear-gradient(90deg, #3b82f6, #6366f1)',
92
+ ercGradient: 'linear-gradient(90deg, #a855f7, #ec4899)',
93
+ online: '#10b981',
64
94
  }
65
95
 
66
96
  const DARK_PALETTE: Palette = {
67
- surface: 'var(--dsw-alias-bg-overlay, #20242c)',
68
- border: 'var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.14))',
97
+ surface: 'var(--dsw-alias-bg-overlay, #1e1b24)',
98
+ border: 'var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.10))',
69
99
  text: 'var(--dsw-alias-label-primary, #e6eaf0)',
70
100
  muted: 'var(--dsw-alias-label-secondary, #8b95a5)',
71
101
  hover: 'var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, 0.08))',
72
102
  danger: 'var(--dsw-alias-state-error-primary, #ff7875)',
73
103
  dangerHover: 'rgba(255, 120, 117, 0.14)',
74
104
  avatarBg: 'var(--dsw-alias-bg-layer-2, rgba(255, 255, 255, 0.10))',
75
- shadow: '0 12px 32px rgba(0, 0, 0, 0.46)',
105
+ shadow: '0 20px 40px -15px rgba(0, 0, 0, 0.7), 0 0 1px 1px rgba(255, 255, 255, 0.1)',
106
+ card: 'rgba(255, 255, 255, 0.03)',
107
+ cardBorder: 'rgba(255, 255, 255, 0.08)',
108
+ metric: 'rgba(0, 0, 0, 0.20)',
109
+ metricHover: 'rgba(0, 0, 0, 0.30)',
110
+ primary: '#e62e2d',
111
+ primaryHover: '#cc2423',
112
+ primarySoft: 'rgba(230, 46, 45, 0.10)',
113
+ track: 'rgba(255, 255, 255, 0.10)',
114
+ genGradient: 'linear-gradient(90deg, #3b82f6, #6366f1)',
115
+ ercGradient: 'linear-gradient(90deg, #a855f7, #ec4899)',
116
+ online: '#10b981',
76
117
  }
77
118
 
78
119
  const TRIGGER_BASE: CSSProperties = {
@@ -95,11 +136,11 @@ const TRIGGER_BASE: CSSProperties = {
95
136
  const MENU_BASE: CSSProperties = {
96
137
  position: 'fixed',
97
138
  zIndex: 2147483000,
98
- minWidth: 184,
99
- padding: 6,
139
+ width: 320,
140
+ padding: 12,
100
141
  borderWidth: 1,
101
142
  borderStyle: 'solid',
102
- borderRadius: 12,
143
+ borderRadius: 16,
103
144
  fontFamily: 'inherit',
104
145
  fontSize: 13,
105
146
  }
@@ -129,17 +170,21 @@ const MENU_ITEM_BASE: CSSProperties = {
129
170
 
130
171
  /**
131
172
  * One menu row. Hover is tracked in state: the client bundle ships no CSS
132
- * file, so inline styles cannot express `:hover`.
173
+ * file, so inline styles cannot express `:hover`. `trailing` renders a
174
+ * chevron on the right (prototype nav rows), `danger` switches to the danger
175
+ * color scheme (prototype logout).
133
176
  */
134
177
  function MenuItem({
135
178
  label,
136
179
  icon,
180
+ trailing,
137
181
  danger,
138
182
  palette,
139
183
  onSelect,
140
184
  }: {
141
185
  label: string
142
186
  icon: React.JSX.Element
187
+ trailing?: React.JSX.Element
143
188
  danger?: boolean
144
189
  palette: Palette
145
190
  onSelect: () => void
@@ -151,6 +196,7 @@ function MenuItem({
151
196
  role="menuitem"
152
197
  style={{
153
198
  ...MENU_ITEM_BASE,
199
+ justifyContent: 'space-between',
154
200
  color: danger ? palette.danger : palette.text,
155
201
  background: hovered ? (danger ? palette.dangerHover : palette.hover) : 'transparent',
156
202
  }}
@@ -158,8 +204,21 @@ function MenuItem({
158
204
  onMouseLeave={() => setHovered(false)}
159
205
  onClick={onSelect}
160
206
  >
161
- {icon}
162
- <span>{label}</span>
207
+ <span style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
208
+ {icon}
209
+ <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
210
+ </span>
211
+ {trailing ? (
212
+ <span
213
+ style={{
214
+ display: 'flex',
215
+ flex: '0 0 auto',
216
+ color: hovered ? (danger ? palette.danger : palette.text) : palette.muted,
217
+ }}
218
+ >
219
+ {trailing}
220
+ </span>
221
+ ) : null}
163
222
  </button>
164
223
  )
165
224
  }
@@ -207,6 +266,308 @@ function LogoutIcon(): React.JSX.Element {
207
266
  )
208
267
  }
209
268
 
269
+ /** GEN metric icon (lucide `cpu`, blue like the prototype). */
270
+ function CpuIcon(): React.JSX.Element {
271
+ return (
272
+ <svg
273
+ xmlns="http://www.w3.org/2000/svg"
274
+ width={13}
275
+ height={13}
276
+ viewBox="0 0 24 24"
277
+ fill="none"
278
+ stroke="#3b82f6"
279
+ strokeWidth={2}
280
+ strokeLinecap="round"
281
+ strokeLinejoin="round"
282
+ aria-hidden
283
+ style={{ flex: '0 0 auto' }}
284
+ >
285
+ <rect x="4" y="4" width="16" height="16" rx="2" />
286
+ <rect x="9" y="9" width="6" height="6" />
287
+ <path d="M9 2v2M15 2v2M9 20v2M15 20v2M2 9h2M2 15h2M20 9h2M20 15h2" />
288
+ </svg>
289
+ )
290
+ }
291
+
292
+ /** ERC metric icon (lucide `waves`, purple like the prototype). */
293
+ function WaveIcon(): React.JSX.Element {
294
+ return (
295
+ <svg
296
+ xmlns="http://www.w3.org/2000/svg"
297
+ width={13}
298
+ height={13}
299
+ viewBox="0 0 24 24"
300
+ fill="none"
301
+ stroke="#a855f7"
302
+ strokeWidth={2}
303
+ strokeLinecap="round"
304
+ strokeLinejoin="round"
305
+ aria-hidden
306
+ style={{ flex: '0 0 auto' }}
307
+ >
308
+ <path d="M2 12c1.5-3 3-3 4.5 0s3 3 4.5 0 3-3 4.5 0 3 3 4.5 0" />
309
+ <path d="M2 17c1.5-3 3-3 4.5 0s3 3 4.5 0 3-3 4.5 0 3 3 4.5 0" />
310
+ </svg>
311
+ )
312
+ }
313
+
314
+ /** Upgrade-button crown (solid amber like the prototype). */
315
+ function CrownIcon(): React.JSX.Element {
316
+ return (
317
+ <svg
318
+ xmlns="http://www.w3.org/2000/svg"
319
+ width={14}
320
+ height={14}
321
+ viewBox="0 0 24 24"
322
+ fill="#fcd34d"
323
+ aria-hidden
324
+ style={{ flex: '0 0 auto' }}
325
+ >
326
+ <path d="M2 18h20l-1.5-9-5.5 4L12 5.5 9 13l-5.5-4L2 18z" />
327
+ </svg>
328
+ )
329
+ }
330
+
331
+ /** Trailing chevron for nav rows (lucide `chevron-right`). */
332
+ function ChevronRightIcon(): React.JSX.Element {
333
+ return (
334
+ <svg
335
+ xmlns="http://www.w3.org/2000/svg"
336
+ width={12}
337
+ height={12}
338
+ viewBox="0 0 24 24"
339
+ fill="none"
340
+ stroke="currentColor"
341
+ strokeWidth={2}
342
+ strokeLinecap="round"
343
+ strokeLinejoin="round"
344
+ aria-hidden
345
+ style={{ flex: '0 0 auto' }}
346
+ >
347
+ <polyline points="9 18 15 12 9 6" />
348
+ </svg>
349
+ )
350
+ }
351
+
352
+ /** Quota metric card: icon + label, mono count and a gradient progress bar. */
353
+ function QuotaMetric({
354
+ label,
355
+ icon,
356
+ quota,
357
+ gradient,
358
+ palette,
359
+ }: {
360
+ label: string
361
+ icon: React.JSX.Element
362
+ quota: SubscriptionQuota | null
363
+ gradient: string
364
+ palette: Palette
365
+ }): React.JSX.Element {
366
+ const t = useT()
367
+ const [hovered, setHovered] = useState(false)
368
+ const used = quota?.currentQuota
369
+ const total = quota?.totalQuota
370
+ const percent = quota?.hasSubscription && typeof total === 'number' && total > 0
371
+ ? Math.max(0, Math.min(100, Math.round(((used ?? 0) / total) * 100)))
372
+ : 0
373
+ const value = quota
374
+ ? (quota.hasSubscription
375
+ ? (typeof total === 'number'
376
+ ? t('subscription.quotaCount', { used: used ?? 0, total })
377
+ : String(used ?? 0))
378
+ : t('subscription.unsubscribed'))
379
+ : '—'
380
+ return (
381
+ <div
382
+ role="presentation"
383
+ style={{
384
+ background: hovered ? palette.metricHover : palette.metric,
385
+ borderRadius: 12,
386
+ border: `1px solid ${palette.cardBorder}`,
387
+ padding: 10,
388
+ boxSizing: 'border-box',
389
+ transition: 'background 0.15s ease',
390
+ }}
391
+ onMouseEnter={() => setHovered(true)}
392
+ onMouseLeave={() => setHovered(false)}
393
+ >
394
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, fontSize: 12, marginBottom: 6 }}>
395
+ <span style={{ display: 'flex', alignItems: 'center', gap: 6, fontWeight: 500, color: palette.text, minWidth: 0 }}>
396
+ {icon}
397
+ <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
398
+ </span>
399
+ <span
400
+ style={{
401
+ fontWeight: 700,
402
+ color: quota?.hasSubscription ? palette.text : palette.danger,
403
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
404
+ fontSize: 12,
405
+ whiteSpace: 'nowrap',
406
+ flexShrink: 0,
407
+ }}
408
+ title={value}
409
+ >
410
+ {value}
411
+ </span>
412
+ </div>
413
+ <div style={{ width: '100%', height: 6, borderRadius: 999, background: palette.track, overflow: 'hidden' }}>
414
+ <div style={{ width: `${percent}%`, height: '100%', borderRadius: 999, background: gradient }} />
415
+ </div>
416
+ </div>
417
+ )
418
+ }
419
+
420
+ /**
421
+ * Profile header card (prototype): name + plan badge + phone line on the
422
+ * left, an "online" pill on the right. No redundant avatar — the trigger
423
+ * already shows it.
424
+ */
425
+ function ProfileHeaderCard({
426
+ displayName,
427
+ packageName,
428
+ phone,
429
+ palette,
430
+ }: {
431
+ displayName: string
432
+ packageName: string | null
433
+ phone: string | null
434
+ palette: Palette
435
+ }): React.JSX.Element {
436
+ const t = useT()
437
+ return (
438
+ <div
439
+ role="presentation"
440
+ style={{
441
+ padding: 10,
442
+ borderRadius: 12,
443
+ background: palette.card,
444
+ border: `1px solid ${palette.cardBorder}`,
445
+ display: 'flex',
446
+ alignItems: 'center',
447
+ justifyContent: 'space-between',
448
+ gap: 8,
449
+ marginBottom: 12,
450
+ boxSizing: 'border-box',
451
+ }}
452
+ >
453
+ <div style={{ minWidth: 0, flex: '1 1 auto' }}>
454
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
455
+ <span
456
+ style={{
457
+ fontSize: 14,
458
+ fontWeight: 700,
459
+ color: palette.text,
460
+ overflow: 'hidden',
461
+ textOverflow: 'ellipsis',
462
+ whiteSpace: 'nowrap',
463
+ }}
464
+ >
465
+ {displayName}
466
+ </span>
467
+ {packageName ? (
468
+ <span
469
+ style={{
470
+ fontSize: 10,
471
+ background: palette.primarySoft,
472
+ color: palette.primary,
473
+ padding: '2px 6px',
474
+ borderRadius: 4,
475
+ fontWeight: 500,
476
+ border: `1px solid ${palette.primarySoft}`,
477
+ whiteSpace: 'nowrap',
478
+ flexShrink: 0,
479
+ }}
480
+ >
481
+ {packageName}
482
+ </span>
483
+ ) : null}
484
+ </div>
485
+ {phone ? (
486
+ <p
487
+ style={{
488
+ margin: 0,
489
+ marginTop: 2,
490
+ fontSize: 12,
491
+ color: palette.muted,
492
+ overflow: 'hidden',
493
+ textOverflow: 'ellipsis',
494
+ whiteSpace: 'nowrap',
495
+ }}
496
+ >
497
+ {phone}
498
+ </p>
499
+ ) : null}
500
+ </div>
501
+ <span
502
+ style={{
503
+ fontSize: 10,
504
+ background: 'rgba(16, 185, 129, 0.10)',
505
+ color: palette.online,
506
+ padding: '2px 8px',
507
+ borderRadius: 999,
508
+ fontWeight: 500,
509
+ border: '1px solid rgba(16, 185, 129, 0.20)',
510
+ display: 'flex',
511
+ alignItems: 'center',
512
+ gap: 5,
513
+ flexShrink: 0,
514
+ }}
515
+ >
516
+ <span style={{ width: 6, height: 6, borderRadius: 6, background: palette.online }} />
517
+ {t('subscription.online')}
518
+ </span>
519
+ </div>
520
+ )
521
+ }
522
+
523
+ /** Full-width red-gradient upgrade button (prototype CTA). */
524
+ function UpgradeButton({
525
+ subscribing,
526
+ palette,
527
+ onClick,
528
+ }: {
529
+ subscribing: boolean
530
+ palette: Palette
531
+ onClick: () => void
532
+ }): React.JSX.Element {
533
+ const t = useT()
534
+ const [hovered, setHovered] = useState(false)
535
+ return (
536
+ <button
537
+ type="button"
538
+ style={{
539
+ width: '100%',
540
+ marginTop: 10,
541
+ padding: '9px 12px',
542
+ borderRadius: 12,
543
+ border: 'none',
544
+ background: subscribing
545
+ ? `linear-gradient(90deg, ${palette.primary}, #e11d48)`
546
+ : hovered
547
+ ? `linear-gradient(90deg, ${palette.primaryHover}, #be123c)`
548
+ : `linear-gradient(90deg, ${palette.primary}, #e11d48)`,
549
+ color: '#ffffff',
550
+ fontSize: 12,
551
+ fontWeight: 600,
552
+ display: 'flex',
553
+ alignItems: 'center',
554
+ justifyContent: 'center',
555
+ gap: 8,
556
+ cursor: subscribing ? 'default' : 'pointer',
557
+ opacity: subscribing ? 0.72 : 1,
558
+ boxShadow: '0 8px 20px rgba(230, 46, 45, 0.22)',
559
+ transition: 'background 0.15s ease, opacity 0.15s ease',
560
+ }}
561
+ onMouseEnter={() => setHovered(true)}
562
+ onMouseLeave={() => setHovered(false)}
563
+ onClick={onClick}
564
+ >
565
+ <CrownIcon />
566
+ {subscribing ? t('subscription.subscribing') : t('subscription.upgrade')}
567
+ </button>
568
+ )
569
+ }
570
+
210
571
  export const HuaqiuAuthSidebarAction = memo(function HuaqiuAuthSidebarAction({ wide }: SidebarFooterActionOwnerProps): React.JSX.Element | null {
211
572
  const authState = useSyncExternalStore(subscribeAuth, getAuthState)
212
573
  const auth = getAuth()
@@ -217,6 +578,8 @@ export const HuaqiuAuthSidebarAction = memo(function HuaqiuAuthSidebarAction({ w
217
578
  const [menuStyle, setMenuStyle] = useState<CSSProperties | null>(null)
218
579
  const [avatarBroken, setAvatarBroken] = useState(false)
219
580
  const [hovered, setHovered] = useState(false)
581
+ const [quota, setQuota] = useState<{ gen: SubscriptionQuota | null; erc: SubscriptionQuota | null } | null>(null)
582
+ const [subscribing, setSubscribing] = useState(false)
220
583
  const triggerRef = useRef<HTMLButtonElement | null>(null)
221
584
  const menuRef = useRef<HTMLDivElement | null>(null)
222
585
 
@@ -270,8 +633,29 @@ export const HuaqiuAuthSidebarAction = memo(function HuaqiuAuthSidebarAction({ w
270
633
  if (!authenticated) setMenuOpen(false)
271
634
  }, [authenticated])
272
635
 
636
+ // Live quota: EVERY time the menu opens (and every time the auth state
637
+ // flips to authenticated) re-fetch the active subscription for GEN and ERC
638
+ // DIRECTLY from eda.cn — the sidebar must never show a stale count.
639
+ useEffect(() => {
640
+ if (!menuOpen || !authenticated) {
641
+ if (!menuOpen) setQuota(null)
642
+ return
643
+ }
644
+ let cancelled = false
645
+ void Promise.all([
646
+ auth?.getSubscriptionQuota?.(GEN_PRODUCT_CODE).catch(() => null) ?? Promise.resolve(null),
647
+ auth?.getSubscriptionQuota?.(ERC_PRODUCT_CODE).catch(() => null) ?? Promise.resolve(null),
648
+ ]).then(([gen, erc]) => {
649
+ if (!cancelled) setQuota({ gen, erc })
650
+ })
651
+ return () => { cancelled = true }
652
+ // eslint-disable-next-line react-hooks/exhaustive-deps
653
+ }, [menuOpen, authenticated])
654
+
273
655
  // Anchor the portalled menu to the trigger before paint: the sidebar footer
274
656
  // sits at the bottom edge, so the menu grows UPWARD from the trigger's top.
657
+ // Width is FIXED (320, prototype `w-80`) — the sidebar footer is narrow, so
658
+ // the popup intentionally overhangs it instead of collapsing to its width.
275
659
  useLayoutEffect(() => {
276
660
  if (!menuOpen || !triggerRef.current) return
277
661
  const rect = triggerRef.current.getBoundingClientRect()
@@ -283,9 +667,8 @@ export const HuaqiuAuthSidebarAction = memo(function HuaqiuAuthSidebarAction({ w
283
667
  boxShadow: palette.shadow,
284
668
  left: Math.max(8, Math.round(rect.left)),
285
669
  bottom: Math.max(8, Math.round(window.innerHeight - rect.top + 8)),
286
- ...(wide ? { width: Math.round(rect.width) } : {}),
287
670
  })
288
- }, [menuOpen, wide, avatar, palette])
671
+ }, [menuOpen, avatar, palette])
289
672
 
290
673
  // Close on: outside click, Escape, resize or scroll (the anchor moved).
291
674
  useEffect(() => {
@@ -340,6 +723,19 @@ export const HuaqiuAuthSidebarAction = memo(function HuaqiuAuthSidebarAction({ w
340
723
  const title = authenticated ? t('sidebar.accountTitle') : t('sidebar.loginTitle')
341
724
  const triggerBackground = menuOpen || hovered ? palette.hover : 'transparent'
342
725
 
726
+ /**
727
+ * Open the subscription page. Host mode → the EDA host's native dialog
728
+ * (DialogService.OpenUrl, preferred size 1400×740); standalone DSH → the
729
+ * in-app iframe dialog at the same preferred size. No credential → no-op.
730
+ */
731
+ const openSubscription = (): void => {
732
+ if (subscribing) return
733
+ setSubscribing(true)
734
+ void auth.openSubscriptionDialog()
735
+ .catch(() => { /* host without dialog service — menu stays usable */ })
736
+ .finally(() => setSubscribing(false))
737
+ }
738
+
343
739
  return (
344
740
  <div style={{ position: 'relative', width: '100%' }}>
345
741
  <button
@@ -401,14 +797,75 @@ export const HuaqiuAuthSidebarAction = memo(function HuaqiuAuthSidebarAction({ w
401
797
  {menuOpen && menuStyle
402
798
  ? createPortal(
403
799
  <div ref={menuRef} role="menu" style={menuStyle}>
404
- {displayName ? (
405
- <div style={{ ...MENU_HEADER_BASE, color: palette.muted }} title={displayName}>{displayName}</div>
406
- ) : null}
407
- <MenuItem
408
- label={t('menu.profile')}
409
- icon={<UserIcon />}
800
+ <ProfileHeaderCard
801
+ displayName={displayName ?? t('sidebar.account')}
802
+ packageName={quota?.gen?.packageName ?? quota?.erc?.packageName ?? null}
803
+ phone={authState.phone ?? null}
410
804
  palette={palette}
411
- onSelect={openProfile}
805
+ />
806
+ <div
807
+ role="presentation"
808
+ style={{
809
+ ...MENU_HEADER_BASE,
810
+ paddingTop: 0,
811
+ paddingBottom: 6,
812
+ fontSize: 11,
813
+ fontWeight: 600,
814
+ letterSpacing: 0.4,
815
+ textTransform: 'uppercase',
816
+ color: palette.muted,
817
+ }}
818
+ >
819
+ {t('subscription.quotaHeader')}
820
+ </div>
821
+ <div role="presentation" style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
822
+ <QuotaMetric
823
+ label={t('subscription.gen')}
824
+ icon={<CpuIcon />}
825
+ quota={quota?.gen ?? null}
826
+ gradient={palette.genGradient}
827
+ palette={palette}
828
+ />
829
+ <QuotaMetric
830
+ label={t('subscription.erc')}
831
+ icon={<WaveIcon />}
832
+ quota={quota?.erc ?? null}
833
+ gradient={palette.ercGradient}
834
+ palette={palette}
835
+ />
836
+ </div>
837
+ <UpgradeButton
838
+ subscribing={subscribing}
839
+ palette={palette}
840
+ onClick={() => {
841
+ if (subscribing) return
842
+ openSubscription()
843
+ }}
844
+ />
845
+ <div
846
+ role="presentation"
847
+ style={{
848
+ height: 1,
849
+ margin: '12px 2px',
850
+ background: palette.border,
851
+ }}
852
+ />
853
+ <div role="presentation" style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
854
+ <MenuItem
855
+ label={t('menu.profile')}
856
+ icon={<UserIcon />}
857
+ trailing={<ChevronRightIcon />}
858
+ palette={palette}
859
+ onSelect={openProfile}
860
+ />
861
+ </div>
862
+ <div
863
+ role="presentation"
864
+ style={{
865
+ height: 1,
866
+ margin: '12px 2px',
867
+ background: palette.border,
868
+ }}
412
869
  />
413
870
  <MenuItem
414
871
  label={t('menu.logout')}