feat: Permettre la génération et l'envoi de codes d'invitation aux parents
Les administrateurs ont besoin d'un moyen simple pour inviter les parents à rejoindre la plateforme. Cette fonctionnalité permet de générer des codes d'invitation uniques (8 caractères alphanumériques) avec une validité de 48h, de les envoyer par email, et de les activer via une page publique dédiée qui crée automatiquement le compte parent. L'interface d'administration offre l'envoi unitaire et en masse, le renvoi, le filtrage par statut, ainsi que la visualisation de l'état de chaque invitation (en attente, activée, expirée).
This commit is contained in:
@@ -31,6 +31,11 @@
|
||||
<span class="action-label">Gérer les utilisateurs</span>
|
||||
<span class="action-hint">Inviter et gérer</span>
|
||||
</a>
|
||||
<a class="action-card" href="/admin/parent-invitations">
|
||||
<span class="action-icon">✉️</span>
|
||||
<span class="action-label">Invitations parents</span>
|
||||
<span class="action-hint">Codes d'invitation</span>
|
||||
</a>
|
||||
<a class="action-card" href="/admin/classes">
|
||||
<span class="action-icon">🏫</span>
|
||||
<span class="action-label">Configurer les classes</span>
|
||||
|
||||
104
frontend/src/lib/features/import/api/parentInvitationImport.ts
Normal file
104
frontend/src/lib/features/import/api/parentInvitationImport.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { getApiBaseUrl } from '$lib/api';
|
||||
import { authenticatedFetch } from '$lib/auth';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface AnalyzeResult {
|
||||
columns: string[];
|
||||
rows: Record<string, string>[];
|
||||
totalRows: number;
|
||||
filename: string;
|
||||
suggestedMapping: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ValidatedRow {
|
||||
studentName: string;
|
||||
email1: string;
|
||||
email2: string;
|
||||
studentId: string | null;
|
||||
studentMatch: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ValidateResult {
|
||||
validatedRows: ValidatedRow[];
|
||||
validCount: number;
|
||||
errorCount: number;
|
||||
}
|
||||
|
||||
export interface BulkResult {
|
||||
created: number;
|
||||
errors: { line: number; email?: string; error: string }[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// === API Functions ===
|
||||
|
||||
/**
|
||||
* Upload et analyse un fichier CSV ou XLSX pour l'import d'invitations parents.
|
||||
*/
|
||||
export async function analyzeFile(file: File): Promise<AnalyzeResult> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await authenticatedFetch(`${apiUrl}/import/parents/analyze`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => null);
|
||||
throw new Error(
|
||||
data?.['hydra:description'] ?? data?.message ?? data?.detail ?? "Erreur lors de l'analyse du fichier"
|
||||
);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide les lignes mappées contre les élèves existants.
|
||||
*/
|
||||
export async function validateRows(
|
||||
rows: { studentName: string; email1: string; email2: string }[]
|
||||
): Promise<ValidateResult> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/import/parents/validate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rows })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => null);
|
||||
throw new Error(
|
||||
data?.['hydra:description'] ?? data?.message ?? data?.detail ?? 'Erreur lors de la validation'
|
||||
);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie les invitations en masse via l'endpoint bulk existant.
|
||||
*/
|
||||
export async function sendBulkInvitations(
|
||||
invitations: { studentId: string; parentEmail: string }[]
|
||||
): Promise<BulkResult> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/parent-invitations/bulk`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ invitations })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => null);
|
||||
throw new Error(
|
||||
data?.['hydra:description'] ?? data?.message ?? data?.detail ?? "Erreur lors de l'envoi"
|
||||
);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
const navLinks = [
|
||||
{ href: '/dashboard', label: 'Tableau de bord', isActive: () => false },
|
||||
{ href: '/admin/users', label: 'Utilisateurs', isActive: () => isUsersActive },
|
||||
{ href: '/admin/parent-invitations', label: 'Invitations parents', isActive: () => isParentInvitationsActive },
|
||||
{ href: '/admin/students', label: 'Élèves', isActive: () => isStudentsActive },
|
||||
{ href: '/admin/classes', label: 'Classes', isActive: () => isClassesActive },
|
||||
{ href: '/admin/subjects', label: 'Matières', isActive: () => isSubjectsActive },
|
||||
@@ -82,6 +83,7 @@
|
||||
|
||||
// Determine which admin section is active
|
||||
const isUsersActive = $derived(page.url.pathname.startsWith('/admin/users'));
|
||||
const isParentInvitationsActive = $derived(page.url.pathname.startsWith('/admin/parent-invitations'));
|
||||
const isStudentsActive = $derived(page.url.pathname.startsWith('/admin/students'));
|
||||
const isClassesActive = $derived(page.url.pathname.startsWith('/admin/classes'));
|
||||
const isSubjectsActive = $derived(page.url.pathname.startsWith('/admin/subjects'));
|
||||
|
||||
1469
frontend/src/routes/admin/import/parents/+page.svelte
Normal file
1469
frontend/src/routes/admin/import/parents/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
1147
frontend/src/routes/admin/parent-invitations/+page.svelte
Normal file
1147
frontend/src/routes/admin/parent-invitations/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
586
frontend/src/routes/parent-activate/[code]/+page.svelte
Normal file
586
frontend/src/routes/parent-activate/[code]/+page.svelte
Normal file
@@ -0,0 +1,586 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getApiBaseUrl } from '$lib/api/config';
|
||||
|
||||
const code = $derived(page.params.code ?? '');
|
||||
const apiBaseUrl = getApiBaseUrl();
|
||||
|
||||
// Form state
|
||||
let firstName = $state('');
|
||||
let lastName = $state('');
|
||||
let password = $state('');
|
||||
let passwordConfirmation = $state('');
|
||||
let showPassword = $state(false);
|
||||
let formError = $state('');
|
||||
let isSubmitting = $state(false);
|
||||
let isActivated = $state(false);
|
||||
|
||||
// Password validation
|
||||
const hasMinLength = $derived(password.length >= 8);
|
||||
const hasUppercase = $derived(/[A-Z]/.test(password));
|
||||
const hasLowercase = $derived(/[a-z]/.test(password));
|
||||
const hasDigit = $derived(/[0-9]/.test(password));
|
||||
const hasSpecial = $derived(/[^A-Za-z0-9]/.test(password));
|
||||
const passwordsMatch = $derived(password === passwordConfirmation && password.length > 0);
|
||||
const isPasswordValid = $derived(
|
||||
hasMinLength && hasUppercase && hasLowercase && hasDigit && hasSpecial && passwordsMatch
|
||||
);
|
||||
const isFormValid = $derived(
|
||||
firstName.trim().length >= 2 && lastName.trim().length >= 2 && isPasswordValid
|
||||
);
|
||||
|
||||
async function handleSubmit(event: Event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!isFormValid) {
|
||||
formError = 'Veuillez corriger les erreurs avant de continuer.';
|
||||
return;
|
||||
}
|
||||
|
||||
formError = '';
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
const response = await globalThis.fetch(`${apiBaseUrl}/parent-invitations/activate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
password
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
const detail =
|
||||
errorData['hydra:description'] ??
|
||||
errorData.detail ??
|
||||
errorData.message;
|
||||
|
||||
if (response.status === 404) {
|
||||
formError = 'Ce lien d\'activation est invalide.';
|
||||
} else if (response.status === 410) {
|
||||
formError = 'Ce lien d\'activation a expiré. Contactez votre établissement.';
|
||||
} else if (response.status === 409) {
|
||||
formError = 'Cette invitation a déjà été activée.';
|
||||
} else {
|
||||
formError = detail ?? 'Une erreur est survenue lors de l\'activation.';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
isActivated = true;
|
||||
} catch {
|
||||
formError = 'Erreur de connexion. Veuillez réessayer.';
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Activation compte parent | Classeo</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="activation-page">
|
||||
<div class="activation-container">
|
||||
<!-- Logo -->
|
||||
<div class="logo">
|
||||
<span class="logo-icon">📚</span>
|
||||
<span class="logo-text">Classeo</span>
|
||||
</div>
|
||||
|
||||
{#if isActivated}
|
||||
<!-- Success state -->
|
||||
<div class="card">
|
||||
<div class="success-state">
|
||||
<div class="success-icon">✓</div>
|
||||
<h2>Compte activé !</h2>
|
||||
<p>Votre compte parent a été créé avec succès.</p>
|
||||
<p class="hint">Vous pouvez maintenant vous connecter pour acceder aux informations de votre enfant.</p>
|
||||
<button class="submit-button" onclick={() => goto('/login')}>
|
||||
Se connecter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Activation form -->
|
||||
<div class="card">
|
||||
<h1>Activation de votre compte parent</h1>
|
||||
|
||||
<p class="intro-text">
|
||||
Vous avez été invité à rejoindre Classeo pour suivre la scolarité de votre enfant.
|
||||
Complétez les informations ci-dessous pour créer votre compte.
|
||||
</p>
|
||||
|
||||
<form onsubmit={handleSubmit}>
|
||||
{#if formError}
|
||||
<div class="form-error">
|
||||
<span class="error-badge">!</span>
|
||||
{formError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Name fields -->
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="firstName">Prénom *</label>
|
||||
<input
|
||||
id="firstName"
|
||||
type="text"
|
||||
bind:value={firstName}
|
||||
required
|
||||
minlength="2"
|
||||
maxlength="100"
|
||||
placeholder="Votre prénom"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="lastName">Nom *</label>
|
||||
<input
|
||||
id="lastName"
|
||||
type="text"
|
||||
bind:value={lastName}
|
||||
required
|
||||
minlength="2"
|
||||
maxlength="100"
|
||||
placeholder="Votre nom"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="form-group">
|
||||
<label for="password">Créer votre mot de passe *</label>
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
bind:value={password}
|
||||
required
|
||||
placeholder="Entrez votre mot de passe"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-password"
|
||||
onclick={() => (showPassword = !showPassword)}
|
||||
>
|
||||
{showPassword ? '🙈' : '👁'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password requirements -->
|
||||
<div class="password-requirements">
|
||||
<span class="requirements-title">Votre mot de passe doit contenir :</span>
|
||||
<ul>
|
||||
<li class:valid={hasMinLength}>
|
||||
<span class="check">{hasMinLength ? '✓' : '○'}</span>
|
||||
Au moins 8 caractères
|
||||
</li>
|
||||
<li class:valid={hasUppercase}>
|
||||
<span class="check">{hasUppercase ? '✓' : '○'}</span>
|
||||
Une majuscule
|
||||
</li>
|
||||
<li class:valid={hasLowercase}>
|
||||
<span class="check">{hasLowercase ? '✓' : '○'}</span>
|
||||
Une minuscule
|
||||
</li>
|
||||
<li class:valid={hasDigit}>
|
||||
<span class="check">{hasDigit ? '✓' : '○'}</span>
|
||||
Un chiffre
|
||||
</li>
|
||||
<li class:valid={hasSpecial}>
|
||||
<span class="check">{hasSpecial ? '✓' : '○'}</span>
|
||||
Un caractère spécial
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Confirm password -->
|
||||
<div class="form-group">
|
||||
<label for="passwordConfirmation">Confirmer le mot de passe *</label>
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
id="passwordConfirmation"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
bind:value={passwordConfirmation}
|
||||
required
|
||||
placeholder="Confirmez votre mot de passe"
|
||||
class:has-error={passwordConfirmation.length > 0 && !passwordsMatch}
|
||||
/>
|
||||
</div>
|
||||
{#if passwordConfirmation.length > 0 && !passwordsMatch}
|
||||
<span class="field-error">Les mots de passe ne correspondent pas.</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<button type="submit" class="submit-button" disabled={!isFormValid || isSubmitting}>
|
||||
{#if isSubmitting}
|
||||
<span class="button-spinner"></span>
|
||||
Activation en cours...
|
||||
{:else}
|
||||
Activer mon compte
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Footer -->
|
||||
<p class="footer">Un problème ? Contactez votre établissement.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Design Tokens - Calm Productivity */
|
||||
:root {
|
||||
--color-calm: hsl(142, 76%, 36%);
|
||||
--color-attention: hsl(38, 92%, 50%);
|
||||
--color-alert: hsl(0, 72%, 51%);
|
||||
--surface-primary: hsl(210, 20%, 98%);
|
||||
--surface-elevated: hsl(0, 0%, 100%);
|
||||
--text-primary: hsl(222, 47%, 11%);
|
||||
--text-secondary: hsl(215, 16%, 47%);
|
||||
--text-muted: hsl(215, 13%, 65%);
|
||||
--accent-primary: hsl(199, 89%, 48%);
|
||||
--border-subtle: hsl(214, 32%, 91%);
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
--shadow-elevated: 0 4px 6px rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.activation-page {
|
||||
min-height: 100vh;
|
||||
background: var(--surface-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.activation-container {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
/* Logo */
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.card {
|
||||
background: var(--surface-elevated);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.card h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.intro-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Form */
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-wrapper input,
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
font-size: 15px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-elevated);
|
||||
color: var(--text-primary);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.input-wrapper input {
|
||||
padding-right: 48px;
|
||||
}
|
||||
|
||||
.input-wrapper input:focus,
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 3px hsla(199, 89%, 48%, 0.15);
|
||||
}
|
||||
|
||||
.input-wrapper input.has-error {
|
||||
border-color: var(--color-alert);
|
||||
}
|
||||
|
||||
.input-wrapper input::placeholder,
|
||||
.form-group input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.toggle-password {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.toggle-password:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
font-size: 13px;
|
||||
color: var(--color-alert);
|
||||
}
|
||||
|
||||
/* Password Requirements */
|
||||
.password-requirements {
|
||||
padding: 16px;
|
||||
background: var(--surface-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.requirements-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.password-requirements ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.password-requirements li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.password-requirements li.valid {
|
||||
color: var(--color-calm);
|
||||
}
|
||||
|
||||
.password-requirements li .check {
|
||||
font-size: 14px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: var(--border-subtle);
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.password-requirements li.valid .check {
|
||||
background: var(--color-calm);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Submit Button */
|
||||
.submit-button {
|
||||
width: 100%;
|
||||
padding: 14px 24px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background: var(--accent-primary);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.1s, box-shadow 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.submit-button:hover:not(:disabled) {
|
||||
background: hsl(199, 89%, 42%);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.submit-button:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.submit-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button-spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Success state */
|
||||
.success-state {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin: 0 auto 20px;
|
||||
background: hsl(142, 76%, 95%);
|
||||
color: var(--color-calm);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.success-state h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.success-state p {
|
||||
font-size: 15px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.success-state .hint {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Form Error */
|
||||
.form-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background: hsl(0, 72%, 97%);
|
||||
border: 1px solid hsl(0, 72%, 90%);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
color: var(--color-alert);
|
||||
}
|
||||
|
||||
.error-badge {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: var(--color-alert);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user