new components

This commit is contained in:
2026-08-06 18:10:55 +02:00
parent b02c885b83
commit f86db1556c
149 changed files with 7038 additions and 395 deletions

View File

@@ -32,6 +32,7 @@ const TRANSLATIONS: TranslationObject = {
reset: 'Borrar',
search: 'Buscar…',
noResults: 'Sin resultados',
legend: 'Leyenda',
validator: {
requiredField: 'Este campo es obligatorio',
onlyNumbers: 'Sólo se admiten números',

View File

@@ -1,3 +1,5 @@
<pi-loader-bar />
<header class="page-header">
<div class="page-header__top">
<div>

View File

@@ -14,5 +14,27 @@ export const routes: Routes = [
loadComponent: () =>
import('./pages/layouts/layouts.page').then((m) => m.LayoutsPage),
},
{
path: 'ejemplos/login',
title: 'Login · @pi/ui',
loadComponent: () =>
import('./pages/examples/login/login.page').then((m) => m.LoginPage),
},
{
path: 'ejemplos/landing',
title: 'Landing · @pi/ui',
loadComponent: () =>
import('./pages/examples/landing/landing.page').then(
(m) => m.LandingPage,
),
},
{
path: 'ejemplos/dashboard',
title: 'Dashboard · @pi/ui',
loadComponent: () =>
import('./pages/examples/dashboard/dashboard.page').then(
(m) => m.DashboardPage,
),
},
{ path: '**', redirectTo: 'componentes' },
];

View File

@@ -8,7 +8,7 @@ import {
} from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { PiSlideToggleComponent, ShellClass } from '@pi/ui';
import { PiLoaderBarComponent, PiSlideToggleComponent, ShellClass } from '@pi/ui';
/**
* Shell del playground: cabecera, navegación y estado de tema. El contenido de
@@ -19,12 +19,21 @@ import { PiSlideToggleComponent, ShellClass } from '@pi/ui';
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './app.html',
styleUrl: './app.scss',
imports: [RouterOutlet, RouterLink, RouterLinkActive, PiSlideToggleComponent],
imports: [
RouterOutlet,
RouterLink,
RouterLinkActive,
PiSlideToggleComponent,
PiLoaderBarComponent,
],
})
export class App {
protected readonly navLinks = [
{ path: '/componentes', label: 'Componentes' },
{ path: '/layouts', label: 'Layouts' },
{ path: '/ejemplos/login', label: 'Ejemplo: Login' },
{ path: '/ejemplos/landing', label: 'Ejemplo: Landing' },
{ path: '/ejemplos/dashboard', label: 'Ejemplo: Dashboard' },
];
/* --- Tema y modo oscuro -------------------------------------------------- */

View File

@@ -58,6 +58,14 @@
<pi-slide-toggle [formField]="f.newsletter" />
</label>
<label class="rating-field">
<span>Satisfacción</span>
<pi-rating [formField]="f.satisfaction" />
</label>
@if (f.satisfaction().errors().length > 0) {
<span class="field-error">{{ f.satisfaction().errors()[0].message }}</span>
}
<pi-slider
[formField]="f.volume"
unit="%"
@@ -215,6 +223,82 @@
</pi-badge>
</app-section>
<app-section
title="Avatar group"
hint="pi-avatar-group pinta los pi-avatar internamente (a partir de items), por eso puede solaparlos con box-shadow como anillo sin tocar pi-avatar."
>
<pi-avatar-group [items]="avatarGroupItems" [max]="3" />
<pi-avatar-group [items]="avatarGroupItems" size="small" />
</app-section>
<app-section
title="Breadcrumb, empty state y segmented control"
hint="pi-breadcrumb usa routerLink en los elementos intermedios; pi-empty-state proyecta la acción; pi-segmented-control es genérico ([(value)])."
>
<div class="stack full">
<pi-breadcrumb [items]="breadcrumbItems" />
<pi-segmented-control
[options]="densityOptions"
[(value)]="density"
ariaLabel="Densidad"
/>
<pi-empty-state
icon="search"
title="Sin resultados"
description="No hay elementos que coincidan con el filtro aplicado."
>
<button pi-outline (click)="log('empty-state: limpiar filtros')">
Limpiar filtros
</button>
</pi-empty-state>
</div>
</app-section>
<app-section
title="Rating y Timeline"
hint="pi-rating es interactivo ([(value)], con previsualización en hover); readonly lo deja solo en lectura. starTemplate sustituye el icono por defecto (aquí, corazones sin pi-icon). pi-timeline es de solo lectura, a partir de items."
>
<div class="stack full">
<pi-rating [(value)]="rating" />
<pi-rating [value]="4" [readonly]="true" color="primary" />
<pi-rating [(value)]="rating" [starTemplate]="heart" />
<ng-template #heart let-filled="filled">
<span class="heart-star" [class.is-filled]="filled">♥</span>
</ng-template>
<pi-timeline [items]="timelineItems" />
</div>
</app-section>
<app-section
title="Autocomplete y rango de fechas"
hint="pi-autocomplete-field es un pi-text-field con sugerencias filtradas (menú propio, sin CDK Overlay); pi-date-range-field envuelve mat-date-range-input con dos fechas ([start, end]) en vez de una."
>
<div class="stack full">
<pi-autocomplete-field
[(value)]="autocompleteValue"
[options]="cityOptions"
placeholder="Escribe una ciudad…"
>
<pi-label>Ciudad</pi-label>
</pi-autocomplete-field>
<pi-date-range-field [(value)]="dateRange">
<pi-label>Periodo</pi-label>
</pi-date-range-field>
</div>
</app-section>
<app-section
title="Tree"
hint="pi-tree es recursivo (pi-tree-node se auto-referencia, como ya hace pi-paginator): expande/colapsa por nodo y la selección burbujea hasta la raíz."
>
<pi-tree [nodes]="treeNodes" [(selectedId)]="treeSelectedId" />
</app-section>
<app-section
title="Cabecera y listado"
hint="pi-headline para portadas de sección, pi-list-item para filas de listado. pi-headline resuelve pathImg contra un CDN externo fijo, así que la ilustración solo carga con red."
@@ -293,6 +377,50 @@
</pi-card>
</app-section>
<app-section
title="Stat card"
hint="pi-stat-card extrae el patrón (icono + label + valor + tendencia) repetido a mano en login/landing/dashboard. No envuelve pi-card: aquí se anida dentro a propósito, igual que en el dashboard."
>
<div class="stat-card-grid">
<pi-card
alignment="flex-start"
gap="s"
innerPadding="m"
context="private"
color="white"
>
<pi-card-center-content>
<pi-stat-card
icon="coins"
label="Ingresos este mes"
value="12.4K €"
trend="+18%"
[trendUp]="true"
color="primary"
/>
</pi-card-center-content>
</pi-card>
<pi-card
alignment="flex-start"
gap="s"
innerPadding="m"
context="private"
color="white"
>
<pi-card-center-content>
<pi-stat-card
icon="document-euro"
label="Facturas pendientes"
value="7"
trend="-2"
[trendUp]="false"
color="accent"
/>
</pi-card-center-content>
</pi-card>
</div>
</app-section>
<app-section
title="Panel"
hint="pi-expansion-panel[type=form] usa Reactive Forms clásico, no Signal Forms."
@@ -517,6 +645,20 @@
</ng-template>
</app-section>
<app-section
title="Hub"
hint="pi-hub es como pi-drawer pero anclado abajo: se abre vía MatDialog con getHubConfigs y entra deslizándose desde el borde inferior."
>
<button pi-primary (click)="openDemoHub('default')">Abrir hub</button>
<button pi-outline (click)="openDemoHub('full')">
Abrir hub (pantalla completa)
</button>
<ng-template #hubTemplate>
<p>Contenido de ejemplo dentro del hub.</p>
<p>Puede llevar cualquier plantilla: formularios, listas, filtros…</p>
</ng-template>
</app-section>
<app-section
title="Popover"
hint="pi-popover es una directiva ([pi-popover]) sobre @angular/cdk/overlay: no hay componente Material de contenido enriquecido que envolver."
@@ -591,14 +733,61 @@
</div>
</app-section>
<app-section title="Chart">
<div class="stack full">
<pi-chart
type="line"
[device]="device"
[xAxisMarks]="['Ene', 'Feb', 'Mar', 'Abr']"
[datasets]="chartDatasets"
/>
<app-section
title="Chart"
hint="pi-line-chart, pi-bar-chart, pi-pie-chart, pi-doughnut-chart, pi-radar-chart y pi-gauge-chart son componentes independientes (cada uno registra solo lo suyo en Chart.js). line/bar/radar comparten datasets + pi-chart-legend; pie/doughnut usan slices y la leyenda nativa; gauge es un solo valor sobre un máximo."
>
<div class="chart-grid">
<div class="chart-grid__item">
<span class="chart-grid__label">pi-line-chart</span>
<pi-line-chart
[device]="device"
[xAxisMarks]="['Ene', 'Feb', 'Mar', 'Abr']"
[datasets]="chartDatasets"
/>
</div>
<div class="chart-grid__item">
<span class="chart-grid__label">pi-line-chart [filled]="true" (área)</span>
<pi-line-chart
[filled]="true"
[device]="device"
[xAxisMarks]="['Ene', 'Feb', 'Mar', 'Abr']"
[datasets]="chartDatasets"
/>
</div>
<div class="chart-grid__item">
<span class="chart-grid__label">pi-bar-chart</span>
<pi-bar-chart
[device]="device"
[xAxisMarks]="['Ene', 'Feb', 'Mar', 'Abr']"
[datasets]="barChartDatasets"
/>
</div>
<div class="chart-grid__item">
<span class="chart-grid__label">pi-pie-chart</span>
<pi-pie-chart [device]="device" [slices]="chartSlices" />
</div>
<div class="chart-grid__item">
<span class="chart-grid__label">pi-doughnut-chart</span>
<pi-doughnut-chart [device]="device" [slices]="chartSlices" />
</div>
<div class="chart-grid__item">
<span class="chart-grid__label">pi-radar-chart</span>
<pi-radar-chart
[device]="device"
[xAxisMarks]="['Velocidad', 'Fiabilidad', 'Precio', 'Soporte', 'Diseño']"
[datasets]="radarDatasets"
/>
</div>
<div class="chart-grid__item">
<span class="chart-grid__label">pi-gauge-chart</span>
<pi-gauge-chart
[device]="device"
[value]="72"
[max]="100"
label="satisfacción"
/>
</div>
</div>
</app-section>
@@ -666,10 +855,13 @@
<app-section
title="Feedback y navegación"
hint="pi-spinner se omite a propósito: es un overlay position:fixed de página completa, no un indicador en línea."
hint="pi-spinner se omite a propósito: es un overlay position:fixed de página completa, no un indicador en línea. pi-loader-bar tampoco se usa como etiqueta con inputs: vive arriba del todo (ver app.html) y se muestra sola siguiendo LoaderFacade."
>
<pi-progress-spinner label="Progreso" [value]="65" [diameter]="72" />
<pi-progress-bar label="Carga" [value]="65" />
<button pi-outline (click)="triggerLoaderBar()">
Simular carga (loader-bar arriba)
</button>
<pi-paginator
[(currentPage)]="currentPage"
[pageSize]="10"

View File

@@ -2,6 +2,17 @@
display: block;
}
.heart-star {
font-size: 1.25rem;
line-height: 1;
color: var(--pg-card-border);
transition: color 0.1s ease-out;
&.is-filled {
color: #e0245e;
}
}
.event {
display: inline-block;
margin-top: 0.75rem;
@@ -44,6 +55,23 @@ main {
gap: 1rem;
}
.field-error {
margin-top: -0.5rem;
font-size: 0.75rem;
color: var(--pi-color-warn);
}
.rating-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
> span {
font-size: 0.75rem;
color: var(--pg-text-muted);
}
}
.state {
margin: 0;
font-size: 0.85rem;
@@ -91,3 +119,29 @@ pre {
border: 1px dashed var(--pg-card-border);
border-radius: 8px;
}
.chart-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.5rem;
width: 100%;
}
.chart-grid__item {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.chart-grid__label {
font-size: 0.78rem;
font-family: monospace;
color: var(--pg-text-muted);
}
.stat-card-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.5rem;
width: 100%;
}

