@mimi-ng/cli 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -1
- package/dist/init/css.js +1 -1
- package/dist/init/index.js +33 -0
- package/dist/init/paths.js +20 -0
- package/dist/registry.js +4 -1
- package/dist/registry.json +198 -1
- package/dist/templates/blocks/login-01/index.ts +1 -0
- package/dist/templates/blocks/login-01/login-01.html +174 -0
- package/dist/templates/blocks/login-01/login-01.ts +114 -0
- package/dist/templates/blocks/login-02/index.ts +1 -0
- package/dist/templates/blocks/login-02/login-02.html +186 -0
- package/dist/templates/blocks/login-02/login-02.ts +101 -0
- package/dist/templates/blocks/otp-01/index.ts +1 -0
- package/dist/templates/blocks/otp-01/otp-01.html +166 -0
- package/dist/templates/blocks/otp-01/otp-01.ts +221 -0
- package/dist/templates/blocks/pricing-01/index.ts +1 -0
- package/dist/templates/blocks/pricing-01/pricing-01.html +173 -0
- package/dist/templates/blocks/pricing-01/pricing-01.ts +178 -0
- package/dist/templates/blocks/settings-01/index.ts +1 -0
- package/dist/templates/blocks/settings-01/settings-01.html +424 -0
- package/dist/templates/blocks/settings-01/settings-01.ts +392 -0
- package/dist/templates/blocks/signup-01/index.ts +1 -0
- package/dist/templates/blocks/signup-01/signup-01.html +332 -0
- package/dist/templates/blocks/signup-01/signup-01.ts +186 -0
- package/dist/templates/blocks/table-01/index.ts +1 -0
- package/dist/templates/blocks/table-01/table-01.html +214 -0
- package/dist/templates/blocks/table-01/table-01.ts +266 -0
- package/dist/templates/components/checkbox/checkbox.ts +14 -5
- package/dist/templates/components/command/command-texts.ts +65 -0
- package/dist/templates/components/command/command.ts +553 -0
- package/dist/templates/components/command/index.ts +13 -0
- package/dist/templates/components/data-table/data-table-texts.ts +110 -0
- package/dist/templates/components/data-table/data-table.ts +1153 -0
- package/dist/templates/components/data-table/index.ts +18 -0
- package/dist/templates/components/sortable-list/index.ts +9 -0
- package/dist/templates/components/sortable-list/sortable-list-texts.ts +71 -0
- package/dist/templates/components/sortable-list/sortable-list.ts +510 -0
- package/dist/templates/components/tabs/tabs.ts +88 -23
- package/dist/templates/components/tree/index.ts +9 -0
- package/dist/templates/components/tree/tree-texts.ts +45 -0
- package/dist/templates/components/tree/tree.ts +730 -0
- package/dist/templates/theme/provider.ts +4 -0
- package/dist/templates/theme/types.ts +84 -0
- package/dist/ui/index.js +323 -13
- package/dist/ui/schema.json +50 -2
- package/dist/utils/pages.js +138 -0
- package/dist/utils/project.js +59 -0
- package/dist/utils/tailwind.js +84 -0
- package/dist/utils/write-files.js +63 -8
- package/package.json +1 -1
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeDetectionStrategy,
|
|
3
|
+
Component,
|
|
4
|
+
DOCUMENT,
|
|
5
|
+
DestroyRef,
|
|
6
|
+
afterNextRender,
|
|
7
|
+
booleanAttribute,
|
|
8
|
+
computed,
|
|
9
|
+
effect,
|
|
10
|
+
inject,
|
|
11
|
+
input,
|
|
12
|
+
numberAttribute,
|
|
13
|
+
output,
|
|
14
|
+
signal,
|
|
15
|
+
untracked,
|
|
16
|
+
viewChild,
|
|
17
|
+
} from '@angular/core';
|
|
18
|
+
import { MimiButton } from '@/components/ui/button';
|
|
19
|
+
import { MimiCard } from '@/components/ui/card';
|
|
20
|
+
import { MimiInputOtp } from '@/components/ui/input-otp';
|
|
21
|
+
import { cn } from '@/components/ui/utils/cn';
|
|
22
|
+
|
|
23
|
+
/** Lo que tu app sabe del último código enviado. */
|
|
24
|
+
export type Otp01Status = 'idle' | 'error' | 'success';
|
|
25
|
+
|
|
26
|
+
let nextId = 0;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* otp-01 (Block de Mimi): verificación con un código de 6 dígitos, con reenvío y cuenta
|
|
30
|
+
* regresiva. Diseño: docs/design/Block otp-01.dc.html.
|
|
31
|
+
*
|
|
32
|
+
* Es una página, que va después del registro o del inicio de sesión: `ng g mimi otp-01` la
|
|
33
|
+
* genera en tu carpeta de páginas (`aliases.pages` de mimi.json; por defecto, `src/app/pages/otp/`,
|
|
34
|
+
* u otro nombre con `--name`) junto con su archivo de rutas (`otp.routes.ts`, con la página en
|
|
35
|
+
* carga diferida), y te da la línea para `app.routes.ts`:
|
|
36
|
+
*
|
|
37
|
+
* ```ts
|
|
38
|
+
* { path: 'otp', loadChildren: () => import('./pages/otp/otp.routes').then((m) => m.OTP_ROUTES) },
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* `verify` sale con el código (al completar los dígitos o con el botón, una sola vez por
|
|
42
|
+
* código); tu app lo comprueba y responde con `status`: conéctalo aquí mismo o, si la usas dentro
|
|
43
|
+
* de otra página, por sus entradas y salidas:
|
|
44
|
+
*
|
|
45
|
+
* ```html
|
|
46
|
+
* <mimi-otp-01 destination="a••••@example.com" [status]="otp.status()" [loading]="otp.checking()"
|
|
47
|
+
* (verify)="otp.check($event)" (resend)="otp.send()" (changeDestination)="router.navigate(['/signup'])" />
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
@Component({
|
|
51
|
+
selector: 'mimi-otp-01',
|
|
52
|
+
imports: [MimiButton, MimiCard, MimiInputOtp],
|
|
53
|
+
templateUrl: './otp-01.html',
|
|
54
|
+
host: {
|
|
55
|
+
'[class]': 'classes()',
|
|
56
|
+
'[attr.data-state]': 'shownStatus()',
|
|
57
|
+
},
|
|
58
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
59
|
+
})
|
|
60
|
+
export class Otp01 {
|
|
61
|
+
/** El correo o teléfono, ya enmascarado, que se muestra. */
|
|
62
|
+
readonly destination = input('a••••@example.com');
|
|
63
|
+
readonly length = input(6, { transform: numberAttribute });
|
|
64
|
+
/** Segundos hasta poder pedir otro código. */
|
|
65
|
+
readonly resendSeconds = input(60, { transform: numberAttribute });
|
|
66
|
+
/** Mientras tu app comprueba el código: «Verifying…» y no reenvía. */
|
|
67
|
+
readonly loading = input(false, { transform: booleanAttribute });
|
|
68
|
+
/** El resultado del último código enviado por `verify`. */
|
|
69
|
+
readonly status = input<Otp01Status>('idle');
|
|
70
|
+
/** Lleva el foco al código al aparecer (no lo hace si el foco ya está en otro lugar). */
|
|
71
|
+
readonly autofocus = input(true, { transform: booleanAttribute });
|
|
72
|
+
readonly userClass = input('', { alias: 'class' });
|
|
73
|
+
|
|
74
|
+
/** El código completo, una sola vez por código. */
|
|
75
|
+
readonly verify = output<string>();
|
|
76
|
+
/** «Resend code». */
|
|
77
|
+
readonly resend = output<void>();
|
|
78
|
+
/** «Use another email». */
|
|
79
|
+
readonly changeDestination = output<void>();
|
|
80
|
+
|
|
81
|
+
protected readonly id = `mimi-otp-01-${nextId++}`;
|
|
82
|
+
private readonly document = inject(DOCUMENT);
|
|
83
|
+
private readonly otp = viewChild.required(MimiInputOtp);
|
|
84
|
+
|
|
85
|
+
protected readonly code = signal('');
|
|
86
|
+
/** El último código que salió por `verify` (para no repetirlo). */
|
|
87
|
+
private readonly sent = signal<string | null>(null);
|
|
88
|
+
/** Se pulsó «Verify» con el código incompleto. */
|
|
89
|
+
protected readonly incomplete = signal(false);
|
|
90
|
+
|
|
91
|
+
/** `status` vale para el código que se envió: al cambiarlo, vuelve a «idle». */
|
|
92
|
+
/**
|
|
93
|
+
* Enviado y sin respuesta todavía (M4): el `status` que hay es el del envío anterior. Se apaga
|
|
94
|
+
* cuando tu app cambia `status` o `loading` (si respondes con el mismo `status`, pon `loading`
|
|
95
|
+
* mientras compruebas).
|
|
96
|
+
*/
|
|
97
|
+
private readonly awaiting = signal(false);
|
|
98
|
+
protected readonly shownStatus = computed<Otp01Status>(() =>
|
|
99
|
+
this.awaiting() || this.loading() || this.code() !== this.sent() ? 'idle' : this.status(),
|
|
100
|
+
);
|
|
101
|
+
protected readonly message = computed(() => {
|
|
102
|
+
if (this.incomplete()) return `Enter the ${this.length()}-digit code.`;
|
|
103
|
+
switch (this.shownStatus()) {
|
|
104
|
+
case 'error':
|
|
105
|
+
return 'Incorrect code. Check it and try again.';
|
|
106
|
+
case 'success':
|
|
107
|
+
return 'Code verified. Signing you in…';
|
|
108
|
+
default:
|
|
109
|
+
return 'The code expires in 10 minutes.';
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
protected readonly messageClasses = computed(() =>
|
|
113
|
+
cn(
|
|
114
|
+
'flex min-h-5 items-center gap-1.5 text-[13px]',
|
|
115
|
+
this.incomplete() || this.shownStatus() === 'error'
|
|
116
|
+
? 'text-destructive-soft-foreground'
|
|
117
|
+
: this.shownStatus() === 'success'
|
|
118
|
+
? 'text-success-soft-foreground'
|
|
119
|
+
: 'text-muted-foreground',
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
/** Cuenta regresiva del reenvío. */
|
|
124
|
+
protected readonly left = signal(0);
|
|
125
|
+
protected readonly countdown = computed(() => {
|
|
126
|
+
const s = this.left();
|
|
127
|
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
|
128
|
+
});
|
|
129
|
+
/** El único anuncio de la cuenta: cuando se habilita (no cada segundo). */
|
|
130
|
+
protected readonly resendAnnouncement = signal('');
|
|
131
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
132
|
+
|
|
133
|
+
protected readonly classes = computed(() =>
|
|
134
|
+
cn(
|
|
135
|
+
'flex min-h-svh w-full items-center justify-center bg-background px-5 py-8 text-foreground sm:bg-muted sm:p-10',
|
|
136
|
+
this.userClass(),
|
|
137
|
+
),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
constructor() {
|
|
141
|
+
// Al completar los dígitos, verifica (una vez por código).
|
|
142
|
+
effect(() => {
|
|
143
|
+
const code = this.code();
|
|
144
|
+
if (code.length === this.length()) untracked(() => this.send(code));
|
|
145
|
+
});
|
|
146
|
+
// La respuesta de tu app (un status o un loading nuevos) termina la espera del envío.
|
|
147
|
+
effect(() => {
|
|
148
|
+
this.status();
|
|
149
|
+
this.loading();
|
|
150
|
+
untracked(() => this.awaiting.set(false));
|
|
151
|
+
});
|
|
152
|
+
inject(DestroyRef).onDestroy(() => this.stopTimer());
|
|
153
|
+
afterNextRender(() => {
|
|
154
|
+
this.startCountdown();
|
|
155
|
+
const active = this.document.activeElement;
|
|
156
|
+
if (this.autofocus() && (!active || active === this.document.body)) {
|
|
157
|
+
this.otp().controlElement.focus();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
protected onCodeChange(code: string): void {
|
|
163
|
+
// Al editar, el código enviado deja de valer: el mensaje vuelve a «idle» y, completo de nuevo
|
|
164
|
+
// (aunque sea el mismo), se vuelve a enviar (M4).
|
|
165
|
+
if (code !== this.code()) this.sent.set(null);
|
|
166
|
+
this.code.set(code);
|
|
167
|
+
this.incomplete.set(false);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
protected onVerify(): void {
|
|
171
|
+
if (this.loading()) return;
|
|
172
|
+
const code = this.code();
|
|
173
|
+
if (code.length < this.length()) {
|
|
174
|
+
this.incomplete.set(true);
|
|
175
|
+
this.otp().controlElement.focus();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
this.send(code);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
protected onResend(): void {
|
|
182
|
+
if (this.left() > 0) return;
|
|
183
|
+
this.resend.emit();
|
|
184
|
+
this.code.set('');
|
|
185
|
+
this.sent.set(null);
|
|
186
|
+
this.incomplete.set(false);
|
|
187
|
+
this.startCountdown();
|
|
188
|
+
this.otp().controlElement.focus();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Una vez por código (B.5-a): al completar y con el botón no se repite. Después de un error, el
|
|
193
|
+
* mismo código se puede reintentar (M4).
|
|
194
|
+
*/
|
|
195
|
+
private send(code: string): void {
|
|
196
|
+
if (this.loading()) return;
|
|
197
|
+
if (code === this.sent() && !(this.status() === 'error' && !this.awaiting())) return;
|
|
198
|
+
this.sent.set(code);
|
|
199
|
+
this.awaiting.set(true);
|
|
200
|
+
this.verify.emit(code);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private startCountdown(): void {
|
|
204
|
+
this.stopTimer();
|
|
205
|
+
this.resendAnnouncement.set('');
|
|
206
|
+
this.left.set(this.resendSeconds());
|
|
207
|
+
if (this.left() <= 0) return;
|
|
208
|
+
this.timer = setInterval(() => {
|
|
209
|
+
this.left.update((s) => Math.max(0, s - 1));
|
|
210
|
+
if (this.left() === 0) {
|
|
211
|
+
this.stopTimer();
|
|
212
|
+
this.resendAnnouncement.set('You can request another code now.');
|
|
213
|
+
}
|
|
214
|
+
}, 1000);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private stopTimer(): void {
|
|
218
|
+
if (this.timer !== null) clearInterval(this.timer);
|
|
219
|
+
this.timer = null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './pricing-01';
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
<div class="flex max-w-[620px] flex-col items-center gap-3 text-center">
|
|
2
|
+
<span mimiBadge variant="soft" tone="info" class="h-[26px] gap-1.5 px-3 text-[12.5px]">
|
|
3
|
+
<svg
|
|
4
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
5
|
+
width="13"
|
|
6
|
+
height="13"
|
|
7
|
+
viewBox="0 0 24 24"
|
|
8
|
+
fill="none"
|
|
9
|
+
stroke="currentColor"
|
|
10
|
+
stroke-width="2"
|
|
11
|
+
stroke-linecap="round"
|
|
12
|
+
stroke-linejoin="round"
|
|
13
|
+
aria-hidden="true"
|
|
14
|
+
>
|
|
15
|
+
<!-- Lucide sparkles (ISC) -->
|
|
16
|
+
<path
|
|
17
|
+
d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"
|
|
18
|
+
/>
|
|
19
|
+
<path d="M20 2v4" />
|
|
20
|
+
<path d="M22 4h-4" />
|
|
21
|
+
<circle cx="4" cy="20" r="2" />
|
|
22
|
+
</svg>
|
|
23
|
+
14-day free trial on any plan
|
|
24
|
+
</span>
|
|
25
|
+
<h1 class="text-[32px] leading-[1.08] font-extrabold tracking-[-0.03em] text-balance md:text-5xl">
|
|
26
|
+
Plans that grow with you
|
|
27
|
+
</h1>
|
|
28
|
+
<p class="text-base leading-[1.55] text-pretty text-muted-foreground">
|
|
29
|
+
Pay only for the people who use it. Change or cancel whenever you want.
|
|
30
|
+
</p>
|
|
31
|
+
|
|
32
|
+
<!-- El fondo que se desliza es de Toggle Group (no se copia el marcado del diseño). -->
|
|
33
|
+
<mimi-toggle-group
|
|
34
|
+
aria-label="Billing period"
|
|
35
|
+
class="mt-1.5 grid grid-cols-[repeat(2,140px)] rounded-full [&_button]:rounded-full [&_[data-slot=indicator]]:rounded-full"
|
|
36
|
+
[value]="period()"
|
|
37
|
+
(valueChange)="setPeriod($event)"
|
|
38
|
+
>
|
|
39
|
+
<button mimiToggleGroupItem="monthly" class="h-9">Monthly</button>
|
|
40
|
+
<button mimiToggleGroupItem="yearly" class="h-9 gap-1.5">
|
|
41
|
+
Yearly
|
|
42
|
+
<!-- El ahorro, calculado con los precios de los planes. -->
|
|
43
|
+
@if (yearlySaving(); as saving) {
|
|
44
|
+
<span mimiBadge variant="soft" tone="success" class="h-5 px-[7px] text-[11px]">{{
|
|
45
|
+
saving
|
|
46
|
+
}}</span>
|
|
47
|
+
}
|
|
48
|
+
</button>
|
|
49
|
+
</mimi-toggle-group>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<div class="grid w-full max-w-[1080px] grid-cols-1 items-stretch gap-[18px] md:grid-cols-3">
|
|
53
|
+
@for (card of cards(); track card.plan.id) {
|
|
54
|
+
<!-- El destacado: un borde de 1,5px con un degradado de color, quieto (B.7-a). -->
|
|
55
|
+
<section
|
|
56
|
+
[attr.aria-labelledby]="id + '-' + card.plan.id"
|
|
57
|
+
[attr.data-featured]="card.plan.featured ? '' : null"
|
|
58
|
+
[class]="
|
|
59
|
+
card.plan.featured
|
|
60
|
+
? 'relative mt-3 flex rounded-card bg-[image:conic-gradient(from_200deg,transparent_0deg_250deg,var(--mimi-info)_310deg,var(--mimi-success)_345deg,transparent_360deg),linear-gradient(var(--mimi-border),var(--mimi-border))] p-[1.5px] shadow-popover md:mt-0'
|
|
61
|
+
: 'relative flex rounded-card shadow-card'
|
|
62
|
+
"
|
|
63
|
+
>
|
|
64
|
+
@if (card.plan.featured) {
|
|
65
|
+
<span
|
|
66
|
+
mimiBadge
|
|
67
|
+
class="absolute -top-[13px] start-1/2 z-2 h-[26px] -translate-x-1/2 gap-[5px] px-3 text-xs whitespace-nowrap shadow-primary rtl:translate-x-1/2"
|
|
68
|
+
>
|
|
69
|
+
<svg
|
|
70
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
71
|
+
width="12"
|
|
72
|
+
height="12"
|
|
73
|
+
viewBox="0 0 24 24"
|
|
74
|
+
fill="none"
|
|
75
|
+
stroke="currentColor"
|
|
76
|
+
stroke-width="2"
|
|
77
|
+
stroke-linecap="round"
|
|
78
|
+
stroke-linejoin="round"
|
|
79
|
+
aria-hidden="true"
|
|
80
|
+
>
|
|
81
|
+
<!-- Lucide star (ISC) -->
|
|
82
|
+
<path
|
|
83
|
+
d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"
|
|
84
|
+
/>
|
|
85
|
+
</svg>
|
|
86
|
+
Most popular
|
|
87
|
+
</span>
|
|
88
|
+
}
|
|
89
|
+
<div
|
|
90
|
+
[class]="
|
|
91
|
+
card.plan.featured
|
|
92
|
+
? 'flex flex-1 flex-col gap-[18px] rounded-[calc(var(--mimi-radius-card)-1.5px)] bg-card px-6 py-[26px]'
|
|
93
|
+
: 'flex flex-1 flex-col gap-[18px] rounded-card border bg-card px-6 py-[26px]'
|
|
94
|
+
"
|
|
95
|
+
>
|
|
96
|
+
<div class="flex flex-col gap-1.5">
|
|
97
|
+
<h2 [id]="id + '-' + card.plan.id" class="text-base font-bold">{{ card.plan.name }}</h2>
|
|
98
|
+
<p class="text-[13.5px] leading-[1.45] text-muted-foreground">
|
|
99
|
+
{{ card.plan.description }}
|
|
100
|
+
</p>
|
|
101
|
+
</div>
|
|
102
|
+
<div class="flex items-baseline gap-1.5">
|
|
103
|
+
<span
|
|
104
|
+
data-slot="price"
|
|
105
|
+
class="text-[40px] font-extrabold tracking-[-0.03em] tabular-nums"
|
|
106
|
+
>{{ card.price }}</span
|
|
107
|
+
>
|
|
108
|
+
<span class="text-[13.5px] text-muted-foreground">{{ card.unit }}</span>
|
|
109
|
+
</div>
|
|
110
|
+
<span
|
|
111
|
+
data-slot="note"
|
|
112
|
+
class="-mt-3 min-h-[18px] text-[12.5px] text-success-soft-foreground"
|
|
113
|
+
>{{ card.note }}</span
|
|
114
|
+
>
|
|
115
|
+
<button
|
|
116
|
+
mimiBtn
|
|
117
|
+
type="button"
|
|
118
|
+
class="h-[calc(var(--mimi-control-height,2.5rem)+4px)] text-[14.5px] font-semibold"
|
|
119
|
+
[variant]="card.plan.featured ? 'solid' : 'outline'"
|
|
120
|
+
[tone]="card.plan.featured ? 'primary' : 'secondary'"
|
|
121
|
+
(click)="planSelected.emit({ plan: card.plan, period: period() })"
|
|
122
|
+
>
|
|
123
|
+
{{ card.plan.cta }}
|
|
124
|
+
</button>
|
|
125
|
+
<div class="h-px bg-border"></div>
|
|
126
|
+
<ul class="flex flex-col gap-2.5">
|
|
127
|
+
@for (feature of card.plan.features; track feature.label) {
|
|
128
|
+
<li class="flex items-start gap-2.5 text-sm leading-[1.4]">
|
|
129
|
+
<svg
|
|
130
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
131
|
+
width="16"
|
|
132
|
+
height="16"
|
|
133
|
+
viewBox="0 0 24 24"
|
|
134
|
+
fill="none"
|
|
135
|
+
stroke="currentColor"
|
|
136
|
+
stroke-width="2"
|
|
137
|
+
stroke-linecap="round"
|
|
138
|
+
stroke-linejoin="round"
|
|
139
|
+
aria-hidden="true"
|
|
140
|
+
[class]="
|
|
141
|
+
feature.included
|
|
142
|
+
? 'mt-px shrink-0 text-success-soft-foreground'
|
|
143
|
+
: 'mt-px shrink-0 text-muted-foreground'
|
|
144
|
+
"
|
|
145
|
+
>
|
|
146
|
+
@if (feature.included) {
|
|
147
|
+
<!-- Lucide check (ISC) -->
|
|
148
|
+
<path d="M20 6 9 17l-5-5" />
|
|
149
|
+
} @else {
|
|
150
|
+
<!-- Lucide minus (ISC) -->
|
|
151
|
+
<path d="M5 12h14" />
|
|
152
|
+
}
|
|
153
|
+
</svg>
|
|
154
|
+
<span [class]="feature.included ? 'text-foreground' : 'text-muted-foreground'">
|
|
155
|
+
<span class="sr-only">{{ feature.included ? 'Included: ' : 'Not included: ' }}</span
|
|
156
|
+
>{{ feature.label }}
|
|
157
|
+
</span>
|
|
158
|
+
</li>
|
|
159
|
+
}
|
|
160
|
+
</ul>
|
|
161
|
+
</div>
|
|
162
|
+
</section>
|
|
163
|
+
}
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
<p class="text-center text-[13.5px] text-muted-foreground">
|
|
167
|
+
Prices in {{ currency() }}, taxes not included ·
|
|
168
|
+
<a
|
|
169
|
+
[href]="faqHref()"
|
|
170
|
+
class="rounded-sm font-semibold text-foreground underline underline-offset-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
|
171
|
+
>FAQ</a
|
|
172
|
+
>
|
|
173
|
+
</p>
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeDetectionStrategy,
|
|
3
|
+
Component,
|
|
4
|
+
LOCALE_ID,
|
|
5
|
+
computed,
|
|
6
|
+
inject,
|
|
7
|
+
input,
|
|
8
|
+
model,
|
|
9
|
+
output,
|
|
10
|
+
} from '@angular/core';
|
|
11
|
+
import { MimiBadge } from '@/components/ui/badge';
|
|
12
|
+
import { MimiButton } from '@/components/ui/button';
|
|
13
|
+
import { MimiToggleGroupImports } from '@/components/ui/toggle-group';
|
|
14
|
+
import { cn } from '@/components/ui/utils/cn';
|
|
15
|
+
|
|
16
|
+
export type Pricing01Period = 'monthly' | 'yearly';
|
|
17
|
+
|
|
18
|
+
export interface Pricing01Plan {
|
|
19
|
+
/** Identificador estable (sale en `planSelected`). */
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
description: string;
|
|
23
|
+
/** Precio por persona y mes, pagando mes a mes. `null`: «Custom» (sin precio). */
|
|
24
|
+
monthly: number | null;
|
|
25
|
+
/** Precio por persona y mes, pagando el año. `null`: «Custom». */
|
|
26
|
+
yearly: number | null;
|
|
27
|
+
/** El plan destacado («Most popular», borde de color). */
|
|
28
|
+
featured?: boolean;
|
|
29
|
+
/** El texto del botón. */
|
|
30
|
+
cta: string;
|
|
31
|
+
features: { label: string; included: boolean }[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Lo que sale al elegir un plan. */
|
|
35
|
+
export interface Pricing01Selection {
|
|
36
|
+
plan: Pricing01Plan;
|
|
37
|
+
period: Pricing01Period;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Los tres planes del diseño, para que la pantalla funcione sin datos. */
|
|
41
|
+
export const PRICING_01_PLANS: Pricing01Plan[] = [
|
|
42
|
+
{
|
|
43
|
+
id: 'starter',
|
|
44
|
+
name: 'Starter',
|
|
45
|
+
description: 'To start organizing your sales.',
|
|
46
|
+
monthly: 12,
|
|
47
|
+
yearly: 10,
|
|
48
|
+
cta: 'Start for free',
|
|
49
|
+
features: [
|
|
50
|
+
{ label: 'Up to 5 people', included: true },
|
|
51
|
+
{ label: 'Unlimited clients', included: true },
|
|
52
|
+
{ label: 'Basic reports', included: true },
|
|
53
|
+
{ label: 'Integrations', included: false },
|
|
54
|
+
{ label: 'Priority support', included: false },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 'team',
|
|
59
|
+
name: 'Team',
|
|
60
|
+
description: 'For teams that sell every day.',
|
|
61
|
+
monthly: 24,
|
|
62
|
+
yearly: 20,
|
|
63
|
+
featured: true,
|
|
64
|
+
cta: 'Try it for 14 days',
|
|
65
|
+
features: [
|
|
66
|
+
{ label: 'Up to 25 people', included: true },
|
|
67
|
+
{ label: 'Unlimited clients', included: true },
|
|
68
|
+
{ label: 'Advanced reports', included: true },
|
|
69
|
+
{ label: 'CRM and ERP integrations', included: true },
|
|
70
|
+
{ label: 'Priority support', included: false },
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: 'enterprise',
|
|
75
|
+
name: 'Enterprise',
|
|
76
|
+
description: 'Security and control for large teams.',
|
|
77
|
+
monthly: null,
|
|
78
|
+
yearly: null,
|
|
79
|
+
cta: 'Talk to sales',
|
|
80
|
+
features: [
|
|
81
|
+
{ label: 'Unlimited people', included: true },
|
|
82
|
+
{ label: 'SSO and audit log', included: true },
|
|
83
|
+
{ label: 'Custom reports', included: true },
|
|
84
|
+
{ label: 'Custom integrations', included: true },
|
|
85
|
+
{ label: 'Account manager', included: true },
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
let nextId = 0;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* pricing-01 (Block de Mimi): tres planes con un interruptor mensual/anual; el destacado con la
|
|
94
|
+
* insignia «Most popular» y un borde de color. Diseño: docs/design/Block pricing-01.dc.html.
|
|
95
|
+
*
|
|
96
|
+
* Los planes son datos (`plans`, con los del diseño por defecto) y los precios se escriben con
|
|
97
|
+
* `Intl.NumberFormat` (`currency` y `locale`). El borde del destacado es estático (B.7-a): el
|
|
98
|
+
* Border Beam que gira es de Mimi Effects (E.7).
|
|
99
|
+
*
|
|
100
|
+
* ```html
|
|
101
|
+
* <mimi-pricing-01 [plans]="plans" currency="EUR" [(period)]="period" (planSelected)="checkout($event)" />
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
@Component({
|
|
105
|
+
selector: 'mimi-pricing-01',
|
|
106
|
+
imports: [MimiBadge, MimiButton, MimiToggleGroupImports],
|
|
107
|
+
templateUrl: './pricing-01.html',
|
|
108
|
+
host: {
|
|
109
|
+
'[class]': 'classes()',
|
|
110
|
+
'[attr.data-period]': 'period()',
|
|
111
|
+
},
|
|
112
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
113
|
+
})
|
|
114
|
+
export class Pricing01 {
|
|
115
|
+
readonly plans = input<readonly Pricing01Plan[]>(PRICING_01_PLANS);
|
|
116
|
+
/** El periodo. Admite `[(period)]`. */
|
|
117
|
+
readonly period = model<Pricing01Period>('monthly');
|
|
118
|
+
/** Moneda de los precios (ISO 4217). */
|
|
119
|
+
readonly currency = input('USD');
|
|
120
|
+
/** Idioma de los números. Por defecto, el `LOCALE_ID` de la app. */
|
|
121
|
+
readonly locale = input<string | undefined>(undefined);
|
|
122
|
+
readonly faqHref = input('#');
|
|
123
|
+
readonly userClass = input('', { alias: 'class' });
|
|
124
|
+
|
|
125
|
+
/** El botón de un plan. */
|
|
126
|
+
readonly planSelected = output<Pricing01Selection>();
|
|
127
|
+
|
|
128
|
+
protected readonly id = `mimi-pricing-01-${nextId++}`;
|
|
129
|
+
private readonly appLocale = inject(LOCALE_ID);
|
|
130
|
+
|
|
131
|
+
private readonly money = computed(
|
|
132
|
+
() =>
|
|
133
|
+
new Intl.NumberFormat(this.locale() ?? this.appLocale, {
|
|
134
|
+
style: 'currency',
|
|
135
|
+
currency: this.currency(),
|
|
136
|
+
maximumFractionDigits: 2,
|
|
137
|
+
minimumFractionDigits: 0,
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
protected readonly cards = computed(() => {
|
|
142
|
+
const yearly = this.period() === 'yearly';
|
|
143
|
+
const money = this.money();
|
|
144
|
+
return this.plans().map((plan) => {
|
|
145
|
+
const amount = yearly ? plan.yearly : plan.monthly;
|
|
146
|
+
return {
|
|
147
|
+
plan,
|
|
148
|
+
price: amount === null ? 'Custom' : money.format(amount),
|
|
149
|
+
unit: amount === null ? '' : 'per seat / month',
|
|
150
|
+
note:
|
|
151
|
+
amount !== null && yearly ? `Billed yearly: ${money.format(amount * 12)} per seat` : '',
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* El ahorro del pago anual, calculado con los precios (no escrito a mano): el mayor de los
|
|
158
|
+
* planes con precio, redondeado («−17%» con 12/10 y 24/20). Sin ahorro, sin insignia.
|
|
159
|
+
*/
|
|
160
|
+
protected readonly yearlySaving = computed(() => {
|
|
161
|
+
const savings = this.plans().flatMap(({ monthly, yearly }) =>
|
|
162
|
+
monthly && yearly !== null ? [Math.round((1 - yearly / monthly) * 100)] : [],
|
|
163
|
+
);
|
|
164
|
+
const best = Math.max(0, ...savings);
|
|
165
|
+
return best > 0 ? `−${best}%` : null;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
protected readonly classes = computed(() =>
|
|
169
|
+
cn(
|
|
170
|
+
'flex w-full flex-col items-center gap-7 bg-background px-[18px] pt-9 pb-8 text-foreground md:px-10 md:py-14',
|
|
171
|
+
this.userClass(),
|
|
172
|
+
),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
protected setPeriod(value: unknown): void {
|
|
176
|
+
if (value === 'monthly' || value === 'yearly') this.period.set(value);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './settings-01';
|