From 59693da057676a2598fd306a86af4ea23dc6a90b Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 1 Aug 2026 21:10:35 +0200 Subject: [PATCH] refactor(stalker): address account-info review round two Three P2s from Codex on #1330: - Normalize the cached stalkerAccountInfo snapshot before rendering: the import path persists portal values verbatim, so expireDate can be a date string or milliseconds at runtime despite the declared number type. normalizeStoredStalkerAccountInfo() runs the same parsers as the fresh path. - Publish the re-auth token into StalkerSessionService's cache: strict portals invalidate the previous token per handshake, so the dialog's authenticate() would otherwise strand an active portal session on a dead token. - Extract the duplicated ~460-line account-dialog stylesheet into libs/ui/styles/_account-dialog.scss, shared by both dialogs with the provider accent injected via --account-dialog-accent; each consumer keeps only its accent and layout overrides. Co-Authored-By: Claude Opus 5 --- .../lib/stalker-account-info.service.spec.ts | 54 ++ .../src/lib/stalker-account-info.service.ts | 37 +- .../stalker-account-info.component.scss | 455 +-------------- .../stalker-account-info.component.spec.ts | 24 + .../stalker-account-info.component.ts | 10 +- .../account-info/account-info.component.scss | 477 +--------------- libs/ui/styles/_account-dialog.scss | 533 ++++++++++++++++++ 7 files changed, 668 insertions(+), 922 deletions(-) create mode 100644 libs/ui/styles/_account-dialog.scss diff --git a/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.spec.ts index 600e325be..f07c90b0d 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.spec.ts @@ -2,6 +2,7 @@ import { TestBed } from '@angular/core/testing'; import { DataService } from '@iptvnator/services'; import { PlaylistMeta, STALKER_REQUEST } from '@iptvnator/shared/interfaces'; import { + normalizeStoredStalkerAccountInfo, parseStalkerDate, StalkerAccountInfoService, } from './stalker-account-info.service'; @@ -13,6 +14,7 @@ describe('StalkerAccountInfoService', () => { let stalkerSession: { authenticate: jest.Mock; makeAuthenticatedRequest: jest.Mock; + setCachedToken: jest.Mock; }; const portalPlaylist = { @@ -37,6 +39,7 @@ describe('StalkerAccountInfoService', () => { stalkerSession = { authenticate: jest.fn(), makeAuthenticatedRequest: jest.fn(), + setCachedToken: jest.fn(), }; TestBed.configureTestingModule({ @@ -78,6 +81,12 @@ describe('StalkerAccountInfoService', () => { fullPortalPlaylist.macAddress, expect.any(Object) ); + // The fresh token must land in the managed session cache — strict + // portals invalidate the previous token on each handshake. + expect(stalkerSession.setCachedToken).toHaveBeenCalledWith( + fullPortalPlaylist._id, + 'token-1' + ); expect(snapshot).toEqual({ login: 'user-77', expireDate: 1_790_000_000, @@ -190,6 +199,51 @@ describe('StalkerAccountInfoService', () => { }); }); +describe('normalizeStoredStalkerAccountInfo', () => { + it('parses stringly-typed persisted values from the import path', () => { + expect( + normalizeStoredStalkerAccountInfo({ + login: 'user-1', + expireDate: '2026-10-01' as unknown as number, + tariffPlanName: 'Premium', + status: '1' as unknown as number, + }) + ).toEqual({ + login: 'user-1', + expireDate: Math.round(Date.parse('2026-10-01') / 1000), + tariffPlanName: 'Premium', + status: 1, + }); + }); + + it('passes through well-formed numeric snapshots unchanged', () => { + expect( + normalizeStoredStalkerAccountInfo({ + login: 'user-1', + expireDate: 1_790_000_000, + tariffPlanName: 'Premium', + status: 1, + }) + ).toEqual({ + login: 'user-1', + expireDate: 1_790_000_000, + tariffPlanName: 'Premium', + status: 1, + }); + }); + + it('returns null for missing or fact-free snapshots', () => { + expect(normalizeStoredStalkerAccountInfo(undefined)).toBeNull(); + expect(normalizeStoredStalkerAccountInfo(null)).toBeNull(); + expect(normalizeStoredStalkerAccountInfo({})).toBeNull(); + expect( + normalizeStoredStalkerAccountInfo({ + expireDate: '0000-00-00' as unknown as number, + }) + ).toBeNull(); + }); +}); + describe('parseStalkerDate', () => { it('accepts unix seconds, unix milliseconds, and date strings', () => { expect(parseStalkerDate(1_790_000_000)).toBe(1_790_000_000); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts index 39e4217b4..785e18cea 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts @@ -85,12 +85,17 @@ export class StalkerAccountInfoService { const identity = getStalkerPortalIdentityFromPlaylist( toStalkerSessionPlaylist(playlist) ); - const { accountInfo } = await this.stalkerSession.authenticate( + const { token, accountInfo } = await this.stalkerSession.authenticate( playlist.portalUrl as string, playlist.macAddress as string, identity ); + // Strict portals invalidate the previous token on every handshake. + // Publish the fresh one into the managed session cache so an active + // portal session keeps working after the dialog re-authenticated. + this.stalkerSession.setCachedToken(playlist._id, token); + if (!accountInfo) { return null; } @@ -156,6 +161,36 @@ function normalizeSnapshot( return hasAnyFact ? snapshot : null; } +/** + * Normalizes the `Playlist.stalkerAccountInfo` snapshot persisted at import + * time. The import path stores the portal's `expire_date`/`status` verbatim, + * so despite the declared number types the runtime values can be date + * strings or millisecond timestamps — run them through the same parsers the + * fresh path uses before the dialog renders them. + */ +export function normalizeStoredStalkerAccountInfo( + cached: + | { + login?: string; + expireDate?: number | string; + tariffPlanName?: string; + status?: number | string; + } + | null + | undefined +): StalkerAccountSnapshot | null { + if (!cached || typeof cached !== 'object') { + return null; + } + + return normalizeSnapshot({ + login: cached.login || undefined, + expireDate: parseStalkerDate(cached.expireDate), + tariffPlanName: cached.tariffPlanName || undefined, + status: parseStalkerNumber(cached.status), + }); +} + /** * Portals report dates as unix seconds, unix milliseconds, or date strings * ("2026-10-01", "0000-00-00"). Returns unix seconds, or undefined when the diff --git a/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.scss b/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.scss index 0a4a7168c..ddabac486 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.scss +++ b/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.scss @@ -1,460 +1,21 @@ -// Mirrors the Xtream account-info dialog's visual language -// (libs/portal/xtream/feature/src/lib/account-info/) with the Stalker -// provider accent. Keep structural changes in sync between the two. +@use '../../../../../../ui/styles/account-dialog'; + :host { display: block; color: var(--app-body-color, var(--mat-sys-on-surface)); + --account-dialog-accent: var( + --app-provider-stalker, + var(--mat-sys-primary) + ); } -// ─── Hero header ──────────────────────────────────────────────────────────── -.account-dialog__hero { - margin: 0; - padding: 20px 20px 18px; - display: grid; - gap: 18px; - background: - var(--app-accent-gradient), - var(--app-widget-header-bg, var(--mat-sys-surface-container-high)); - border-bottom: 1px solid - var(--app-widget-header-border, rgba(255, 255, 255, 0.05)); -} - -.account-dialog__hero-copy { - display: grid; - gap: 10px; -} - -.account-dialog__eyebrow { - display: inline-flex; - align-items: center; - gap: 8px; - margin: 0; - color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant)); - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.1em; - text-transform: uppercase; -} - -.account-dialog__eyebrow-mark { - width: 8px; - height: 8px; - border-radius: 999px; - background: var(--app-provider-stalker, var(--mat-sys-primary)); - box-shadow: 0 0 0 4px - color-mix( - in srgb, - var(--app-provider-stalker, var(--mat-sys-primary)) 14%, - transparent - ); -} - -.account-dialog__eyebrow-divider { - width: 3px; - height: 3px; - border-radius: 999px; - background: currentColor; - opacity: 0.5; -} - -.account-dialog__headline-row { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 14px; -} - -.account-dialog__title { - margin: 0; - font-size: 1.3rem; - line-height: 1.2; - font-weight: 650; - letter-spacing: -0.02em; - color: var(--app-heading-color, var(--mat-sys-on-surface)); -} - -.account-dialog__subtitle { - margin: 4px 0 0; - color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); - font-size: 0.88rem; -} - -// ─── Status pills ─────────────────────────────────────────────────────────── -.account-dialog__status-cluster { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: 6px; -} - -.account-dialog__status-pill { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 28px; - padding: 0 10px; - border-radius: 999px; - border: 1px solid var(--app-separator); - background: var(--mat-sys-surface-container); - color: var(--app-body-color, var(--mat-sys-on-surface)); - font-size: 0.76rem; - font-weight: 600; - letter-spacing: 0.06em; - text-transform: uppercase; -} - -.account-dialog__status-pill--active { - border-color: color-mix(in srgb, #34d399 30%, transparent); - background: color-mix(in srgb, #34d399 12%, transparent); - color: #2db36f; -} - -.account-dialog__status-pill--cached { - border-color: color-mix(in srgb, #fbbf24 28%, transparent); - background: color-mix(in srgb, #f59e0b 12%, transparent); - color: #d18b09; -} - -// ─── Messages ─────────────────────────────────────────────────────────────── -.account-dialog__message, -.account-dialog__loading-copy { - margin: 0; - max-width: 60ch; - color: var(--app-body-color, var(--mat-sys-on-surface)); -} - -.account-dialog__message { - padding: 10px 14px; - border-radius: 10px; - border: 1px solid var(--app-separator); - background: var(--mat-sys-surface-container); - line-height: 1.5; - font-size: 0.88rem; -} - -// ─── Hero stat cards ──────────────────────────────────────────────────────── +// Stalker shows three hero stats (days left / tariff / MAC), not four. .account-dialog__hero-stats { - display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 10px; -} - -.account-stat { - display: grid; - gap: 6px; - padding: 12px 14px; - border-radius: 12px; - border: 1px solid var(--app-widget-border, transparent); - background: var(--app-widget-bg, var(--mat-sys-surface-container)); - box-shadow: var(--app-widget-shadow, none); - outline: 1px solid rgba(255, 255, 255, 0.04); - outline-offset: -1px; -} - -.account-stat__icon { - width: 32px; - height: 32px; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 10px; - background: color-mix( - in srgb, - var(--app-provider-stalker, var(--mat-sys-primary)) 12%, - transparent - ); - color: var(--app-provider-stalker, var(--mat-sys-primary)); - - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - } -} - -.account-stat__label { - color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.1em; - text-transform: uppercase; -} - -.account-stat__value { - font-size: 1.3rem; - font-weight: 650; - letter-spacing: -0.03em; - color: var(--app-heading-color, var(--mat-sys-on-surface)); - overflow-wrap: anywhere; -} - -.account-stat__value--warning { - color: #d18b09; -} - -// ─── Content area ─────────────────────────────────────────────────────────── -.account-dialog__content { - margin: 0; - padding: 20px; - background: var(--app-widget-bg, var(--mat-sys-surface)); -} - -.account-dialog__grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 14px; -} - -// ─── Detail panels ────────────────────────────────────────────────────────── -.account-panel { - display: grid; - gap: 14px; - padding: 16px; - border-radius: 16px; - border: 1px solid var(--app-widget-border, transparent); - background: var(--app-widget-bg, var(--mat-sys-surface-container-low)); - box-shadow: var(--app-widget-shadow, none); - outline: 1px solid rgba(255, 255, 255, 0.04); - outline-offset: -1px; - align-content: start; -} - -.account-panel__header { - display: grid; - gap: 4px; -} - -.account-panel__header h3 { - margin: 0; - font-size: 1rem; - font-weight: 600; - letter-spacing: -0.01em; - color: var(--app-heading-color, var(--mat-sys-on-surface)); - overflow-wrap: anywhere; -} - -.account-panel__eyebrow { - margin: 0; - color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant)); - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.1em; - text-transform: uppercase; -} - -// ─── Detail rows ──────────────────────────────────────────────────────────── -.account-detail-list { - display: grid; - gap: 0; - margin: 0; -} - -.account-detail-row { - display: grid; - grid-template-columns: minmax(120px, 140px) minmax(0, 1fr); - gap: 12px; - align-items: baseline; - padding: 10px 0; - border-bottom: 1px solid var(--app-separator); -} - -.account-detail-row:last-child { - border-bottom: 0; - padding-bottom: 0; -} - -.account-detail-row dt { - margin: 0; - color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.1em; - text-transform: uppercase; -} - -.account-detail-row dd { - margin: 0; - color: var(--app-body-color, var(--mat-sys-on-surface)); - font-size: 0.9rem; - font-weight: 500; - word-break: break-word; -} - -.account-detail-row__value--mono { - font-family: 'JetBrains Mono', 'Roboto Mono', monospace; - font-size: 0.84rem; -} - -.account-detail-row__value--positive { - color: #2db36f; -} - -.account-detail-row__value--warning { - color: #d18b09; -} - -// ─── Empty account block ──────────────────────────────────────────────────── -.account-dialog__empty-copy { - display: grid; - gap: 6px; - - h4 { - margin: 0; - font-size: 0.92rem; - font-weight: 600; - color: var(--app-heading-color, var(--mat-sys-on-surface)); - } - - p { - margin: 0; - max-width: 46ch; - color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); - font-size: 0.85rem; - line-height: 1.5; - } -} - -// ─── Error state ──────────────────────────────────────────────────────────── -.account-dialog__error-state { - display: grid; - justify-items: center; - gap: 12px; - padding: 36px 20px; - border-radius: 16px; - border: 1px dashed var(--app-separator); - background: var(--app-widget-bg, var(--mat-sys-surface-container-low)); - text-align: center; -} - -.account-dialog__error-state h3, -.account-dialog__error-state p { - margin: 0; -} - -.account-dialog__error-state p { - max-width: 52ch; - color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); - line-height: 1.5; - font-size: 0.88rem; -} - -.account-dialog__error-icon { - width: 48px; - height: 48px; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 14px; - background: color-mix(in srgb, var(--mat-sys-error) 12%, transparent); - color: var(--mat-sys-error); -} - -// ─── Skeleton loading ─────────────────────────────────────────────────────── -.account-panel--loading { - gap: 12px; -} - -.account-skeleton { - position: relative; - overflow: hidden; - border-radius: 999px; - background: var(--app-separator); -} - -.account-skeleton::after { - content: ''; - position: absolute; - inset: 0; - transform: translateX(-100%); - background: linear-gradient( - 90deg, - transparent, - color-mix( - in srgb, - var(--app-heading-color, var(--mat-sys-on-surface)) 10%, - transparent - ), - transparent - ); - animation: account-dialog-shimmer 1.4s infinite; -} - -.account-skeleton--stat { - min-height: 110px; - border-radius: 12px; -} - -.account-skeleton--heading { - width: 54%; - height: 20px; -} - -.account-skeleton--row { - width: 100%; - height: 16px; -} - -.account-skeleton--row.short { - width: 62%; -} - -// ─── Footer actions ───────────────────────────────────────────────────────── -.account-dialog__actions { - padding: 0 20px 20px; - display: flex; - justify-content: flex-end; - gap: 10px; - border-top: 1px solid - var(--app-widget-header-border, rgba(255, 255, 255, 0.05)); - background: var(--app-widget-bg, var(--mat-sys-surface)); -} - -.account-dialog__close { - min-width: 100px; - border-radius: 999px; -} - -@keyframes account-dialog-shimmer { - 100% { - transform: translateX(100%); - } -} - -// ─── Responsive breakpoints ───────────────────────────────────────────────── -@media (max-width: 980px) { - .account-dialog__hero-stats { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - - .account-dialog__grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } } @media (max-width: 720px) { - .account-dialog__hero, - .account-dialog__content, - .account-dialog__actions { - padding-left: 16px; - padding-right: 16px; - } - - .account-dialog__headline-row { - flex-direction: column; - align-items: flex-start; - } - - .account-dialog__status-cluster { - justify-content: flex-start; - } - - .account-dialog__hero-stats, - .account-dialog__grid { + .account-dialog__hero-stats { grid-template-columns: 1fr; } - - .account-detail-row { - grid-template-columns: 1fr; - gap: 4px; - } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.spec.ts index 58449c15f..007a78478 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.spec.ts @@ -105,6 +105,30 @@ describe('StalkerAccountInfoComponent', () => { expect(component.expiresSoon()).toBe(true); }); + it('normalizes stringly-typed cached values persisted at import time', async () => { + accountInfoService.fetchAccountInfo.mockRejectedValue( + new Error('offline') + ); + playlistsService.getPlaylist.mockReturnValue( + of({ + stalkerAccountInfo: { + login: 'cached-user', + // Import persists portal values verbatim; a date string + // must not blow up daysLeft/expiry rendering. + expireDate: '2099-12-31', + status: '1', + }, + }) + ); + + await createComponent(); + + expect(component.loadState()).toBe('ready'); + expect(component.statusKind()).toBe('active'); + expect(component.expiryDate()?.getFullYear()).toBe(2099); + expect(component.daysLeft()).toBeGreaterThan(0); + }); + it('keeps the cached snapshot when the portal publishes nothing', async () => { accountInfoService.fetchAccountInfo.mockResolvedValue(null); playlistsService.getPlaylist.mockReturnValue( diff --git a/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.ts b/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.ts index 7d40708d0..d2f83e64a 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.ts @@ -11,6 +11,7 @@ import { MatIconModule } from '@angular/material/icon'; import { TranslatePipe } from '@ngx-translate/core'; import { firstValueFrom } from 'rxjs'; import { + normalizeStoredStalkerAccountInfo, StalkerAccountInfoService, StalkerAccountSnapshot, } from '@iptvnator/portal/stalker/data-access'; @@ -248,8 +249,13 @@ export class StalkerAccountInfoComponent { const playlist = await firstValueFrom( this.playlistsService.getPlaylist(this.playlist._id) ); - const cached = playlist?.stalkerAccountInfo; - if (cached && Object.values(cached).some((v) => v !== undefined)) { + // The import path persists portal values verbatim, so the + // stored expiry may be a date string or milliseconds despite + // the declared type — normalize before rendering. + const cached = normalizeStoredStalkerAccountInfo( + playlist?.stalkerAccountInfo + ); + if (cached) { this.snapshot.set(cached); this.snapshotSource.set('cached'); this.loadState.set('ready'); diff --git a/libs/portal/xtream/feature/src/lib/account-info/account-info.component.scss b/libs/portal/xtream/feature/src/lib/account-info/account-info.component.scss index f553b6fb3..e5c01a1cc 100644 --- a/libs/portal/xtream/feature/src/lib/account-info/account-info.component.scss +++ b/libs/portal/xtream/feature/src/lib/account-info/account-info.component.scss @@ -1,477 +1,10 @@ +@use '../../../../../../ui/styles/account-dialog'; + :host { display: block; color: var(--app-body-color, var(--mat-sys-on-surface)); -} - -// ─── Hero header ──────────────────────────────────────────────────────────── -.account-dialog__hero { - margin: 0; - padding: 20px 20px 18px; - display: grid; - gap: 18px; - background: - var(--app-accent-gradient), - var(--app-widget-header-bg, var(--mat-sys-surface-container-high)); - border-bottom: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05)); -} - -.account-dialog__hero-copy { - display: grid; - gap: 10px; -} - -.account-dialog__eyebrow { - display: inline-flex; - align-items: center; - gap: 8px; - margin: 0; - color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant)); - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.1em; - text-transform: uppercase; -} - -.account-dialog__eyebrow-mark { - width: 8px; - height: 8px; - border-radius: 999px; - background: var(--app-provider-xtream, var(--mat-sys-primary)); - box-shadow: 0 0 0 4px color-mix(in srgb, var(--app-provider-xtream, var(--mat-sys-primary)) 14%, transparent); -} - -.account-dialog__eyebrow-divider { - width: 3px; - height: 3px; - border-radius: 999px; - background: currentColor; - opacity: 0.5; -} - -.account-dialog__headline-row { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 14px; -} - -.account-dialog__title { - margin: 0; - font-size: 1.3rem; - line-height: 1.2; - font-weight: 650; - letter-spacing: -0.02em; - color: var(--app-heading-color, var(--mat-sys-on-surface)); -} - -.account-dialog__subtitle { - margin: 4px 0 0; - color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); - font-size: 0.88rem; -} - -// ─── Status pills ─────────────────────────────────────────────────────────── -.account-dialog__status-cluster { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: 6px; -} - -.account-dialog__status-pill { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 28px; - padding: 0 10px; - border-radius: 999px; - border: 1px solid var(--app-separator); - background: var(--mat-sys-surface-container); - color: var(--app-body-color, var(--mat-sys-on-surface)); - font-size: 0.76rem; - font-weight: 600; - letter-spacing: 0.06em; - text-transform: uppercase; -} - -.account-dialog__status-pill--active { - border-color: color-mix(in srgb, #34d399 30%, transparent); - background: color-mix(in srgb, #34d399 12%, transparent); - color: #2db36f; -} - -.account-dialog__status-pill--trial { - border-color: color-mix(in srgb, #fbbf24 28%, transparent); - background: color-mix(in srgb, #f59e0b 12%, transparent); - color: #d18b09; -} - -// ─── Messages ─────────────────────────────────────────────────────────────── -.account-dialog__message, -.account-dialog__loading-copy { - margin: 0; - max-width: 60ch; - color: var(--app-body-color, var(--mat-sys-on-surface)); -} - -.account-dialog__message { - padding: 10px 14px; - border-radius: 10px; - border: 1px solid var(--app-separator); - background: var(--mat-sys-surface-container); - line-height: 1.5; - font-size: 0.88rem; -} - -// ─── Hero stat cards ──────────────────────────────────────────────────────── -.account-dialog__hero-stats { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 10px; -} - -.account-stat { - display: grid; - gap: 6px; - padding: 12px 14px; - border-radius: 12px; - border: 1px solid var(--app-widget-border, transparent); - background: var(--app-widget-bg, var(--mat-sys-surface-container)); - box-shadow: var(--app-widget-shadow, none); - // Subtle inset highlight matching widget-shell pattern - outline: 1px solid rgba(255, 255, 255, 0.04); - outline-offset: -1px; -} - -.account-stat__icon { - width: 32px; - height: 32px; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 10px; - background: color-mix(in srgb, var(--app-provider-xtream, var(--mat-sys-primary)) 12%, transparent); - color: var(--app-provider-xtream, var(--mat-sys-primary)); - - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - } -} - -.account-stat__label { - color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.1em; - text-transform: uppercase; -} - -.account-stat__value { - font-size: 1.3rem; - font-weight: 650; - letter-spacing: -0.03em; - color: var(--app-heading-color, var(--mat-sys-on-surface)); -} - -.account-stat__meter { - position: relative; - overflow: hidden; - height: 4px; - border-radius: 999px; - background: var(--app-separator); -} - -.account-stat__meter span { - display: block; - height: 100%; - border-radius: inherit; - background: var(--app-provider-xtream, var(--mat-sys-primary)); -} - -// ─── Content area ─────────────────────────────────────────────────────────── -.account-dialog__content { - margin: 0; - padding: 20px; - background: var(--app-widget-bg, var(--mat-sys-surface)); -} - -.account-dialog__grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 14px; -} - -// ─── Detail panels ────────────────────────────────────────────────────────── -.account-panel { - display: grid; - gap: 14px; - padding: 16px; - border-radius: 16px; - border: 1px solid var(--app-widget-border, transparent); - background: var(--app-widget-bg, var(--mat-sys-surface-container-low)); - box-shadow: var(--app-widget-shadow, none); - outline: 1px solid rgba(255, 255, 255, 0.04); - outline-offset: -1px; -} - -.account-panel__header { - display: grid; - gap: 4px; -} - -.account-panel__header h3 { - margin: 0; - font-size: 1rem; - font-weight: 600; - letter-spacing: -0.01em; - color: var(--app-heading-color, var(--mat-sys-on-surface)); -} - -.account-panel__eyebrow { - margin: 0; - color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant)); - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.1em; - text-transform: uppercase; -} - -// ─── Detail rows ──────────────────────────────────────────────────────────── -.account-detail-list { - display: grid; - gap: 0; - margin: 0; -} - -.account-detail-row { - display: grid; - grid-template-columns: minmax(120px, 140px) minmax(0, 1fr); - gap: 12px; - align-items: baseline; - padding: 10px 0; - border-bottom: 1px solid var(--app-separator); -} - -.account-detail-row:last-child { - border-bottom: 0; - padding-bottom: 0; -} - -.account-detail-row dt { - margin: 0; - color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.1em; - text-transform: uppercase; -} - -.account-detail-row dd { - margin: 0; - color: var(--app-body-color, var(--mat-sys-on-surface)); - font-size: 0.9rem; - font-weight: 500; - word-break: break-word; -} - -.account-detail-row__value--mono { - font-family: 'JetBrains Mono', 'Roboto Mono', monospace; - font-size: 0.84rem; -} - -.account-detail-row__value--accent { - color: var(--app-provider-xtream, var(--mat-sys-primary)); -} - -.account-detail-row__value--positive { - color: #2db36f; -} - -.account-detail-row__value--warning { - color: #d18b09; -} - -// ─── Chip sections ────────────────────────────────────────────────────────── -.account-chip-section { - display: grid; - gap: 10px; -} - -.account-chip-section__label { - margin: 0; - color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.1em; - text-transform: uppercase; -} - -.account-chip-group { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.account-chip { - display: inline-flex; - align-items: center; - min-height: 28px; - padding: 0 10px; - border-radius: 999px; - border: 1px solid color-mix(in srgb, var(--app-provider-xtream, var(--mat-sys-primary)) 16%, transparent); - background: color-mix(in srgb, var(--app-provider-xtream, var(--mat-sys-primary)) 8%, transparent); - color: var(--app-body-color, var(--mat-sys-on-surface)); - font-size: 0.8rem; - font-weight: 600; - text-transform: uppercase; -} - -.account-chip--mono { - font-family: 'JetBrains Mono', 'Roboto Mono', monospace; - text-transform: none; -} - -.account-chip--muted { - border-color: var(--app-separator); - background: var(--mat-sys-surface-container); - color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); -} - -// ─── Error state ──────────────────────────────────────────────────────────── -.account-dialog__error-state { - display: grid; - justify-items: center; - gap: 12px; - padding: 36px 20px; - border-radius: 16px; - border: 1px dashed var(--app-separator); - background: var(--app-widget-bg, var(--mat-sys-surface-container-low)); - text-align: center; -} - -.account-dialog__error-state h3, -.account-dialog__error-state p { - margin: 0; -} - -.account-dialog__error-state p { - max-width: 52ch; - color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); - line-height: 1.5; - font-size: 0.88rem; -} - -.account-dialog__error-icon { - width: 48px; - height: 48px; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 14px; - background: color-mix(in srgb, var(--mat-sys-error) 12%, transparent); - color: var(--mat-sys-error); -} - -// ─── Skeleton loading ─────────────────────────────────────────────────────── -.account-panel--loading { - gap: 12px; -} - -.account-skeleton { - position: relative; - overflow: hidden; - border-radius: 999px; - background: var(--app-separator); -} - -.account-skeleton::after { - content: ''; - position: absolute; - inset: 0; - transform: translateX(-100%); - background: linear-gradient( - 90deg, - transparent, - color-mix(in srgb, var(--app-heading-color, var(--mat-sys-on-surface)) 10%, transparent), - transparent + --account-dialog-accent: var( + --app-provider-xtream, + var(--mat-sys-primary) ); - animation: account-dialog-shimmer 1.4s infinite; -} - -.account-skeleton--stat { - min-height: 110px; - border-radius: 12px; -} - -.account-skeleton--heading { - width: 54%; - height: 20px; -} - -.account-skeleton--row { - width: 100%; - height: 16px; -} - -.account-skeleton--row.short { - width: 62%; -} - -// ─── Footer actions ───────────────────────────────────────────────────────── -.account-dialog__actions { - padding: 0 20px 20px; - display: flex; - justify-content: flex-end; - gap: 10px; - border-top: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05)); - background: var(--app-widget-bg, var(--mat-sys-surface)); -} - -.account-dialog__close { - min-width: 100px; - border-radius: 999px; -} - -@keyframes account-dialog-shimmer { - 100% { - transform: translateX(100%); - } -} - -// ─── Responsive breakpoints ───────────────────────────────────────────────── -@media (max-width: 980px) { - .account-dialog__hero-stats, - .account-dialog__grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -@media (max-width: 720px) { - .account-dialog__hero, - .account-dialog__content, - .account-dialog__actions { - padding-left: 16px; - padding-right: 16px; - } - - .account-dialog__headline-row { - flex-direction: column; - align-items: flex-start; - } - - .account-dialog__status-cluster { - justify-content: flex-start; - } - - .account-dialog__hero-stats, - .account-dialog__grid { - grid-template-columns: 1fr; - } - - .account-detail-row { - grid-template-columns: 1fr; - gap: 4px; - } } diff --git a/libs/ui/styles/_account-dialog.scss b/libs/ui/styles/_account-dialog.scss new file mode 100644 index 000000000..36a10e950 --- /dev/null +++ b/libs/ui/styles/_account-dialog.scss @@ -0,0 +1,533 @@ +// Shared account-info dialog stylesheet, used by both portal dialogs: +// - libs/portal/xtream/feature/src/lib/account-info/ +// - libs/portal/stalker/feature/src/lib/stalker-account-info/ +// +// The provider accent is injected via the `--account-dialog-accent` custom +// property, set on :host by each consumer. Provider-specific structural +// tweaks (e.g. the stats column count) stay in the consumer stylesheet. + +// ─── Hero header ──────────────────────────────────────────────────────────── +.account-dialog__hero { + margin: 0; + padding: 20px 20px 18px; + display: grid; + gap: 18px; + background: + var(--app-accent-gradient), + var(--app-widget-header-bg, var(--mat-sys-surface-container-high)); + border-bottom: 1px solid + var(--app-widget-header-border, rgba(255, 255, 255, 0.05)); +} + +.account-dialog__hero-copy { + display: grid; + gap: 10px; +} + +.account-dialog__eyebrow { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 0; + color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant)); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.account-dialog__eyebrow-mark { + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--account-dialog-accent, var(--mat-sys-primary)); + box-shadow: 0 0 0 4px + color-mix( + in srgb, + var(--account-dialog-accent, var(--mat-sys-primary)) 14%, + transparent + ); +} + +.account-dialog__eyebrow-divider { + width: 3px; + height: 3px; + border-radius: 999px; + background: currentColor; + opacity: 0.5; +} + +.account-dialog__headline-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} + +.account-dialog__title { + margin: 0; + font-size: 1.3rem; + line-height: 1.2; + font-weight: 650; + letter-spacing: -0.02em; + color: var(--app-heading-color, var(--mat-sys-on-surface)); +} + +.account-dialog__subtitle { + margin: 4px 0 0; + color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); + font-size: 0.88rem; +} + +// ─── Status pills ─────────────────────────────────────────────────────────── +.account-dialog__status-cluster { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + +.account-dialog__status-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 28px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid var(--app-separator); + background: var(--mat-sys-surface-container); + color: var(--app-body-color, var(--mat-sys-on-surface)); + font-size: 0.76rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.account-dialog__status-pill--active { + border-color: color-mix(in srgb, #34d399 30%, transparent); + background: color-mix(in srgb, #34d399 12%, transparent); + color: #2db36f; +} + +.account-dialog__status-pill--trial, +.account-dialog__status-pill--cached { + border-color: color-mix(in srgb, #fbbf24 28%, transparent); + background: color-mix(in srgb, #f59e0b 12%, transparent); + color: #d18b09; +} + +// ─── Messages ─────────────────────────────────────────────────────────────── +.account-dialog__message, +.account-dialog__loading-copy { + margin: 0; + max-width: 60ch; + color: var(--app-body-color, var(--mat-sys-on-surface)); +} + +.account-dialog__message { + padding: 10px 14px; + border-radius: 10px; + border: 1px solid var(--app-separator); + background: var(--mat-sys-surface-container); + line-height: 1.5; + font-size: 0.88rem; +} + +// ─── Hero stat cards ──────────────────────────────────────────────────────── +.account-dialog__hero-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.account-stat { + display: grid; + gap: 6px; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid var(--app-widget-border, transparent); + background: var(--app-widget-bg, var(--mat-sys-surface-container)); + box-shadow: var(--app-widget-shadow, none); + // Subtle inset highlight matching widget-shell pattern + outline: 1px solid rgba(255, 255, 255, 0.04); + outline-offset: -1px; +} + +.account-stat__icon { + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 10px; + background: color-mix( + in srgb, + var(--account-dialog-accent, var(--mat-sys-primary)) 12%, + transparent + ); + color: var(--account-dialog-accent, var(--mat-sys-primary)); + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } +} + +.account-stat__label { + color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.account-stat__value { + font-size: 1.3rem; + font-weight: 650; + letter-spacing: -0.03em; + color: var(--app-heading-color, var(--mat-sys-on-surface)); + overflow-wrap: anywhere; +} + +.account-stat__value--warning { + color: #d18b09; +} + +.account-stat__meter { + position: relative; + overflow: hidden; + height: 4px; + border-radius: 999px; + background: var(--app-separator); +} + +.account-stat__meter span { + display: block; + height: 100%; + border-radius: inherit; + background: var(--account-dialog-accent, var(--mat-sys-primary)); +} + +// ─── Content area ─────────────────────────────────────────────────────────── +.account-dialog__content { + margin: 0; + padding: 20px; + background: var(--app-widget-bg, var(--mat-sys-surface)); +} + +.account-dialog__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +// ─── Detail panels ────────────────────────────────────────────────────────── +.account-panel { + display: grid; + gap: 14px; + padding: 16px; + border-radius: 16px; + border: 1px solid var(--app-widget-border, transparent); + background: var(--app-widget-bg, var(--mat-sys-surface-container-low)); + box-shadow: var(--app-widget-shadow, none); + outline: 1px solid rgba(255, 255, 255, 0.04); + outline-offset: -1px; + align-content: start; +} + +.account-panel__header { + display: grid; + gap: 4px; +} + +.account-panel__header h3 { + margin: 0; + font-size: 1rem; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--app-heading-color, var(--mat-sys-on-surface)); + overflow-wrap: anywhere; +} + +.account-panel__eyebrow { + margin: 0; + color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant)); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +// ─── Detail rows ──────────────────────────────────────────────────────────── +.account-detail-list { + display: grid; + gap: 0; + margin: 0; +} + +.account-detail-row { + display: grid; + grid-template-columns: minmax(120px, 140px) minmax(0, 1fr); + gap: 12px; + align-items: baseline; + padding: 10px 0; + border-bottom: 1px solid var(--app-separator); +} + +.account-detail-row:last-child { + border-bottom: 0; + padding-bottom: 0; +} + +.account-detail-row dt { + margin: 0; + color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.account-detail-row dd { + margin: 0; + color: var(--app-body-color, var(--mat-sys-on-surface)); + font-size: 0.9rem; + font-weight: 500; + word-break: break-word; +} + +.account-detail-row__value--mono { + font-family: 'JetBrains Mono', 'Roboto Mono', monospace; + font-size: 0.84rem; +} + +.account-detail-row__value--accent { + color: var(--account-dialog-accent, var(--mat-sys-primary)); +} + +.account-detail-row__value--positive { + color: #2db36f; +} + +.account-detail-row__value--warning { + color: #d18b09; +} + +// ─── Chip sections ────────────────────────────────────────────────────────── +.account-chip-section { + display: grid; + gap: 10px; +} + +.account-chip-section__label { + margin: 0; + color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.account-chip-group { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.account-chip { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid + color-mix( + in srgb, + var(--account-dialog-accent, var(--mat-sys-primary)) 16%, + transparent + ); + background: color-mix( + in srgb, + var(--account-dialog-accent, var(--mat-sys-primary)) 8%, + transparent + ); + color: var(--app-body-color, var(--mat-sys-on-surface)); + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; +} + +.account-chip--mono { + font-family: 'JetBrains Mono', 'Roboto Mono', monospace; + text-transform: none; +} + +.account-chip--muted { + border-color: var(--app-separator); + background: var(--mat-sys-surface-container); + color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); +} + +// ─── Empty account block ──────────────────────────────────────────────────── +.account-dialog__empty-copy { + display: grid; + gap: 6px; + + h4 { + margin: 0; + font-size: 0.92rem; + font-weight: 600; + color: var(--app-heading-color, var(--mat-sys-on-surface)); + } + + p { + margin: 0; + max-width: 46ch; + color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); + font-size: 0.85rem; + line-height: 1.5; + } +} + +// ─── Error state ──────────────────────────────────────────────────────────── +.account-dialog__error-state { + display: grid; + justify-items: center; + gap: 12px; + padding: 36px 20px; + border-radius: 16px; + border: 1px dashed var(--app-separator); + background: var(--app-widget-bg, var(--mat-sys-surface-container-low)); + text-align: center; +} + +.account-dialog__error-state h3, +.account-dialog__error-state p { + margin: 0; +} + +.account-dialog__error-state p { + max-width: 52ch; + color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); + line-height: 1.5; + font-size: 0.88rem; +} + +.account-dialog__error-icon { + width: 48px; + height: 48px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 14px; + background: color-mix(in srgb, var(--mat-sys-error) 12%, transparent); + color: var(--mat-sys-error); +} + +// ─── Skeleton loading ─────────────────────────────────────────────────────── +.account-panel--loading { + gap: 12px; +} + +.account-skeleton { + position: relative; + overflow: hidden; + border-radius: 999px; + background: var(--app-separator); +} + +.account-skeleton::after { + content: ''; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient( + 90deg, + transparent, + color-mix( + in srgb, + var(--app-heading-color, var(--mat-sys-on-surface)) 10%, + transparent + ), + transparent + ); + animation: account-dialog-shimmer 1.4s infinite; +} + +.account-skeleton--stat { + min-height: 110px; + border-radius: 12px; +} + +.account-skeleton--heading { + width: 54%; + height: 20px; +} + +.account-skeleton--row { + width: 100%; + height: 16px; +} + +.account-skeleton--row.short { + width: 62%; +} + +// ─── Footer actions ───────────────────────────────────────────────────────── +.account-dialog__actions { + padding: 0 20px 20px; + display: flex; + justify-content: flex-end; + gap: 10px; + border-top: 1px solid + var(--app-widget-header-border, rgba(255, 255, 255, 0.05)); + background: var(--app-widget-bg, var(--mat-sys-surface)); +} + +.account-dialog__close { + min-width: 100px; + border-radius: 999px; +} + +@keyframes account-dialog-shimmer { + 100% { + transform: translateX(100%); + } +} + +// ─── Responsive breakpoints ───────────────────────────────────────────────── +@media (max-width: 980px) { + .account-dialog__hero-stats, + .account-dialog__grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 720px) { + .account-dialog__hero, + .account-dialog__content, + .account-dialog__actions { + padding-left: 16px; + padding-right: 16px; + } + + .account-dialog__headline-row { + flex-direction: column; + align-items: flex-start; + } + + .account-dialog__status-cluster { + justify-content: flex-start; + } + + .account-dialog__hero-stats, + .account-dialog__grid { + grid-template-columns: 1fr; + } + + .account-detail-row { + grid-template-columns: 1fr; + gap: 4px; + } +}