View File

@@ -21,8 +21,15 @@ import {
PiAccordionItemContentDirective,
PiAccordionItemDirective,
PiActionButtonComponent,
AvatarGroupItem,
PiAvatarComponent,
PiAvatarGroupComponent,
PiBadgeComponent,
BreadcrumbItem,
PiBreadcrumbComponent,
PiEmptyStateComponent,
SegmentedControlOption,
PiSegmentedControlComponent,
PiBannerComponent,
PiBaseDataTableComponent,
PiCardBottomContentDirective,
@@ -33,7 +40,10 @@ import {
PiCarouselItemDirective,
PiCellDirective,
PiCellTypeComponent,
PiChartComponent,
PiBarChartComponent,
PiDoughnutChartComponent,
PiLineChartComponent,
PiPieChartComponent,
PiChevronButtonComponent,
PiChipActionComponent,
PiChipComponent,
@@ -46,6 +56,7 @@ import {
PiCurrencyFieldComponent,
PiDataGridComponent,
PiDataTableItemComponent,
ChartSlice,
Dataset,
DatasetColor,
Device,
@@ -54,6 +65,9 @@ import {
PiDrawerComponent,
getDrawerConfigs,
PiEmailFieldComponent,
HubConfigKeys,
PiHubComponent,
getHubConfigs,
PiExpansionPanelComponent,
PiGhostButtonDirective,
PiHamburgerComponent,
@@ -72,6 +86,7 @@ import {
PiListItemLeftIconDirective,
PiListItemRightContentDirective,
PiListItemRightIconDirective,
LoaderFacade,
ModalConfigKeys,
ModalStyleType,
ModalType,
@@ -95,6 +110,7 @@ import {
PiRadioButtonComponent,
PiRange,
PiRangeSliderComponent,
PiRatingComponent,
PiSearchFieldComponent,
PiSelectCarouselComponent,
PiSelectComponent,
@@ -128,6 +144,16 @@ import {
PiDynamicMenuComponent,
PiSkeletonComponent,
PiStepperComponent,
PiTimelineComponent,
TimelineItem,
PiStatCardComponent,
PiRadarChartComponent,
PiGaugeChartComponent,
PiAutocompleteFieldComponent,
PiDateRangeFieldComponent,
DateRange,
PiTreeComponent,
TreeNode,
} from '@pi/ui';
import { ShowcaseSection } from '../showcase-section';
@@ -149,6 +175,7 @@ interface DemoModel {
range: PiRange;
volume: number;
newsletter: boolean;
satisfaction: number;
search: string;
website: string;
phone: string;
@@ -198,7 +225,19 @@ interface DemoModel {
PiTextViewerComponent,
PiDividerComponent,
PiAvatarComponent,
PiAvatarGroupComponent,
PiBadgeComponent,
PiBreadcrumbComponent,
PiEmptyStateComponent,
PiSegmentedControlComponent,
PiRatingComponent,
PiTimelineComponent,
PiStatCardComponent,
PiRadarChartComponent,
PiGaugeChartComponent,
PiAutocompleteFieldComponent,
PiDateRangeFieldComponent,
PiTreeComponent,
PiChipComponent,
PiChipStatusComponent,
PiChipValueComponent,
@@ -270,7 +309,10 @@ interface DemoModel {
PiBaseDataTableComponent,
PiDataTableItemComponent,
PiCellTypeComponent,
PiChartComponent,
PiBarChartComponent,
PiDoughnutChartComponent,
PiLineChartComponent,
PiPieChartComponent,
PiCarouselComponent,
PiCarouselItemDirective,
// Componentes nuevos
@@ -297,6 +339,7 @@ export class CatalogPage {
range: { from: 0, to: 10 },
volume: 40,
newsletter: false,
satisfaction: 0,
search: '',
website: '',
phone: '',
@@ -322,6 +365,7 @@ export class CatalogPage {
maxLength(m.bio, 140, { message: 'Máximo 140 caracteres' });
email(m.email, { message: 'Introduce un email válido' });
min(m.age, 18, { message: 'Debes ser mayor de edad' });
min(m.satisfaction, 1, { message: 'Valora con al menos una estrella' });
});
/** Se muestra en el panel lateral para ver el modelo en vivo. */
@@ -508,6 +552,14 @@ export class CatalogPage {
);
}
/** `pi-loader-bar` no tiene inputs: lee este estado compartido solo. */
private readonly loader = inject(LoaderFacade);
protected triggerLoaderBar(): void {
this.loader.showLoader();
setTimeout(() => this.loader.hideLoader(), 2000);
}
/* --- Drawer -------------------------------------------------------------- */
/** Igual que `pi-modal`: se abre desde TypeScript vía `MatDialog`. */
@@ -525,6 +577,118 @@ export class CatalogPage {
this.log(`drawer abierto (${side})`);
}
/* --- Hub ------------------------------------------------------------------ */
/** Igual que `pi-drawer`, pero anclado abajo con un deslizamiento vertical. */
private readonly hubTemplate =
viewChild.required<TemplateRef<unknown>>('hubTemplate');
protected openDemoHub(size: 'default' | 'full'): void {
this.dialog.open(
PiHubComponent,
getHubConfigs(size === 'full' ? HubConfigKeys.Full : HubConfigKeys.Default, {
title: 'Detalle',
template: this.hubTemplate(),
}),
);
this.log(`hub abierto (${size})`);
}
/* --- Avatar group, breadcrumb, segmented control ------------------------ */
protected readonly avatarGroupItems: AvatarGroupItem[] = [
{ name: 'Ada Lovelace', color: 'primary' },
{ name: 'Grace Hopper', color: 'accent' },
{ image: 'https://i.pravatar.cc/128?img=12' },
{ name: 'Margaret Hamilton', color: 'highlighted' },
{ name: 'Katherine Johnson', color: 'grey' },
];
protected readonly breadcrumbItems: BreadcrumbItem[] = [
{ label: 'Inicio', link: ['/'] },
{ label: 'Componentes', link: ['/componentes'] },
{ label: 'Avatar group' },
];
protected readonly densityOptions: SegmentedControlOption<string>[] = [
{ value: 'compact', label: 'Compacta' },
{ value: 'comfortable', label: 'Cómoda' },
];
protected readonly density = signal('comfortable');
protected readonly rating = signal(3);
protected readonly autocompleteValue = signal('');
protected readonly cityOptions: string[] = [
'Barcelona',
'Bilbao',
'Madrid',
'Málaga',
'Sevilla',
'Valencia',
'Zaragoza',
];
protected readonly dateRange = signal<DateRange>({ start: null, end: null });
protected readonly treeSelectedId = signal<string | undefined>(undefined);
protected readonly treeNodes: TreeNode[] = [
{
id: 'docs',
label: 'Documentación',
icon: 'document-euro',
children: [
{ id: 'docs-getting-started', label: 'Primeros pasos' },
{
id: 'docs-components',
label: 'Componentes',
children: [
{ id: 'docs-components-form', label: 'Formularios' },
{ id: 'docs-components-chart', label: 'Charts' },
],
},
],
},
{
id: 'settings',
label: 'Ajustes',
icon: 'user',
children: [
{ id: 'settings-profile', label: 'Perfil' },
{ id: 'settings-billing', label: 'Facturación' },
],
},
];
protected readonly timelineItems: TimelineItem[] = [
{
title: 'Pedido creado',
description: 'Se registró el pedido #4521.',
date: '28 jul, 09:12',
color: 'grey',
},
{
title: 'Pago confirmado',
description: 'Cargo aceptado por la pasarela de pago.',
date: '28 jul, 09:14',
color: 'primary',
},
{
title: 'Enviado',
description: 'Paquete entregado al transportista.',
date: '29 jul, 16:40',
color: 'accent',
},
{
title: 'Entregado',
date: '31 jul, 11:05',
color: 'success',
},
];
/* --- Datos: grid, tabla, gráfico, carrusel ------------------------------ */
protected readonly employees = [
@@ -552,6 +716,42 @@ export class CatalogPage {
},
];
protected readonly barChartDatasets: Dataset[] = [
{
label: 'Ventas',
data: [65, 82, 74, 96],
borderColor: DatasetColor.primary,
backgroundColor: DatasetColor.primary,
unit: ' uds.',
unitPosition: 'after',
},
];
protected readonly chartSlices: ChartSlice[] = [
{ label: 'Básico', value: 38, color: DatasetColor.primary },
{ label: 'Pro', value: 45, color: DatasetColor.accent },
{ label: 'Empresas', value: 17, color: DatasetColor.highlighted },
];
protected readonly radarDatasets: Dataset[] = [
{
label: 'Nuestro producto',
data: [8, 7, 6, 9, 8],
borderColor: DatasetColor.primary,
backgroundColor: DatasetColor.primary,
unit: '/10',
unitPosition: 'after',
},
{
label: 'Competencia',
data: [6, 6, 8, 5, 6],
borderColor: DatasetColor.grey,
backgroundColor: DatasetColor.grey,
unit: '/10',
unitPosition: 'after',
},
];
protected readonly carouselItems = [
{ id: 1, label: 'Elemento 1' },
{ id: 2, label: 'Elemento 2' },

View File

@@ -0,0 +1,193 @@
<div class="dashboard">
<aside class="dashboard__sidebar">
<div class="dashboard__brand">
<pi-icon icon="dashboard" color="primary" size="large" />
<span>Nimbus</span>
</div>
<pi-dynamic-menu
class="dashboard__menu"
[menuItems]="menuEntries()"
(selectEntry)="onSelectMenuEntry($any($event))"
/>
<div class="dashboard__sidebar-footer">
<pi-avatar name="Marta Ruiz" color="primary" size="small" />
<div>
<strong>Marta Ruiz</strong>
<span>COO, Nimbus</span>
</div>
</div>
</aside>
<div class="dashboard__main">
<header class="dashboard__topbar">
<pi-breadcrumb [items]="breadcrumbItems" />
<div class="dashboard__topbar-actions">
<pi-search-field
[formField]="f.query"
placeholder="Buscar clientes o facturas…"
/>
<pi-badge [value]="3" color="warn">
<pi-icon-button
icon="notification-on"
aria-label="Notificaciones"
[hasPadding]="false"
/>
</pi-badge>
<pi-avatar name="Marta Ruiz" color="primary" size="small" />
</div>
</header>
<section class="dashboard__stats">
@for (stat of stats; track stat.label) {
<pi-card
alignment="flex-start"
gap="s"
innerPadding="l"
context="private"
color="white"
>
<pi-card-top-content>
<div class="dashboard__stat-icon">
<pi-icon [icon]="stat.icon" color="primary" size="medium" />
</div>
</pi-card-top-content>
<pi-card-center-content>
<span class="dashboard__stat-label">{{ stat.label }}</span>
<strong class="dashboard__stat-value">{{ stat.value }}</strong>
</pi-card-center-content>
<pi-card-bottom-content>
<span
class="dashboard__stat-trend"
[class.is-down]="!stat.trendUp"
>
<pi-icon
[icon]="stat.trendUp ? 'metric-up-arrow' : 'metric-down-arrow'"
[color]="stat.trendUp ? 'success' : 'warn'"
size="x-small"
/>
{{ stat.trend }}
</span>
</pi-card-bottom-content>
</pi-card>
}
</section>
<section class="dashboard__content">
<div class="dashboard__content-main">
<pi-card
alignment="stretch"
gap="m"
innerPadding="l"
context="private"
color="white"
>
<pi-card-top-content>
<div class="dashboard__card-header">
<h3>Ingresos vs. gastos</h3>
<button pi-ghost small>Ver informe</button>
</div>
</pi-card-top-content>
<pi-card-center-content>
<pi-line-chart
[device]="device"
[xAxisMarks]="['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun']"
[datasets]="chartDatasets"
[isComparativeChart]="true"
/>
</pi-card-center-content>
</pi-card>
<pi-card
alignment="stretch"
gap="m"
innerPadding="l"
context="private"
color="white"
>
<pi-card-top-content>
<div class="dashboard__card-header">
<h3>Últimas facturas</h3>
<button pi-ghost small>Ver todas</button>
</div>
</pi-card-top-content>
<pi-card-center-content>
<div class="dashboard__table">
<div class="dashboard__table-row dashboard__table-row--head">
<span>Cliente</span>
<span>Importe</span>
<span>Fecha</span>
<span>Estado</span>
</div>
@for (row of invoices; track row.client) {
<div class="dashboard__table-row">
<span>{{ row.client }}</span>
<span>{{ row.amount }}</span>
<span>{{ row.date }}</span>
<pi-chip-status [status]="row.status" [label]="row.statusLabel" />
</div>
}
</div>
</pi-card-center-content>
</pi-card>
<pi-card
alignment="stretch"
gap="m"
innerPadding="l"
context="private"
color="white"
>
<pi-card-top-content>
<div class="dashboard__card-header">
<h3>Nuevos clientes por mes</h3>
<button pi-ghost small>Ver informe</button>
</div>
</pi-card-top-content>
<pi-card-center-content>
<pi-bar-chart
[device]="device"
[xAxisMarks]="['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun']"
[datasets]="newClientsDatasets"
[isComparativeChart]="false"
/>
</pi-card-center-content>
</pi-card>
</div>
<div class="dashboard__content-side">
<pi-card
alignment="stretch"
gap="m"
innerPadding="l"
context="private"
color="white"
>
<pi-card-top-content>
<h3>Facturas por estado</h3>
</pi-card-top-content>
<pi-card-center-content>
<pi-doughnut-chart [device]="device" [slices]="invoiceStatusSlices" />
</pi-card-center-content>
</pi-card>
<pi-card
alignment="stretch"
gap="m"
innerPadding="l"
context="private"
color="white"
>
<pi-card-top-content>
<h3>Actividad reciente</h3>
</pi-card-top-content>
<pi-card-center-content>
<pi-timeline [items]="timelineItems" />
</pi-card-center-content>
</pi-card>
</div>
</section>
</div>
</div>

View File

@@ -0,0 +1,222 @@
:host {
display: block;
}
.dashboard {
display: grid;
grid-template-columns: 16rem minmax(0, 1fr);
gap: var(--pi-spacing-x-large);
min-height: 80vh;
}
@media (max-width: 960px) {
.dashboard {
grid-template-columns: 1fr;
}
}
/* ---------- Sidebar ---------- */
.dashboard__sidebar {
display: flex;
flex-direction: column;
gap: var(--pi-spacing-x-large);
padding: var(--pi-spacing-large);
border-radius: var(--pi-border-radius-large);
background: var(--pg-card-bg);
border: 1px solid var(--pg-card-border);
}
.dashboard__brand {
display: flex;
align-items: center;
gap: var(--pi-spacing-xx-small);
font-weight: 700;
font-size: var(--pi-font-size-medium);
color: var(--pg-page-text);
padding: 0 var(--pi-spacing-xx-small);
}
.dashboard__menu {
flex: 1;
}
.dashboard__sidebar-footer {
display: flex;
align-items: center;
gap: var(--pi-spacing-small);
padding: var(--pi-spacing-small) var(--pi-spacing-xx-small) 0;
border-top: 1px solid var(--pg-card-border);
strong {
display: block;
font-size: var(--pi-font-size-x-small);
color: var(--pg-page-text);
}
span {
display: block;
font-size: var(--pi-font-size-xxx-small);
color: var(--pg-text-muted);
}
}
/* ---------- Main column ---------- */
.dashboard__main {
display: flex;
flex-direction: column;
gap: var(--pi-spacing-x-large);
min-width: 0;
}
.dashboard__topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--pi-spacing-large);
flex-wrap: wrap;
}
.dashboard__topbar-actions {
display: flex;
align-items: center;
gap: var(--pi-spacing-medium);
}
/* ---------- Stats ---------- */
.dashboard__stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--pi-spacing-large);
}
@media (max-width: 1200px) {
.dashboard__stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.dashboard__stats {
grid-template-columns: 1fr;
}
}
.dashboard__stat-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: var(--pi-border-radius-medium);
background: var(--pi-color-primary-25);
}
.dashboard__stat-label {
display: block;
font-size: var(--pi-font-size-x-small);
color: var(--pg-text-muted);
}
.dashboard__stat-value {
display: block;
margin-top: var(--pi-spacing-xxxx-small);
font-size: var(--pi-font-size-xx-large);
color: var(--pg-page-text);
}
.dashboard__stat-trend {
display: inline-flex;
align-items: center;
gap: var(--pi-spacing-xxxx-small);
font-size: var(--pi-font-size-x-small);
font-weight: 600;
color: var(--pi-color-success);
&.is-down {
color: var(--pi-color-warn);
}
}
/* ---------- Contenido: gráfico + tabla / actividad ---------- */
.dashboard__content {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
gap: var(--pi-spacing-large);
align-items: start;
}
@media (max-width: 1100px) {
.dashboard__content {
grid-template-columns: 1fr;
}
}
.dashboard__content-main {
display: flex;
flex-direction: column;
gap: var(--pi-spacing-large);
min-width: 0;
}
.dashboard__content-side {
display: flex;
flex-direction: column;
gap: var(--pi-spacing-large);
min-width: 0;
}
.dashboard__card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--pi-spacing-small);
h3 {
margin: 0;
font-size: var(--pi-font-size-medium);
color: var(--pg-page-text);
}
}
.dashboard__content-side pi-card h3 {
margin: 0;
font-size: var(--pi-font-size-medium);
color: var(--pg-page-text);
}
/* ---------- Tabla de facturas ---------- */
.dashboard__table {
display: flex;
flex-direction: column;
}
.dashboard__table-row {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr) minmax(
0,
0.8fr
);
align-items: center;
gap: var(--pi-spacing-medium);
padding: var(--pi-spacing-small) var(--pi-spacing-xx-small);
border-bottom: 1px solid var(--pg-card-border);
font-size: var(--pi-font-size-small);
color: var(--pg-page-text);
&:last-child {
border-bottom: none;
}
}
.dashboard__table-row--head {
font-size: var(--pi-font-size-xxx-small);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--pg-text-muted);
font-weight: 600;
}

