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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-08-02 09:16:00 +02:00
1 parent e1550489db
commit 59693da057
7 files changed
+668 -922

No files matched your search

@@ -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);
@@ -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
@@ -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;
}
}
@@ -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(
@@ -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');
@@ -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;
}
}
+533
View File
@@ -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;
}
}