@mimi-ng/cli 0.3.0 → 0.4.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 +20 -1
- package/dist/init/css.js +1 -1
- package/dist/init/index.js +25 -0
- package/dist/init/paths.js +20 -0
- package/dist/registry.json +112 -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 +105 -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 +92 -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 +212 -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 +382 -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 +176 -0
- package/dist/templates/components/tabs/tabs.ts +88 -23
- package/dist/ui/index.js +128 -10
- package/dist/ui/schema.json +26 -2
- package/dist/utils/project.js +35 -0
- package/dist/utils/tailwind.js +80 -0
- package/dist/utils/write-files.js +37 -7
- package/package.json +1 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
<!-- Imagen: arriba como banner en móvil, la mitad derecha en escritorio. -->
|
|
2
|
+
<div
|
|
3
|
+
class="relative h-[200px] shrink-0 overflow-hidden rounded-b-[24px] bg-muted md:order-2 md:h-auto md:w-1/2 md:rounded-none"
|
|
4
|
+
>
|
|
5
|
+
@if (imageSrc(); as src) {
|
|
6
|
+
<img [src]="src" [alt]="imageAlt()" class="absolute inset-0 size-full object-cover" />
|
|
7
|
+
} @else {
|
|
8
|
+
<!-- PROVISIONAL (B.3-a): degradado neutro hasta tener una imagen en el diseño. Pon la tuya con imageSrc. -->
|
|
9
|
+
<div
|
|
10
|
+
aria-hidden="true"
|
|
11
|
+
data-slot="placeholder"
|
|
12
|
+
class="absolute inset-0 bg-linear-to-br from-secondary via-muted to-accent"
|
|
13
|
+
></div>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
<figure
|
|
17
|
+
class="pointer-events-none absolute inset-x-7 bottom-7 hidden flex-col gap-3 rounded-card border border-glass-border bg-glass p-5 backdrop-blur-glass md:flex [@media(prefers-reduced-transparency:reduce)]:bg-card [@media(prefers-reduced-transparency:reduce)]:backdrop-blur-none"
|
|
18
|
+
>
|
|
19
|
+
<!-- Texto de relleno: pon aquí la cita de un cliente real (con su permiso), o quita el figure. -->
|
|
20
|
+
<blockquote class="text-[17px] leading-[1.45] font-semibold text-pretty">
|
|
21
|
+
Add a short quote from a customer here.
|
|
22
|
+
</blockquote>
|
|
23
|
+
<figcaption class="flex items-center gap-2.5">
|
|
24
|
+
<mimi-avatar size="sm">
|
|
25
|
+
<mimi-avatar-fallback class="bg-info-soft font-bold text-info-soft-foreground"
|
|
26
|
+
>CN</mimi-avatar-fallback
|
|
27
|
+
>
|
|
28
|
+
</mimi-avatar>
|
|
29
|
+
<span class="flex flex-col leading-[1.3]">
|
|
30
|
+
<span class="text-[13.5px] font-bold">Customer name</span>
|
|
31
|
+
<span class="text-[12.5px] text-muted-foreground">Role · Company</span>
|
|
32
|
+
</span>
|
|
33
|
+
</figcaption>
|
|
34
|
+
</figure>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<div class="flex min-w-0 flex-1 flex-col px-[22px] pt-5 pb-6 md:px-10 md:py-8">
|
|
38
|
+
<div class="flex items-center gap-2.5">
|
|
39
|
+
<span
|
|
40
|
+
aria-hidden="true"
|
|
41
|
+
class="inline-flex size-8 items-center justify-center rounded-[10px] bg-primary text-lg leading-none font-extrabold text-primary-foreground"
|
|
42
|
+
>m</span
|
|
43
|
+
>
|
|
44
|
+
<span class="text-base font-bold">Mimi Sales</span>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<div class="flex flex-1 items-center justify-center py-6 md:py-0">
|
|
48
|
+
<form
|
|
49
|
+
novalidate
|
|
50
|
+
class="flex w-full max-w-[360px] flex-col gap-[18px]"
|
|
51
|
+
[attr.aria-labelledby]="id + '-title'"
|
|
52
|
+
(submit)="onSubmit($event)"
|
|
53
|
+
>
|
|
54
|
+
<div class="flex flex-col gap-1.5">
|
|
55
|
+
<h1
|
|
56
|
+
[id]="id + '-title'"
|
|
57
|
+
class="text-[26px] font-extrabold tracking-[-0.025em] md:text-[32px]"
|
|
58
|
+
>
|
|
59
|
+
Welcome back
|
|
60
|
+
</h1>
|
|
61
|
+
<p class="text-[14.5px] text-muted-foreground">Sign in with your work email.</p>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<mimi-form-field label="Email">
|
|
65
|
+
<input
|
|
66
|
+
mimiInput
|
|
67
|
+
type="email"
|
|
68
|
+
autocomplete="email"
|
|
69
|
+
placeholder="name@example.com"
|
|
70
|
+
[formField]="login.email"
|
|
71
|
+
/>
|
|
72
|
+
</mimi-form-field>
|
|
73
|
+
|
|
74
|
+
<mimi-form-field class="relative">
|
|
75
|
+
<label [attr.for]="id + '-password'" class="text-sm font-medium">Password</label>
|
|
76
|
+
<a
|
|
77
|
+
[href]="forgotHref()"
|
|
78
|
+
class="absolute top-0 right-0 rounded-sm text-[13px] text-muted-foreground underline underline-offset-3 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
|
79
|
+
>Forgot it?</a
|
|
80
|
+
>
|
|
81
|
+
<input
|
|
82
|
+
mimiInput
|
|
83
|
+
type="password"
|
|
84
|
+
autocomplete="current-password"
|
|
85
|
+
placeholder="••••••••"
|
|
86
|
+
[id]="id + '-password'"
|
|
87
|
+
[formField]="login.password"
|
|
88
|
+
/>
|
|
89
|
+
</mimi-form-field>
|
|
90
|
+
|
|
91
|
+
<!-- Siempre en la página: así el lector anuncia el error cuando aparece. -->
|
|
92
|
+
<div role="alert" class="empty:hidden">
|
|
93
|
+
@if (error(); as message) {
|
|
94
|
+
<p
|
|
95
|
+
class="flex items-start gap-2 rounded-[var(--mimi-radius)] bg-destructive-soft-bg px-3 py-2.5 text-sm text-destructive-soft-foreground"
|
|
96
|
+
>
|
|
97
|
+
<svg
|
|
98
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
99
|
+
width="16"
|
|
100
|
+
height="16"
|
|
101
|
+
viewBox="0 0 24 24"
|
|
102
|
+
fill="none"
|
|
103
|
+
stroke="currentColor"
|
|
104
|
+
stroke-width="2"
|
|
105
|
+
stroke-linecap="round"
|
|
106
|
+
stroke-linejoin="round"
|
|
107
|
+
aria-hidden="true"
|
|
108
|
+
class="mt-0.5 shrink-0"
|
|
109
|
+
>
|
|
110
|
+
<!-- Lucide circle-alert (ISC) -->
|
|
111
|
+
<circle cx="12" cy="12" r="10" />
|
|
112
|
+
<line x1="12" x2="12" y1="8" y2="12" />
|
|
113
|
+
<line x1="12" x2="12.01" y1="16" y2="16" />
|
|
114
|
+
</svg>
|
|
115
|
+
{{ message }}
|
|
116
|
+
</p>
|
|
117
|
+
}
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<button
|
|
121
|
+
mimiBtn
|
|
122
|
+
type="submit"
|
|
123
|
+
class="h-[calc(var(--mimi-control-height,2.5rem)+4px)] text-[15px] font-semibold"
|
|
124
|
+
[attr.aria-disabled]="loading() ? 'true' : null"
|
|
125
|
+
[attr.aria-busy]="loading() ? 'true' : null"
|
|
126
|
+
>
|
|
127
|
+
@if (loading()) {
|
|
128
|
+
<svg
|
|
129
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
130
|
+
width="16"
|
|
131
|
+
height="16"
|
|
132
|
+
viewBox="0 0 24 24"
|
|
133
|
+
fill="none"
|
|
134
|
+
stroke="currentColor"
|
|
135
|
+
stroke-width="2"
|
|
136
|
+
stroke-linecap="round"
|
|
137
|
+
stroke-linejoin="round"
|
|
138
|
+
aria-hidden="true"
|
|
139
|
+
class="motion-safe:animate-[mimi-spin_0.8s_linear_infinite]"
|
|
140
|
+
>
|
|
141
|
+
<!-- Lucide loader-circle (ISC) -->
|
|
142
|
+
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
143
|
+
</svg>
|
|
144
|
+
Signing in…
|
|
145
|
+
} @else {
|
|
146
|
+
Sign in
|
|
147
|
+
}
|
|
148
|
+
</button>
|
|
149
|
+
|
|
150
|
+
<button mimiBtn type="button" variant="outline" tone="secondary" (click)="sso.emit()">
|
|
151
|
+
<svg
|
|
152
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
153
|
+
width="16"
|
|
154
|
+
height="16"
|
|
155
|
+
viewBox="0 0 24 24"
|
|
156
|
+
fill="none"
|
|
157
|
+
stroke="currentColor"
|
|
158
|
+
stroke-width="2"
|
|
159
|
+
stroke-linecap="round"
|
|
160
|
+
stroke-linejoin="round"
|
|
161
|
+
aria-hidden="true"
|
|
162
|
+
>
|
|
163
|
+
<!-- Lucide key-round (ISC) -->
|
|
164
|
+
<path
|
|
165
|
+
d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"
|
|
166
|
+
/>
|
|
167
|
+
<circle cx="16.5" cy="7.5" r=".5" fill="currentColor" />
|
|
168
|
+
</svg>
|
|
169
|
+
Sign in with SSO
|
|
170
|
+
</button>
|
|
171
|
+
|
|
172
|
+
<p class="text-sm text-muted-foreground">
|
|
173
|
+
First time here?
|
|
174
|
+
<a
|
|
175
|
+
[href]="signupHref()"
|
|
176
|
+
class="rounded-sm font-semibold text-foreground underline underline-offset-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
|
177
|
+
>Create an account</a
|
|
178
|
+
>
|
|
179
|
+
</p>
|
|
180
|
+
</form>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<p class="text-xs text-muted-foreground">
|
|
184
|
+
By continuing you agree to the Terms and the Privacy Notice.
|
|
185
|
+
</p>
|
|
186
|
+
</div>
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeDetectionStrategy,
|
|
3
|
+
Component,
|
|
4
|
+
booleanAttribute,
|
|
5
|
+
computed,
|
|
6
|
+
input,
|
|
7
|
+
output,
|
|
8
|
+
signal,
|
|
9
|
+
} from '@angular/core';
|
|
10
|
+
import { FormField, email, form, required } from '@angular/forms/signals';
|
|
11
|
+
import { MimiAvatar, MimiAvatarFallback } from '@/components/ui/avatar';
|
|
12
|
+
import { MimiButton } from '@/components/ui/button';
|
|
13
|
+
import { MimiFormField } from '@/components/ui/form-field';
|
|
14
|
+
import { MimiInput } from '@/components/ui/input';
|
|
15
|
+
import { cn } from '@/components/ui/utils/cn';
|
|
16
|
+
|
|
17
|
+
/** Lo que sale al enviar un formulario válido. */
|
|
18
|
+
export interface Login02Value {
|
|
19
|
+
email: string;
|
|
20
|
+
password: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let nextId = 0;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* login-02 (Block de Mimi): inicio de sesión dividido en dos, con el formulario a un lado y una
|
|
27
|
+
* imagen con un testimonio en vidrio al otro. Diseño: docs/design/Block login-02.dc.html.
|
|
28
|
+
*
|
|
29
|
+
* Es una pantalla: ponla en tus rutas (`{ path: 'login', component: Login02 }`). Da tu imagen con
|
|
30
|
+
* `imageSrc` (y `imageAlt` si no es decorativa). El testimonio está escrito en la plantilla:
|
|
31
|
+
* cámbialo ahí.
|
|
32
|
+
*
|
|
33
|
+
* ```html
|
|
34
|
+
* <mimi-login-02 imageSrc="/img/login.jpg" [loading]="auth.loading()" (submitted)="auth.signIn($event)" />
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
@Component({
|
|
38
|
+
selector: 'mimi-login-02',
|
|
39
|
+
imports: [FormField, MimiAvatar, MimiAvatarFallback, MimiButton, MimiFormField, MimiInput],
|
|
40
|
+
templateUrl: './login-02.html',
|
|
41
|
+
host: {
|
|
42
|
+
'[class]': 'classes()',
|
|
43
|
+
'[attr.data-state]': 'loading() ? "loading" : null',
|
|
44
|
+
},
|
|
45
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
46
|
+
})
|
|
47
|
+
export class Login02 {
|
|
48
|
+
/** Mientras tu app comprueba las credenciales: el botón dice «Signing in…» y no reenvía. */
|
|
49
|
+
readonly loading = input(false, { transform: booleanAttribute });
|
|
50
|
+
/** Error de tu servidor, encima del botón. `null`: ninguno. */
|
|
51
|
+
readonly error = input<string | null>(null);
|
|
52
|
+
/** Tu imagen. Sin ella, un degradado neutro (provisional, B.3-a). */
|
|
53
|
+
readonly imageSrc = input<string | null>(null);
|
|
54
|
+
/** Texto alternativo. Vacío por defecto: la imagen es decorativa. */
|
|
55
|
+
readonly imageAlt = input('');
|
|
56
|
+
readonly forgotHref = input('#');
|
|
57
|
+
readonly signupHref = input('#');
|
|
58
|
+
readonly userClass = input('', { alias: 'class' });
|
|
59
|
+
|
|
60
|
+
/** Formulario válido enviado. */
|
|
61
|
+
readonly submitted = output<Login02Value>();
|
|
62
|
+
/** Se pulsó «Sign in with SSO». */
|
|
63
|
+
readonly sso = output<void>();
|
|
64
|
+
|
|
65
|
+
protected readonly id = `mimi-login-02-${nextId++}`;
|
|
66
|
+
|
|
67
|
+
protected readonly model = signal<Login02Value>({ email: '', password: '' });
|
|
68
|
+
protected readonly login = form(this.model, (path) => {
|
|
69
|
+
required(path.email);
|
|
70
|
+
email(path.email);
|
|
71
|
+
required(path.password);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
protected readonly classes = computed(() =>
|
|
75
|
+
cn(
|
|
76
|
+
'flex min-h-svh w-full flex-col bg-background text-foreground md:flex-row',
|
|
77
|
+
this.userClass(),
|
|
78
|
+
),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
protected onSubmit(event: Event): void {
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
if (this.loading()) return;
|
|
84
|
+
const state = this.login();
|
|
85
|
+
state.markAsTouched();
|
|
86
|
+
if (state.invalid()) {
|
|
87
|
+
state.errorSummary()[0]?.fieldTree().focusBoundControl();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
this.submitted.emit({ ...this.model() });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './otp-01';
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
<mimi-card
|
|
2
|
+
class="w-full items-center gap-[22px] border-0 bg-transparent p-0 text-center shadow-none sm:max-w-[440px] sm:border sm:bg-card sm:px-8 sm:py-9 sm:shadow-card"
|
|
3
|
+
>
|
|
4
|
+
<span
|
|
5
|
+
aria-hidden="true"
|
|
6
|
+
class="inline-flex size-14 items-center justify-center rounded-full bg-info-soft text-info-soft-foreground shadow-[0_0_0_8px_color-mix(in_oklab,var(--mimi-info)_8%,transparent)]"
|
|
7
|
+
>
|
|
8
|
+
<svg
|
|
9
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
10
|
+
width="26"
|
|
11
|
+
height="26"
|
|
12
|
+
viewBox="0 0 24 24"
|
|
13
|
+
fill="none"
|
|
14
|
+
stroke="currentColor"
|
|
15
|
+
stroke-width="2"
|
|
16
|
+
stroke-linecap="round"
|
|
17
|
+
stroke-linejoin="round"
|
|
18
|
+
>
|
|
19
|
+
<!-- Lucide mail-check (ISC) -->
|
|
20
|
+
<path d="M22 13V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v12c0 1.1.9 2 2 2h8" />
|
|
21
|
+
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
|
22
|
+
<path d="m16 19 2 2 4-4" />
|
|
23
|
+
</svg>
|
|
24
|
+
</span>
|
|
25
|
+
|
|
26
|
+
<div class="flex flex-col gap-1.5">
|
|
27
|
+
<h1 [id]="id + '-title'" class="text-2xl font-extrabold tracking-[-0.02em]">
|
|
28
|
+
Check your email
|
|
29
|
+
</h1>
|
|
30
|
+
<p class="text-sm leading-normal text-muted-foreground">
|
|
31
|
+
We sent a {{ length() }}-digit code to
|
|
32
|
+
<b class="font-bold text-foreground">{{ destination() }}</b
|
|
33
|
+
>.
|
|
34
|
+
</p>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<div class="flex flex-col items-center gap-2.5">
|
|
38
|
+
<!-- Casillas más pequeñas en móvil, con los tokens de Input OTP. -->
|
|
39
|
+
<mimi-input-otp
|
|
40
|
+
class="[--mimi-otp-font-size:20px] [--mimi-otp-gap:6px] [--mimi-otp-height:50px] [--mimi-otp-width:42px] sm:[--mimi-otp-font-size:22px] sm:[--mimi-otp-gap:8px] sm:[--mimi-otp-height:56px] sm:[--mimi-otp-width:48px]"
|
|
41
|
+
aria-label="Verification code"
|
|
42
|
+
[aria-describedby]="id + '-message'"
|
|
43
|
+
[length]="length()"
|
|
44
|
+
[separatorAfter]="length() === 6 ? 3 : undefined"
|
|
45
|
+
[invalid]="incomplete() || shownStatus() === 'error'"
|
|
46
|
+
[touched]="incomplete() || shownStatus() === 'error'"
|
|
47
|
+
[success]="shownStatus() === 'success'"
|
|
48
|
+
[value]="code()"
|
|
49
|
+
(valueChange)="onCodeChange($event)"
|
|
50
|
+
/>
|
|
51
|
+
<!-- El mensaje del código (Input OTP no trae uno propio). -->
|
|
52
|
+
<span role="status" [id]="id + '-message'" [class]="messageClasses()">
|
|
53
|
+
@if (incomplete() || shownStatus() === 'error') {
|
|
54
|
+
<svg
|
|
55
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
56
|
+
width="14"
|
|
57
|
+
height="14"
|
|
58
|
+
viewBox="0 0 24 24"
|
|
59
|
+
fill="none"
|
|
60
|
+
stroke="currentColor"
|
|
61
|
+
stroke-width="2"
|
|
62
|
+
stroke-linecap="round"
|
|
63
|
+
stroke-linejoin="round"
|
|
64
|
+
aria-hidden="true"
|
|
65
|
+
>
|
|
66
|
+
<!-- Lucide circle-alert (ISC) -->
|
|
67
|
+
<circle cx="12" cy="12" r="10" />
|
|
68
|
+
<line x1="12" x2="12" y1="8" y2="12" />
|
|
69
|
+
<line x1="12" x2="12.01" y1="16" y2="16" />
|
|
70
|
+
</svg>
|
|
71
|
+
} @else if (shownStatus() === 'success') {
|
|
72
|
+
<svg
|
|
73
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
74
|
+
width="14"
|
|
75
|
+
height="14"
|
|
76
|
+
viewBox="0 0 24 24"
|
|
77
|
+
fill="none"
|
|
78
|
+
stroke="currentColor"
|
|
79
|
+
stroke-width="2"
|
|
80
|
+
stroke-linecap="round"
|
|
81
|
+
stroke-linejoin="round"
|
|
82
|
+
aria-hidden="true"
|
|
83
|
+
>
|
|
84
|
+
<!-- Lucide circle-check (ISC) -->
|
|
85
|
+
<circle cx="12" cy="12" r="10" />
|
|
86
|
+
<path d="m9 12 2 2 4-4" />
|
|
87
|
+
</svg>
|
|
88
|
+
}
|
|
89
|
+
{{ message() }}
|
|
90
|
+
</span>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<button
|
|
94
|
+
mimiBtn
|
|
95
|
+
type="button"
|
|
96
|
+
class="h-[calc(var(--mimi-control-height,2.5rem)+4px)] w-full text-[15px] font-semibold"
|
|
97
|
+
[attr.aria-disabled]="loading() ? 'true' : null"
|
|
98
|
+
[attr.aria-busy]="loading() ? 'true' : null"
|
|
99
|
+
(click)="onVerify()"
|
|
100
|
+
>
|
|
101
|
+
@if (loading()) {
|
|
102
|
+
<svg
|
|
103
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
104
|
+
width="16"
|
|
105
|
+
height="16"
|
|
106
|
+
viewBox="0 0 24 24"
|
|
107
|
+
fill="none"
|
|
108
|
+
stroke="currentColor"
|
|
109
|
+
stroke-width="2"
|
|
110
|
+
stroke-linecap="round"
|
|
111
|
+
stroke-linejoin="round"
|
|
112
|
+
aria-hidden="true"
|
|
113
|
+
class="motion-safe:animate-[mimi-spin_0.8s_linear_infinite]"
|
|
114
|
+
>
|
|
115
|
+
<!-- Lucide loader-circle (ISC) -->
|
|
116
|
+
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
117
|
+
</svg>
|
|
118
|
+
Verifying…
|
|
119
|
+
} @else {
|
|
120
|
+
Verify
|
|
121
|
+
}
|
|
122
|
+
</button>
|
|
123
|
+
|
|
124
|
+
<div class="flex flex-col gap-2 text-[13.5px] text-muted-foreground">
|
|
125
|
+
<span>
|
|
126
|
+
Didn't get it?
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
class="rounded-sm font-semibold text-foreground underline underline-offset-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:cursor-not-allowed disabled:text-muted-foreground"
|
|
130
|
+
[disabled]="left() > 0"
|
|
131
|
+
(click)="onResend()"
|
|
132
|
+
>
|
|
133
|
+
@if (left() > 0) {
|
|
134
|
+
Resend in {{ countdown() }}
|
|
135
|
+
} @else {
|
|
136
|
+
Resend code
|
|
137
|
+
}
|
|
138
|
+
</button>
|
|
139
|
+
</span>
|
|
140
|
+
<!-- Anuncia solo cuando se puede reenviar (no cada segundo). -->
|
|
141
|
+
<span class="sr-only" aria-live="polite">{{ resendAnnouncement() }}</span>
|
|
142
|
+
<button
|
|
143
|
+
type="button"
|
|
144
|
+
class="inline-flex items-center justify-center gap-1.5 rounded-sm text-muted-foreground hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
|
145
|
+
(click)="changeDestination.emit()"
|
|
146
|
+
>
|
|
147
|
+
<svg
|
|
148
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
149
|
+
width="14"
|
|
150
|
+
height="14"
|
|
151
|
+
viewBox="0 0 24 24"
|
|
152
|
+
fill="none"
|
|
153
|
+
stroke="currentColor"
|
|
154
|
+
stroke-width="2"
|
|
155
|
+
stroke-linecap="round"
|
|
156
|
+
stroke-linejoin="round"
|
|
157
|
+
aria-hidden="true"
|
|
158
|
+
>
|
|
159
|
+
<!-- Lucide arrow-left (ISC) -->
|
|
160
|
+
<path d="m12 19-7-7 7-7" />
|
|
161
|
+
<path d="M19 12H5" />
|
|
162
|
+
</svg>
|
|
163
|
+
Use another email
|
|
164
|
+
</button>
|
|
165
|
+
</div>
|
|
166
|
+
</mimi-card>
|
|
@@ -0,0 +1,212 @@
|
|
|
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 pantalla: va después del registro o del inicio de sesión. `verify` sale con el código
|
|
33
|
+
* (al completar los dígitos o con el botón, una sola vez por código); tu app lo comprueba y
|
|
34
|
+
* responde con `status`.
|
|
35
|
+
*
|
|
36
|
+
* ```html
|
|
37
|
+
* <mimi-otp-01 destination="a••••@example.com" [status]="otp.status()" [loading]="otp.checking()"
|
|
38
|
+
* (verify)="otp.check($event)" (resend)="otp.send()" (changeDestination)="router.navigate(['/signup'])" />
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
@Component({
|
|
42
|
+
selector: 'mimi-otp-01',
|
|
43
|
+
imports: [MimiButton, MimiCard, MimiInputOtp],
|
|
44
|
+
templateUrl: './otp-01.html',
|
|
45
|
+
host: {
|
|
46
|
+
'[class]': 'classes()',
|
|
47
|
+
'[attr.data-state]': 'shownStatus()',
|
|
48
|
+
},
|
|
49
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
50
|
+
})
|
|
51
|
+
export class Otp01 {
|
|
52
|
+
/** El correo o teléfono, ya enmascarado, que se muestra. */
|
|
53
|
+
readonly destination = input('a••••@example.com');
|
|
54
|
+
readonly length = input(6, { transform: numberAttribute });
|
|
55
|
+
/** Segundos hasta poder pedir otro código. */
|
|
56
|
+
readonly resendSeconds = input(60, { transform: numberAttribute });
|
|
57
|
+
/** Mientras tu app comprueba el código: «Verifying…» y no reenvía. */
|
|
58
|
+
readonly loading = input(false, { transform: booleanAttribute });
|
|
59
|
+
/** El resultado del último código enviado por `verify`. */
|
|
60
|
+
readonly status = input<Otp01Status>('idle');
|
|
61
|
+
/** Lleva el foco al código al aparecer (no lo hace si el foco ya está en otro lugar). */
|
|
62
|
+
readonly autofocus = input(true, { transform: booleanAttribute });
|
|
63
|
+
readonly userClass = input('', { alias: 'class' });
|
|
64
|
+
|
|
65
|
+
/** El código completo, una sola vez por código. */
|
|
66
|
+
readonly verify = output<string>();
|
|
67
|
+
/** «Resend code». */
|
|
68
|
+
readonly resend = output<void>();
|
|
69
|
+
/** «Use another email». */
|
|
70
|
+
readonly changeDestination = output<void>();
|
|
71
|
+
|
|
72
|
+
protected readonly id = `mimi-otp-01-${nextId++}`;
|
|
73
|
+
private readonly document = inject(DOCUMENT);
|
|
74
|
+
private readonly otp = viewChild.required(MimiInputOtp);
|
|
75
|
+
|
|
76
|
+
protected readonly code = signal('');
|
|
77
|
+
/** El último código que salió por `verify` (para no repetirlo). */
|
|
78
|
+
private readonly sent = signal<string | null>(null);
|
|
79
|
+
/** Se pulsó «Verify» con el código incompleto. */
|
|
80
|
+
protected readonly incomplete = signal(false);
|
|
81
|
+
|
|
82
|
+
/** `status` vale para el código que se envió: al cambiarlo, vuelve a «idle». */
|
|
83
|
+
/**
|
|
84
|
+
* Enviado y sin respuesta todavía (M4): el `status` que hay es el del envío anterior. Se apaga
|
|
85
|
+
* cuando tu app cambia `status` o `loading` (si respondes con el mismo `status`, pon `loading`
|
|
86
|
+
* mientras compruebas).
|
|
87
|
+
*/
|
|
88
|
+
private readonly awaiting = signal(false);
|
|
89
|
+
protected readonly shownStatus = computed<Otp01Status>(() =>
|
|
90
|
+
this.awaiting() || this.loading() || this.code() !== this.sent() ? 'idle' : this.status(),
|
|
91
|
+
);
|
|
92
|
+
protected readonly message = computed(() => {
|
|
93
|
+
if (this.incomplete()) return `Enter the ${this.length()}-digit code.`;
|
|
94
|
+
switch (this.shownStatus()) {
|
|
95
|
+
case 'error':
|
|
96
|
+
return 'Incorrect code. Check it and try again.';
|
|
97
|
+
case 'success':
|
|
98
|
+
return 'Code verified. Signing you in…';
|
|
99
|
+
default:
|
|
100
|
+
return 'The code expires in 10 minutes.';
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
protected readonly messageClasses = computed(() =>
|
|
104
|
+
cn(
|
|
105
|
+
'flex min-h-5 items-center gap-1.5 text-[13px]',
|
|
106
|
+
this.incomplete() || this.shownStatus() === 'error'
|
|
107
|
+
? 'text-destructive-soft-foreground'
|
|
108
|
+
: this.shownStatus() === 'success'
|
|
109
|
+
? 'text-success-soft-foreground'
|
|
110
|
+
: 'text-muted-foreground',
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
/** Cuenta regresiva del reenvío. */
|
|
115
|
+
protected readonly left = signal(0);
|
|
116
|
+
protected readonly countdown = computed(() => {
|
|
117
|
+
const s = this.left();
|
|
118
|
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
|
119
|
+
});
|
|
120
|
+
/** El único anuncio de la cuenta: cuando se habilita (no cada segundo). */
|
|
121
|
+
protected readonly resendAnnouncement = signal('');
|
|
122
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
123
|
+
|
|
124
|
+
protected readonly classes = computed(() =>
|
|
125
|
+
cn(
|
|
126
|
+
'flex min-h-svh w-full items-center justify-center bg-background px-5 py-8 text-foreground sm:bg-muted sm:p-10',
|
|
127
|
+
this.userClass(),
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
constructor() {
|
|
132
|
+
// Al completar los dígitos, verifica (una vez por código).
|
|
133
|
+
effect(() => {
|
|
134
|
+
const code = this.code();
|
|
135
|
+
if (code.length === this.length()) untracked(() => this.send(code));
|
|
136
|
+
});
|
|
137
|
+
// La respuesta de tu app (un status o un loading nuevos) termina la espera del envío.
|
|
138
|
+
effect(() => {
|
|
139
|
+
this.status();
|
|
140
|
+
this.loading();
|
|
141
|
+
untracked(() => this.awaiting.set(false));
|
|
142
|
+
});
|
|
143
|
+
inject(DestroyRef).onDestroy(() => this.stopTimer());
|
|
144
|
+
afterNextRender(() => {
|
|
145
|
+
this.startCountdown();
|
|
146
|
+
const active = this.document.activeElement;
|
|
147
|
+
if (this.autofocus() && (!active || active === this.document.body)) {
|
|
148
|
+
this.otp().controlElement.focus();
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
protected onCodeChange(code: string): void {
|
|
154
|
+
// Al editar, el código enviado deja de valer: el mensaje vuelve a «idle» y, completo de nuevo
|
|
155
|
+
// (aunque sea el mismo), se vuelve a enviar (M4).
|
|
156
|
+
if (code !== this.code()) this.sent.set(null);
|
|
157
|
+
this.code.set(code);
|
|
158
|
+
this.incomplete.set(false);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
protected onVerify(): void {
|
|
162
|
+
if (this.loading()) return;
|
|
163
|
+
const code = this.code();
|
|
164
|
+
if (code.length < this.length()) {
|
|
165
|
+
this.incomplete.set(true);
|
|
166
|
+
this.otp().controlElement.focus();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
this.send(code);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
protected onResend(): void {
|
|
173
|
+
if (this.left() > 0) return;
|
|
174
|
+
this.resend.emit();
|
|
175
|
+
this.code.set('');
|
|
176
|
+
this.sent.set(null);
|
|
177
|
+
this.incomplete.set(false);
|
|
178
|
+
this.startCountdown();
|
|
179
|
+
this.otp().controlElement.focus();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Una vez por código (B.5-a): al completar y con el botón no se repite. Después de un error, el
|
|
184
|
+
* mismo código se puede reintentar (M4).
|
|
185
|
+
*/
|
|
186
|
+
private send(code: string): void {
|
|
187
|
+
if (this.loading()) return;
|
|
188
|
+
if (code === this.sent() && !(this.status() === 'error' && !this.awaiting())) return;
|
|
189
|
+
this.sent.set(code);
|
|
190
|
+
this.awaiting.set(true);
|
|
191
|
+
this.verify.emit(code);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private startCountdown(): void {
|
|
195
|
+
this.stopTimer();
|
|
196
|
+
this.resendAnnouncement.set('');
|
|
197
|
+
this.left.set(this.resendSeconds());
|
|
198
|
+
if (this.left() <= 0) return;
|
|
199
|
+
this.timer = setInterval(() => {
|
|
200
|
+
this.left.update((s) => Math.max(0, s - 1));
|
|
201
|
+
if (this.left() === 0) {
|
|
202
|
+
this.stopTimer();
|
|
203
|
+
this.resendAnnouncement.set('You can request another code now.');
|
|
204
|
+
}
|
|
205
|
+
}, 1000);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private stopTimer(): void {
|
|
209
|
+
if (this.timer !== null) clearInterval(this.timer);
|
|
210
|
+
this.timer = null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './pricing-01';
|