View File

@@ -0,0 +1,239 @@
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { FormField, form } from '@angular/forms/signals';
import {
PiAvatarComponent,
PiBadgeComponent,
PiBarChartComponent,
BreadcrumbItem,
PiBreadcrumbComponent,
PiCardBottomContentDirective,
PiCardCenterContentDirective,
PiCardComponent,
PiCardTopContentDirective,
ChartSlice,
PiDoughnutChartComponent,
PiLineChartComponent,
PiChipStatusComponent,
Dataset,
DatasetColor,
Device,
PiDynamicMenuComponent,
PiGhostButtonDirective,
PiIconButtonComponent,
PiIconComponent,
PiSearchFieldComponent,
PiSmallButtonDirective,
PiTimelineComponent,
TimelineItem,
MenuEntry,
} from '@pi/ui';
interface SearchModel {
query: string;
}
interface Stat {
icon: string;
label: string;
value: string;
trend: string;
trendUp: boolean;
}
interface InvoiceRow {
client: string;
amount: string;
date: string;
status: 'success' | 'pending' | 'error';
statusLabel: string;
}
@Component({
selector: 'app-dashboard',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './dashboard.page.html',
styleUrl: './dashboard.page.scss',
imports: [
FormField,
PiAvatarComponent,
PiBadgeComponent,
PiBreadcrumbComponent,
PiCardBottomContentDirective,
PiCardCenterContentDirective,
PiCardComponent,
PiCardTopContentDirective,
PiBarChartComponent,
PiDoughnutChartComponent,
PiLineChartComponent,
PiChipStatusComponent,
PiDynamicMenuComponent,
PiGhostButtonDirective,
PiIconButtonComponent,
PiIconComponent,
PiSearchFieldComponent,
PiSmallButtonDirective,
PiTimelineComponent,
],
})
export class DashboardPage {
protected readonly device: Device = 'DESKTOP';
protected readonly menuEntries = signal<MenuEntry[]>([
{ id: 1, label: 'Panel', icon: 'dashboard', isActive: true },
{ id: 2, label: 'Clientes', icon: 'briefcase' },
{ id: 3, label: 'Facturas', icon: 'document-euro' },
{ id: 4, label: 'Pagos', icon: 'coins' },
{ id: 5, label: 'Ajustes', icon: 'user' },
]);
protected readonly breadcrumbItems: BreadcrumbItem[] = [
{ label: 'Nimbus', link: ['/ejemplos/dashboard'] },
{ label: 'Panel' },
];
private readonly searchModel = signal<SearchModel>({ query: '' });
protected readonly f = form(this.searchModel, () => {});
protected readonly stats: Stat[] = [
{
icon: 'coins',
label: 'Ingresos este mes',
value: '12.4K €',
trend: '+18%',
trendUp: true,
},
{
icon: 'document-euro',
label: 'Facturas pendientes',
value: '7',
trend: '-2',
trendUp: false,
},
{
icon: 'briefcase',
label: 'Clientes activos',
value: '184',
trend: '+5',
trendUp: true,
},
{
icon: 'time-detail',
label: 'Tiempo medio de cobro',
value: '9 días',
trend: '-1,2',
trendUp: true,
},
];
protected readonly chartDatasets: Dataset[] = [
{
label: 'Ingresos',
data: [38, 52, 47, 63, 59, 74],
borderColor: DatasetColor.primary,
backgroundColor: DatasetColor.primary,
cubicInterpolationMode: 'monotone',
unit: 'K €',
unitPosition: 'after',
},
{
label: 'Gastos',
data: [22, 26, 24, 30, 28, 31],
borderColor: DatasetColor.grey,
backgroundColor: DatasetColor.grey,
cubicInterpolationMode: 'monotone',
unit: 'K €',
unitPosition: 'after',
},
];
protected readonly newClientsDatasets: Dataset[] = [
{
label: 'Nuevos clientes',
data: [8, 12, 9, 14, 11, 17],
borderColor: DatasetColor.highlighted,
backgroundColor: DatasetColor.highlighted,
unit: '',
unitPosition: 'after',
},
];
/** Coincide con el desglose real de `invoices`: 2 pagadas, 2 pendientes, 1 rechazada. */
protected readonly invoiceStatusSlices: ChartSlice[] = [
{ label: 'Pagadas', value: 2, color: DatasetColor.success },
{ label: 'Pendientes', value: 2, color: DatasetColor.accent },
{ label: 'Rechazadas', value: 1, color: DatasetColor.warn },
];
protected readonly invoices: InvoiceRow[] = [
{
client: 'Cocina Litoral',
amount: '1.250,00 €',
date: '28/07/2026',
status: 'success',
statusLabel: 'Pagada',
},
{
client: 'Bloom Studio',
amount: '890,50 €',
date: '26/07/2026',
status: 'pending',
statusLabel: 'Pendiente',
},
{
client: 'Vela Textil',
amount: '2.140,00 €',
date: '22/07/2026',
status: 'success',
statusLabel: 'Pagada',
},
{
client: 'Norte Legal',
amount: '430,00 €',
date: '18/07/2026',
status: 'error',
statusLabel: 'Rechazada',
},
{
client: 'Rueda Logística',
amount: '675,25 €',
date: '15/07/2026',
status: 'pending',
statusLabel: 'Pendiente',
},
];
protected readonly timelineItems: TimelineItem[] = [
{
title: 'Factura #4521 pagada',
description: 'Cocina Litoral · 1.250,00 €',
date: 'Hoy, 09:12',
color: 'primary',
},
{
title: 'Nuevo cliente',
description: 'Rueda Logística se ha dado de alta.',
date: 'Ayer, 17:40',
color: 'accent',
},
{
title: 'Factura #4498 rechazada',
description: 'Norte Legal · pago rechazado por el banco.',
date: 'Ayer, 11:05',
color: 'grey',
},
{
title: 'Recordatorio enviado',
description: 'Bloom Studio · factura #4510 pendiente.',
date: '26 jul, 16:20',
color: 'success',
},
];
protected onSelectMenuEntry(entry: MenuEntry): void {
this.menuEntries.update((entries) =>
entries.map((item) => ({ ...item, isActive: item.id === entry.id })),
);
}
}

View File

@@ -0,0 +1,242 @@
<div class="landing">
<!-- Nav -->
<nav class="landing__nav">
<div class="landing__logo">
<pi-icon icon="dashboard" color="primary" size="large" />
<span>Nimbus</span>
</div>
<div class="landing__nav-links">
<a href="javascript:void(0)">Producto</a>
<a href="javascript:void(0)">Precios</a>
<a href="javascript:void(0)">Clientes</a>
</div>
<div class="landing__nav-actions">
<button pi-outline small>Iniciar sesión</button>
<button pi-primary small>Empezar gratis</button>
</div>
</nav>
<!-- Hero -->
<header class="landing__hero">
<div class="landing__hero-copy">
<pi-chip label="Nuevo: previsión de caja con IA" color="primary" />
<h1>Todo el control de tu negocio, en un solo panel.</h1>
<p>
Nimbus conecta tu facturación, tus clientes y tu banco para que dejes
de perseguir números en hojas de cálculo y empieces a tomar
decisiones con datos en tiempo real.
</p>
<div class="landing__hero-actions">
<button pi-primary large>Empieza gratis 14 días</button>
<button pi-outline large>Ver una demo</button>
</div>
<p class="landing__hero-note">
Sin tarjeta de crédito · Cancela cuando quieras
</p>
</div>
<div class="landing__hero-preview">
<pi-card
alignment="stretch"
gap="m"
innerPadding="l"
context="public"
color="white"
class="landing__preview-card"
>
<pi-card-top-content>
<div class="landing__preview-header">
<span>Ingresos</span>
<pi-chip-status status="success" label="+18% vs. mes anterior" />
</div>
</pi-card-top-content>
<pi-card-center-content>
<pi-line-chart
[device]="device"
[xAxisMarks]="['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun']"
[datasets]="heroChartData"
/>
</pi-card-center-content>
</pi-card>
</div>
</header>
<!-- Prueba social -->
<section class="landing__logos">
<span>Con la confianza de equipos en</span>
<div class="landing__logos-row">
<span>Cocina Litoral</span>
<span>Bloom Studio</span>
<span>Vela Textil</span>
<span>Norte Legal</span>
<span>Rueda Logística</span>
</div>
</section>
<!-- Features -->
<section class="landing__section">
<div class="landing__section-heading">
<h2>Todo lo que necesitas para llevar las cuentas</h2>
<p>Sin implementaciones eternas ni consultores. Listo en un día.</p>
</div>
<div class="landing__features">
@for (feature of features; track feature.title) {
<pi-card
alignment="flex-start"
gap="s"
innerPadding="l"
context="public"
color="white"
>
<pi-card-top-content>
<div class="landing__feature-icon">
<pi-icon [icon]="feature.icon" color="primary" size="medium" />
</div>
</pi-card-top-content>
<pi-card-center-content>
<h3>{{ feature.title }}</h3>
<p>{{ feature.description }}</p>
</pi-card-center-content>
</pi-card>
}
</div>
</section>
<!-- Stats -->
<section class="landing__stats">
@for (stat of stats; track stat.label) {
<div class="landing__stat">
<strong>{{ stat.value }}</strong>
<span>{{ stat.label }}</span>
</div>
}
</section>
<!-- Testimonios -->
<section class="landing__section">
<div class="landing__section-heading">
<h2>Lo que dicen los equipos que ya usan Nimbus</h2>
</div>
<div class="landing__testimonials">
@for (testimonial of testimonials; track testimonial.name) {
<pi-card
alignment="flex-start"
gap="m"
innerPadding="l"
context="public"
color="white"
>
<pi-card-center-content>
<p class="landing__testimonial-quote">«{{ testimonial.quote }}»</p>
</pi-card-center-content>
<pi-card-bottom-content>
<div class="landing__testimonial-author">
<pi-avatar [name]="testimonial.name" [color]="testimonial.color" />
<div>
<strong>{{ testimonial.name }}</strong>
<span>{{ testimonial.role }}</span>
</div>
</div>
</pi-card-bottom-content>
</pi-card>
}
</div>
</section>
<!-- Precios -->
<section class="landing__section" id="precios">
<div class="landing__section-heading">
<h2>Un plan para cada etapa</h2>
<p>Cambia de plan cuando quieras, sin penalizaciones.</p>
</div>
<div class="landing__plans">
@for (plan of plans; track plan.name) {
<pi-card
alignment="stretch"
gap="m"
innerPadding="xl"
context="public"
[color]="plan.isFeatured ? 'primary' : 'white'"
[class.landing__plan--featured]="plan.isFeatured"
>
<pi-card-top-content>
@if (plan.isFeatured) {
<pi-chip label="Más popular" color="primary-white" />
}
<h3>{{ plan.name }}</h3>
<p class="landing__plan-description">{{ plan.description }}</p>
<p class="landing__plan-price">
<strong>{{ plan.price }} €</strong>
<span>{{ plan.period }}</span>
</p>
</pi-card-top-content>
<pi-card-center-content>
<ul class="landing__plan-features">
@for (item of plan.features; track item) {
<li>
<pi-icon
icon="check-rounded"
[color]="plan.isFeatured ? 'white' : 'primary'"
size="small"
/>
<span>{{ item }}</span>
</li>
}
</ul>
</pi-card-center-content>
<pi-card-bottom-content>
@if (plan.isFeatured) {
<button pi-white w100>{{ plan.cta }}</button>
} @else {
<button pi-outline w100>{{ plan.cta }}</button>
}
</pi-card-bottom-content>
</pi-card>
}
</div>
</section>
<!-- FAQ -->
<section class="landing__section landing__section--narrow">
<div class="landing__section-heading">
<h2>Preguntas frecuentes</h2>
</div>
<pi-accordion
color="primary"
appearance="outline"
[(expandedItemIndex)]="expandedFaqIndex"
>
@for (faq of faqs; track faq.question) {
<pi-accordion-item
[title]="faq.question"
[status]="faq.tagStatus"
[statusLabel]="faq.tag"
>
<ng-template pi-accordion-item-expandable-content>
<p>{{ faq.answer }}</p>
</ng-template>
</pi-accordion-item>
}
</pi-accordion>
</section>
<!-- CTA final -->
<section class="landing__cta">
<h2>Deja de perseguir números. Empieza a decidir con datos.</h2>
<p>Prueba Nimbus gratis durante 14 días. Sin tarjeta de crédito.</p>
<button pi-white large>Crear cuenta gratis</button>
</section>
<!-- Footer -->
<footer class="landing__footer">
<div class="landing__logo">
<pi-icon icon="dashboard" color="grey" size="medium" />
<span>Nimbus</span>
</div>
<p>© 2026 Nimbus. Pantalla de ejemplo generada con @pi/ui.</p>
</footer>
</div>

View File

@@ -0,0 +1,409 @@
:host {
display: block;
}
.landing {
display: flex;
flex-direction: column;
gap: var(--pi-spacing-xxxx-large);
max-width: 72rem;
margin: 0 auto;
padding-bottom: var(--pi-spacing-xxxx-large);
}
h1,
h2,
h3 {
color: var(--pg-page-text);
}
/* ---------- Nav ---------- */
.landing__nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--pi-spacing-large);
padding: var(--pi-spacing-medium) 0;
}
.landing__nav-links {
display: flex;
gap: var(--pi-spacing-x-large);
a {
color: var(--pg-text-muted);
text-decoration: none;
font-size: var(--pi-font-size-small);
font-weight: 600;
&:hover {
color: var(--pi-color-primary);
}
}
}
.landing__nav-actions {
display: flex;
gap: var(--pi-spacing-small);
}
.landing__logo {
display: flex;
align-items: center;
gap: var(--pi-spacing-xx-small);
font-weight: 700;
font-size: var(--pi-font-size-medium);
color: var(--pg-page-text);
}
/* ---------- Hero ---------- */
.landing__hero {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--pi-spacing-xxx-large);
align-items: center;
padding: var(--pi-spacing-x-large) 0 var(--pi-spacing-xxxx-large);
}
@media (max-width: 900px) {
.landing__hero {
grid-template-columns: 1fr;
}
}
.landing__hero-copy {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--pi-spacing-large);
h1 {
margin: 0;
font-size: var(--pi-font-size-xxx-large);
line-height: 1.1;
}
> p {
margin: 0;
color: var(--pg-text-muted);
font-size: var(--pi-font-size-medium);
line-height: 1.6;
max-width: 32rem;
}
}
.landing__hero-actions {
display: flex;
gap: var(--pi-spacing-small);
}
.landing__hero-note {
font-size: var(--pi-font-size-x-small);
color: var(--pg-text-muted);
}
.landing__preview-card {
box-shadow: 0 40px 80px -40px rgba(20, 30, 70, 0.35) !important;
}
.landing__preview-header {
display: flex;
align-items: center;
justify-content: space-between;
span {
font-weight: 700;
color: var(--pg-page-text);
}
}
/* ---------- Logos ---------- */
.landing__logos {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--pi-spacing-medium);
padding: var(--pi-spacing-large) 0;
border-top: 1px solid var(--pg-card-border);
border-bottom: 1px solid var(--pg-card-border);
> span {
font-size: var(--pi-font-size-x-small);
color: var(--pg-text-muted);
}
}
.landing__logos-row {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--pi-spacing-xx-large);
span {
font-weight: 700;
color: var(--pg-text-muted);
opacity: 0.7;
}
}
/* ---------- Secciones genéricas ---------- */
.landing__section {
display: flex;
flex-direction: column;
gap: var(--pi-spacing-xx-large);
}
.landing__section--narrow {
max-width: 42rem;
margin: 0 auto;
width: 100%;
}
.landing__section-heading {
display: flex;
flex-direction: column;
gap: var(--pi-spacing-xxx-small);
text-align: center;
h2 {
margin: 0;
font-size: var(--pi-font-size-xx-large);
}
p {
margin: 0;
color: var(--pg-text-muted);
}
}
/* ---------- Features ---------- */
.landing__features {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--pi-spacing-large);
}
@media (max-width: 900px) {
.landing__features {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.landing__features {
grid-template-columns: 1fr;
}
}
.landing__feature-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
border-radius: var(--pi-border-radius-medium);
background: var(--pi-color-primary-25);
}
.landing__features h3 {
margin: 0 0 var(--pi-spacing-xxx-small);
font-size: var(--pi-font-size-medium);
}
.landing__features p {
margin: 0;
color: var(--pg-text-muted);
font-size: var(--pi-font-size-small);
line-height: 1.5;
}
/* ---------- Stats ---------- */
.landing__stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--pi-spacing-large);
padding: var(--pi-spacing-xx-large);
border-radius: var(--pi-border-radius-large);
background: var(--pg-card-bg);
border: 1px solid var(--pg-card-border);
}
@media (max-width: 700px) {
.landing__stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.landing__stat {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--pi-spacing-xxxx-small);
text-align: center;
strong {
font-size: var(--pi-font-size-xx-large);
color: var(--pi-color-primary);
}
span {
font-size: var(--pi-font-size-x-small);
color: var(--pg-text-muted);
}
}
/* ---------- Testimonios ---------- */
.landing__testimonials {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--pi-spacing-large);
}
@media (max-width: 900px) {
.landing__testimonials {
grid-template-columns: 1fr;
}
}
.landing__testimonial-quote {
margin: 0;
font-style: italic;
color: var(--pg-page-text);
line-height: 1.6;
}
.landing__testimonial-author {
display: flex;
align-items: center;
gap: var(--pi-spacing-small);
strong {
display: block;
font-size: var(--pi-font-size-small);
color: var(--pg-page-text);
}
span {
display: block;
font-size: var(--pi-font-size-xxx-small);
color: var(--pg-text-muted);
}
}
/* ---------- Precios ---------- */
.landing__plans {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--pi-spacing-large);
align-items: stretch;
}
@media (max-width: 900px) {
.landing__plans {
grid-template-columns: 1fr;
}
}
.landing__plan--featured {
transform: scale(1.03);
border: 2px solid var(--pi-color-primary);
box-shadow: 0 24px 48px -24px rgba(87, 114, 212, 0.45) !important;
}
.landing__plans h3 {
margin: var(--pi-spacing-small) 0 0;
font-size: var(--pi-font-size-large);
}
.landing__plan-description {
margin: var(--pi-spacing-xxxx-small) 0 0;
font-size: var(--pi-font-size-x-small);
opacity: 0.85;
}
.landing__plan-price {
margin: var(--pi-spacing-medium) 0 0;
display: flex;
align-items: baseline;
gap: var(--pi-spacing-xxxx-small);
strong {
font-size: var(--pi-font-size-xxx-large);
}
span {
font-size: var(--pi-font-size-x-small);
opacity: 0.75;
}
}
.landing__plan-features {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--pi-spacing-small);
li {
display: flex;
align-items: center;
gap: var(--pi-spacing-xx-small);
font-size: var(--pi-font-size-small);
}
}
/* ---------- CTA final ---------- */
.landing__cta {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--pi-spacing-medium);
text-align: center;
padding: var(--pi-spacing-xxxx-large);
border-radius: var(--pi-border-radius-large);
background: linear-gradient(
135deg,
var(--pi-color-primary-900),
var(--pi-color-highlighted)
);
color: var(--pi-color-white);
h2 {
margin: 0;
color: var(--pi-color-white);
font-size: var(--pi-font-size-xx-large);
max-width: 34rem;
}
p {
margin: 0;
color: var(--pi-color-primary-100);
}
}
/* ---------- Footer ---------- */
.landing__footer {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--pi-spacing-small);
padding-top: var(--pi-spacing-x-large);
border-top: 1px solid var(--pg-card-border);
p {
margin: 0;
font-size: var(--pi-font-size-xxx-small);
color: var(--pg-text-muted);
}
}

View File

@@ -0,0 +1,220 @@
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import {
PiAccordionComponent,
PiAccordionItemContentDirective,
PiAccordionItemDirective,
PiAvatarComponent,
PiCardBottomContentDirective,
PiCardCenterContentDirective,
PiCardComponent,
PiCardTopContentDirective,
PiLineChartComponent,
PiChipComponent,
PiChipStatusComponent,
PiColorPrimaryDirective,
PiColorWhiteDirective,
Dataset,
DatasetColor,
Device,
PiIconComponent,
PiLargeButtonDirective,
PiOutlineButtonDirective,
PiSmallButtonDirective,
PiW100ButtonDirective,
} from '@pi/ui';
interface Feature {
icon: string;
title: string;
description: string;
}
interface Plan {
name: string;
price: string;
period: string;
description: string;
isFeatured: boolean;
features: string[];
cta: string;
}
interface Faq {
question: string;
answer: string;
tagStatus: 'success' | 'info' | 'pending';
tag: string;
}
@Component({
selector: 'app-landing',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './landing.page.html',
styleUrl: './landing.page.scss',
imports: [
PiAccordionComponent,
PiAccordionItemContentDirective,
PiAccordionItemDirective,
PiAvatarComponent,
PiCardBottomContentDirective,
PiCardCenterContentDirective,
PiCardComponent,
PiCardTopContentDirective,
PiLineChartComponent,
PiChipComponent,
PiChipStatusComponent,
PiColorPrimaryDirective,
PiColorWhiteDirective,
PiIconComponent,
PiLargeButtonDirective,
PiOutlineButtonDirective,
PiSmallButtonDirective,
PiW100ButtonDirective,
],
})
export class LandingPage {
protected readonly device: Device = 'DESKTOP';
protected readonly heroChartData: Dataset[] = [
{
label: 'Ingresos',
data: [38, 52, 47, 63, 59, 74],
borderColor: DatasetColor.primary,
backgroundColor: DatasetColor.primary,
cubicInterpolationMode: 'monotone',
unit: 'K €',
unitPosition: 'after',
},
];
protected readonly features: Feature[] = [
{
icon: 'dashboard',
title: 'Panel unificado',
description:
'Ventas, gastos y caja en una sola vista, actualizada al segundo.',
},
{
icon: 'document-euro',
title: 'Facturación automática',
description:
'Genera y envía facturas recurrentes sin tocar una hoja de cálculo.',
},
{
icon: 'briefcase',
title: 'Gestión de clientes',
description: 'Historial de pedidos, notas y contratos en una ficha.',
},
{
icon: 'connect',
title: 'Se integra con tu stack',
description:
'Conecta tu banco, tu ERP y tu CRM en minutos, sin escribir código.',
},
];
protected readonly stats = [
{ value: '2.400+', label: 'Empresas activas' },
{ value: '98%', label: 'Retención anual' },
{ value: '12M €', label: 'Facturados cada mes' },
{ value: '4.9/5', label: 'Valoración media' },
];
protected readonly testimonials = [
{
quote:
'Migramos toda la contabilidad a Nimbus en una tarde. El equipo de soporte respondió en minutos.',
name: 'Marta Ruiz',
role: 'COO, Cocina Litoral',
color: 'primary' as const,
},
{
quote:
'Por fin dejamos de perseguir números en spreadsheets. El panel en tiempo real cambió cómo tomamos decisiones.',
name: 'Diego Ortiz',
role: 'CEO, Bloom Studio',
color: 'accent' as const,
},
{
quote:
'La facturación automática nos ahorra unas 6 horas a la semana solo en el equipo de administración.',
name: 'Laura Campos',
role: 'Finanzas, Vela Textil',
color: 'highlighted' as const,
},
];
protected readonly plans: Plan[] = [
{
name: 'Básico',
price: '9',
period: '/mes',
description: 'Para autónomos que empiezan a facturar.',
isFeatured: false,
features: ['Hasta 20 facturas/mes', '1 usuario', 'Soporte por email'],
cta: 'Empezar gratis',
},
{
name: 'Pro',
price: '29',
period: '/mes',
description: 'Para equipos que necesitan más control.',
isFeatured: true,
features: [
'Facturas ilimitadas',
'Hasta 10 usuarios',
'Panel de métricas en vivo',
'Soporte prioritario',
],
cta: 'Empezar prueba de 14 días',
},
{
name: 'Empresas',
price: '99',
period: '/mes',
description: 'Para organizaciones con necesidades a medida.',
isFeatured: false,
features: [
'Todo lo de Pro',
'Usuarios ilimitados',
'Integraciones a medida',
'Gestor de cuenta dedicado',
],
cta: 'Hablar con ventas',
},
];
protected readonly faqs: Faq[] = [
{
question: '¿Puedo cancelar cuando quiera?',
answer:
'Sí. No hay permanencia: puedes cancelar tu suscripción desde el panel en cualquier momento y seguirás teniendo acceso hasta el final del periodo pagado.',
tagStatus: 'success',
tag: 'Planes',
},
{
question: '¿Cómo migro mis datos actuales?',
answer:
'Importamos clientes, facturas y catálogo desde una hoja de cálculo o desde tu herramienta anterior. El equipo de soporte te acompaña durante todo el proceso.',
tagStatus: 'pending',
tag: 'Migración',
},
{
question: '¿Mis datos están seguros?',
answer:
'Toda la información viaja cifrada y se aloja en servidores dentro de la UE, con copias de seguridad diarias.',
tagStatus: 'info',
tag: 'Seguridad',
},
{
question: '¿Ofrecéis soporte en español?',
answer:
'Sí, todo nuestro equipo de soporte atiende en español por chat y email, con un tiempo de respuesta medio de menos de una hora en horario laboral.',
tagStatus: 'info',
tag: 'Soporte',
},
];
protected readonly expandedFaqIndex = signal<number | null>(0);
}

View File

@@ -0,0 +1,92 @@
<div class="login">
<section class="login__brand">
<div class="login__brand-content">
<div class="login__logo">
<pi-icon icon="dashboard" color="white" size="large" />
<span>Nimbus</span>
</div>
<h1>Todo el control de tu negocio, en un solo panel.</h1>
<p>
Facturación, clientes y métricas en tiempo real. Nimbus conecta tus
herramientas para que dejes de perseguir números en hojas de cálculo.
</p>
<ul class="login__highlights">
@for (item of highlights; track item.text) {
<li>
<pi-icon [icon]="item.icon" color="white" size="small" />
<span>{{ item.text }}</span>
</li>
}
</ul>
<pi-card
alignment="flex-start"
gap="s"
innerPadding="m"
context="public"
color="white"
[hasShadow]="true"
class="login__quote"
>
<pi-card-center-content>
<p>
«Migramos toda la contabilidad a Nimbus en una tarde. El equipo de
soporte respondió en minutos.»
</p>
</pi-card-center-content>
<pi-card-bottom-content>
<div class="login__quote-author">
<pi-avatar name="Marta Ruiz" color="primary" size="small" />
<div>
<strong>Marta Ruiz</strong>
<span>COO, Cocina Litoral</span>
</div>
</div>
</pi-card-bottom-content>
</pi-card>
</div>
</section>
<section class="login__form-side">
<pi-card
alignment="stretch"
gap="l"
innerPadding="xl"
context="public"
color="white"
class="login__form-card"
>
<pi-card-top-content>
<h2>Inicia sesión</h2>
<p class="login__form-hint">
¿No tienes cuenta todavía?
<a href="javascript:void(0)">Prueba Nimbus gratis</a>
</p>
</pi-card-top-content>
<pi-card-center-content>
<form class="login__form" (submit)="$event.preventDefault(); submit()">
<pi-email-field [formField]="f.email" placeholder="tu@empresa.com">
<pi-label>Email</pi-label>
</pi-email-field>
<pi-password-field [formField]="f.password" placeholder="••••••••">
<pi-label>Contraseña</pi-label>
</pi-password-field>
<div class="login__form-row">
<label class="login__remember">
<pi-slide-toggle [formField]="f.remember" />
<span>Recuérdame</span>
</label>
<a href="javascript:void(0)">¿Olvidaste tu contraseña?</a>
</div>
<button pi-primary w100 large type="submit">Entrar</button>
</form>
</pi-card-center-content>
</pi-card>
</section>
</div>

View File

@@ -0,0 +1,179 @@
:host {
display: block;
}
.login {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr);
min-height: 80vh;
border-radius: 1.25rem;
overflow: hidden;
box-shadow: 0 30px 60px -30px rgba(20, 30, 70, 0.25);
}
@media (max-width: 860px) {
.login {
grid-template-columns: 1fr;
min-height: auto;
}
}
/* ---------- Panel de marca ---------- */
.login__brand {
display: flex;
align-items: center;
padding: var(--pi-spacing-xxxx-large);
background:
radial-gradient(
circle at 20% 20%,
rgba(255, 255, 255, 0.14),
transparent 45%
),
linear-gradient(
145deg,
var(--pi-color-primary-900),
var(--pi-color-highlighted)
);
color: var(--pi-color-white);
}
.login__brand-content {
display: flex;
flex-direction: column;
gap: var(--pi-spacing-x-large);
max-width: 26rem;
}
.login__logo {
display: flex;
align-items: center;
gap: var(--pi-spacing-small);
font-size: var(--pi-font-size-large);
font-weight: 700;
}
.login__brand-content h1 {
margin: 0;
font-size: var(--pi-font-size-xx-large);
line-height: 1.15;
}
.login__brand-content > p {
margin: 0;
color: var(--pi-color-primary-100);
line-height: 1.6;
}
.login__highlights {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--pi-spacing-small);
li {
display: flex;
align-items: center;
gap: var(--pi-spacing-small);
font-size: var(--pi-font-size-small);
}
}
.login__quote {
margin-top: var(--pi-spacing-small);
background: rgba(255, 255, 255, 0.08) !important;
p {
margin: 0;
font-style: italic;
color: var(--pi-color-white);
line-height: 1.5;
}
}
.login__quote-author {
display: flex;
align-items: center;
gap: var(--pi-spacing-small);
strong {
display: block;
font-size: var(--pi-font-size-x-small);
}
span {
display: block;
font-size: var(--pi-font-size-xxx-small);
color: var(--pi-color-primary-100);
}
}
/* ---------- Panel de formulario ---------- */
.login__form-side {
display: flex;
align-items: center;
justify-content: center;
padding: var(--pi-spacing-xxxx-large) var(--pi-spacing-x-large);
background: var(--pi-color-white);
}
.login__form-card {
width: 100%;
max-width: 24rem;
box-shadow: none !important;
}
.login__form-card h2 {
margin: 0 0 var(--pi-spacing-xxx-small);
font-size: var(--pi-font-size-x-large);
color: var(--pg-page-text);
}
.login__form-hint {
margin: 0;
color: var(--pg-text-muted);
font-size: var(--pi-font-size-small);
a {
color: var(--pi-color-primary);
font-weight: 600;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
.login__form {
display: flex;
flex-direction: column;
gap: var(--pi-spacing-large);
}
.login__form-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--pi-spacing-small);
font-size: var(--pi-font-size-small);
a {
color: var(--pi-color-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
.login__remember {
display: flex;
align-items: center;
gap: var(--pi-spacing-xx-small);
color: var(--pg-text-muted);
}

View File

@@ -0,0 +1,68 @@
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { FormField, email, form } from '@angular/forms/signals';
import {
PiAvatarComponent,
PiCardBottomContentDirective,
PiCardCenterContentDirective,
PiCardComponent,
PiCardTopContentDirective,
PiColorPrimaryDirective,
PiEmailFieldComponent,
PiIconComponent,
PiLabelDirective,
PiLargeButtonDirective,
PiPasswordFieldComponent,
PiSlideToggleComponent,
PiW100ButtonDirective,
} from '@pi/ui';
interface LoginModel {
email: string;
password: string;
remember: boolean;
}
@Component({
selector: 'app-login',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './login.page.html',
styleUrl: './login.page.scss',
imports: [
FormField,
PiAvatarComponent,
PiCardBottomContentDirective,
PiCardCenterContentDirective,
PiCardComponent,
PiCardTopContentDirective,
PiColorPrimaryDirective,
PiEmailFieldComponent,
PiIconComponent,
PiLabelDirective,
PiLargeButtonDirective,
PiPasswordFieldComponent,
PiSlideToggleComponent,
PiW100ButtonDirective,
],
})
export class LoginPage {
protected readonly highlights = [
{ icon: 'check-rounded', text: 'Reportes en tiempo real, sin esperas' },
{ icon: 'check-rounded', text: 'Integra tu stack en menos de 10 minutos' },
{ icon: 'check-rounded', text: 'Soporte humano, siempre en español' },
];
private readonly model = signal<LoginModel>({
email: '',
password: '',
remember: true,
});
protected readonly f = form(this.model, (m) => {
email(m.email, { message: 'Introduce un email válido' });
});
protected submit(): void {
this.f().markAsTouched();
}
}

View File

@@ -107,6 +107,47 @@ html.dark-mode {
--pi-color-background-300: #4b505c;
--pi-color-background-400: #1c1f26;
--pi-color-background-500: #181b22;
// --- @pi/ui: accordion/tab ---------------------------------------------
// Ambos usan un escalón claro de la paleta de marca (`--pi-color-{color}-25`
// / `-50`) como fondo de toda la superficie, no como un simple acento — así
// que, a diferencia de un badge o un chip, no puede quedarse claro en
// oscuro. Los tokens `--pi-theme-accordion-surface-*` / `--pi-theme-tab-*`
// (con fallback al valor de marca de siempre) permiten oscurecer solo estas
// dos superficies sin tocar `--pi-color-*` y sin afectar a nada que sí
// funciona bien en oscuro (chips, badges...).
--pi-theme-accordion-surface-primary: var(--pi-color-grey-50);
--pi-theme-accordion-surface-primary-expanded: var(--pi-color-grey-100);
--pi-theme-accordion-surface-warn: var(--pi-color-grey-50);
--pi-theme-accordion-surface-warn-expanded: var(--pi-color-grey-100);
--pi-theme-accordion-surface-accent: var(--pi-color-grey-50);
--pi-theme-accordion-surface-accent-expanded: var(--pi-color-grey-100);
--pi-theme-tab-header-surface: var(--pi-color-grey-50);
--pi-theme-tab-header-surface-fill: var(--pi-color-grey-50);
--pi-theme-tab-active-surface: var(--pi-color-grey-100);
// --- @pi/ui: select-option ---------------------------------------------
// Mismo caso: cada fila de opción usa un escalón claro de marca como fondo
// de toda la fila (no un acento), en 3 estados (reposo/seleccionada/hover).
// Un solo token oscuro por estado basta — todos los colores de marca
// (primary/accent/highlighted/success/warn) comparten el mismo criterio de
// superficie en oscuro, aunque conserven su matiz en claro.
--pi-theme-select-option-surface-primary: var(--pi-color-grey-50);
--pi-theme-select-option-surface-primary-selected: var(--pi-color-grey-100);
--pi-theme-select-option-surface-primary-hover: var(--pi-color-grey-100);
--pi-theme-select-option-surface-accent: var(--pi-color-grey-50);
--pi-theme-select-option-surface-accent-selected: var(--pi-color-grey-100);
--pi-theme-select-option-surface-accent-hover: var(--pi-color-grey-100);
--pi-theme-select-option-surface-highlighted: var(--pi-color-grey-50);
--pi-theme-select-option-surface-highlighted-selected: var(--pi-color-grey-100);
--pi-theme-select-option-surface-highlighted-hover: var(--pi-color-grey-100);
--pi-theme-select-option-surface-success: var(--pi-color-grey-50);
--pi-theme-select-option-surface-success-selected: var(--pi-color-grey-100);
--pi-theme-select-option-surface-success-hover: var(--pi-color-grey-100);
--pi-theme-select-option-surface-warn: var(--pi-color-grey-50);
--pi-theme-select-option-surface-warn-selected: var(--pi-color-grey-100);
--pi-theme-select-option-surface-warn-hover: var(--pi-color-grey-100);
}
// El tema alternativo usa escalones claros de su marca como superficie de los