Initial commit
This commit is contained in:
321
Client-Registration/src/App.jsx
Normal file
321
Client-Registration/src/App.jsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { PublicClientApplication } from '@azure/msal-browser';
|
||||
|
||||
// ─── Config ───
|
||||
const TENANT_ID = '891f98f1-ed34-42a1-9b6c-28b0554d92c2';
|
||||
const CLIENT_ID = '154c9111-14a0-4c0f-8132-7bc68254a74e';
|
||||
const AUTHORITY = `https://USIMClients.ciamlogin.com/${TENANT_ID}`;
|
||||
const GATEWAY_URL = 'https://adsapi.usimdev.com';
|
||||
const CLIENT_APP = 'https://adpclient.usimdev.com';
|
||||
|
||||
const msalConfig = {
|
||||
auth: {
|
||||
clientId: CLIENT_ID,
|
||||
authority: AUTHORITY,
|
||||
redirectUri: window.location.origin,
|
||||
postLogoutRedirectUri: window.location.origin,
|
||||
knownAuthorities: ['USIMClients.ciamlogin.com'],
|
||||
navigateToLoginRequestUrl: true
|
||||
},
|
||||
cache: { cacheLocation: 'sessionStorage', storeAuthStateInCookie: false },
|
||||
system: {
|
||||
loggerOptions: {
|
||||
loggerCallback: (level, msg, pii) => { if (!pii && level <= 1) console.warn(msg); },
|
||||
logLevel: 1
|
||||
}
|
||||
}
|
||||
};
|
||||
const loginRequest = { scopes: ['openid', 'profile', 'email'] };
|
||||
|
||||
const msalInstance = new PublicClientApplication(msalConfig);
|
||||
const msalReady = msalInstance.initialize();
|
||||
|
||||
// ─── App ───
|
||||
export default function App() {
|
||||
// States: loading | unauthenticated | form | submitting | success | error
|
||||
const [state, setState] = useState('loading');
|
||||
const [jwt, setJwt] = useState(null);
|
||||
const [user, setUser] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [form, setForm] = useState({ companyName: '', industry: '', website: '' });
|
||||
const initRef = useRef(false);
|
||||
|
||||
// ─── Initialize MSAL & authenticate ───
|
||||
useEffect(() => {
|
||||
if (initRef.current) return;
|
||||
initRef.current = true;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await msalReady;
|
||||
console.log('[Reg] MSAL initialized');
|
||||
|
||||
// Handle redirect response first
|
||||
const redirectResult = await msalInstance.handleRedirectPromise();
|
||||
if (redirectResult?.accessToken || redirectResult?.idToken) {
|
||||
const token = redirectResult.accessToken || redirectResult.idToken;
|
||||
console.log('[Reg] Got token from redirect');
|
||||
setJwt(token);
|
||||
setUser({
|
||||
name: redirectResult.account?.name,
|
||||
email: redirectResult.account?.username
|
||||
});
|
||||
setState('form');
|
||||
return;
|
||||
}
|
||||
|
||||
// Try silent token acquisition (SSO from client app)
|
||||
const accounts = msalInstance.getAllAccounts();
|
||||
if (accounts.length > 0) {
|
||||
try {
|
||||
const silent = await msalInstance.acquireTokenSilent({
|
||||
...loginRequest,
|
||||
account: accounts[0]
|
||||
});
|
||||
console.log('[Reg] Silent token acquired');
|
||||
setJwt(silent.accessToken || silent.idToken);
|
||||
setUser({ name: accounts[0].name, email: accounts[0].username });
|
||||
setState('form');
|
||||
return;
|
||||
} catch (e) {
|
||||
console.warn('[Reg] Silent failed, need interaction:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
setState('unauthenticated');
|
||||
} catch (err) {
|
||||
console.error('[Reg] Init error:', err);
|
||||
setError(err.message);
|
||||
setState('error');
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// ─── Sign in ───
|
||||
const signIn = useCallback(async () => {
|
||||
try {
|
||||
setState('loading');
|
||||
await msalInstance.loginRedirect(loginRequest);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setState('error');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ─── Submit registration ───
|
||||
const handleSubmit = useCallback(async (e) => {
|
||||
e.preventDefault();
|
||||
if (!form.companyName.trim()) return;
|
||||
|
||||
setState('submitting');
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${GATEWAY_URL}/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${jwt}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
companyName: form.companyName.trim(),
|
||||
industry: form.industry.trim() || null,
|
||||
website: form.website.trim() || null
|
||||
})
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
console.log('[Reg] Register response:', res.status, body);
|
||||
|
||||
if (body.ok) {
|
||||
setState('success');
|
||||
// Redirect to client app after short delay
|
||||
setTimeout(() => {
|
||||
window.location.href = CLIENT_APP;
|
||||
}, 3000);
|
||||
} else {
|
||||
setError(body.error || 'Registration failed');
|
||||
setState('form');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Reg] Submit error:', err);
|
||||
setError('Unable to connect to the server. Please try again.');
|
||||
setState('form');
|
||||
}
|
||||
}, [jwt, form]);
|
||||
|
||||
const updateField = (field) => (e) => setForm({ ...form, [field]: e.target.value });
|
||||
|
||||
// ─── Render ───
|
||||
return (
|
||||
<div className="reg-page">
|
||||
<header className="reg-header">
|
||||
<div className="reg-logo" onClick={() => window.location.href = CLIENT_APP}>
|
||||
<div className="reg-logo-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<span>AdPlatform</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="reg-main">
|
||||
{/* Loading */}
|
||||
{state === 'loading' && (
|
||||
<div className="reg-card center">
|
||||
<div className="spinner" />
|
||||
<p className="reg-text-muted">Preparing your account…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unauthenticated */}
|
||||
{state === 'unauthenticated' && (
|
||||
<div className="reg-card">
|
||||
<div className="reg-card-header">
|
||||
<div className="step-badge">Step 1 of 2</div>
|
||||
<h1>Welcome to AdPlatform</h1>
|
||||
<p>Sign in with your Microsoft, Google, or Apple account to get started.</p>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-lg btn-full" onClick={signIn}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4M10 17l5-5-5-5M15 12H3" />
|
||||
</svg>
|
||||
Sign In to Continue
|
||||
</button>
|
||||
<p className="reg-text-muted small">You'll be redirected to Microsoft's sign-in page. If you already have an account, you'll be signed in automatically.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Registration Form */}
|
||||
{state === 'form' && (
|
||||
<div className="reg-card">
|
||||
<div className="reg-card-header">
|
||||
<div className="step-badge">Step 2 of 2</div>
|
||||
<h1>Set Up Your Account</h1>
|
||||
<p>Tell us about your organization to complete registration.</p>
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
<div className="reg-user-info">
|
||||
<div className="reg-user-avatar">{(user.name || user.email || 'U')[0].toUpperCase()}</div>
|
||||
<div>
|
||||
<div className="reg-user-name">{user.name || 'User'}</div>
|
||||
<div className="reg-user-email">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="reg-error">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M15 9l-6 6M9 9l6 6"/></svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Organization / Company Name <span className="required">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="e.g. Acme Marketing Inc."
|
||||
value={form.companyName}
|
||||
onChange={updateField('companyName')}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<span className="form-hint">This will be your client name in AdPlatform.</span>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Industry</label>
|
||||
<select className="form-input" value={form.industry} onChange={updateField('industry')}>
|
||||
<option value="">Select an industry (optional)</option>
|
||||
<option value="Retail / E-commerce">Retail / E-commerce</option>
|
||||
<option value="Technology / SaaS">Technology / SaaS</option>
|
||||
<option value="Healthcare">Healthcare</option>
|
||||
<option value="Finance / Insurance">Finance / Insurance</option>
|
||||
<option value="Real Estate">Real Estate</option>
|
||||
<option value="Education">Education</option>
|
||||
<option value="Travel / Hospitality">Travel / Hospitality</option>
|
||||
<option value="Food / Restaurant">Food / Restaurant</option>
|
||||
<option value="Professional Services">Professional Services</option>
|
||||
<option value="Non-profit">Non-profit</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Website</label>
|
||||
<input
|
||||
type="url"
|
||||
className="form-input"
|
||||
placeholder="https://www.example.com"
|
||||
value={form.website}
|
||||
onChange={updateField('website')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary btn-lg btn-full" disabled={!form.companyName.trim()}>
|
||||
Complete Registration
|
||||
</button>
|
||||
|
||||
<p className="reg-text-muted small center-text">
|
||||
By registering, your advertising account will be managed under the USIM agency umbrella.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submitting */}
|
||||
{state === 'submitting' && (
|
||||
<div className="reg-card center">
|
||||
<div className="spinner" />
|
||||
<h2>Creating Your Account</h2>
|
||||
<p className="reg-text-muted">Setting up {form.companyName}…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success */}
|
||||
{state === 'success' && (
|
||||
<div className="reg-card center">
|
||||
<div className="success-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 11.08V12a10 10 0 11-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1>You're All Set!</h1>
|
||||
<p className="reg-text-muted">
|
||||
<strong>{form.companyName}</strong> has been registered. Redirecting you to the dashboard…
|
||||
</p>
|
||||
<div className="progress-bar"><div className="progress-fill" /></div>
|
||||
<a href={CLIENT_APP} className="btn btn-outline">Go to Dashboard Now</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{state === 'error' && (
|
||||
<div className="reg-card center">
|
||||
<div className="error-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2>Something Went Wrong</h2>
|
||||
<p className="reg-text-muted">{error}</p>
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center' }}>
|
||||
<button className="btn btn-primary" onClick={() => { setError(null); setState('unauthenticated'); }}>Try Again</button>
|
||||
<a href={CLIENT_APP} className="btn btn-outline">Back to Home</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="reg-footer">
|
||||
<span>Powered by <strong>USIM</strong></span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
Client-Registration/src/index.js
Normal file
6
Client-Registration/src/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')).render(<App />);
|
||||
327
Client-Registration/src/styles.css
Normal file
327
Client-Registration/src/styles.css
Normal file
@@ -0,0 +1,327 @@
|
||||
/* ─── Registration Portal Styles ─── */
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap');
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--color-primary: #2563eb;
|
||||
--color-primary-hover: #1d4ed8;
|
||||
--color-primary-light: #eff6ff;
|
||||
--color-success: #059669;
|
||||
--color-success-light: #ecfdf5;
|
||||
--color-error: #dc2626;
|
||||
--color-error-light: #fef2f2;
|
||||
--color-bg: #f8fafc;
|
||||
--color-surface: #ffffff;
|
||||
--color-border: #e2e8f0;
|
||||
--color-text: #0f172a;
|
||||
--color-text-secondary: #475569;
|
||||
--color-text-muted: #94a3b8;
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
|
||||
--shadow: 0 1px 3px rgba(0,0,0,0.08), 0 4px 16px rgba(0,0,0,0.04);
|
||||
--shadow-lg: 0 4px 24px rgba(0,0,0,0.08), 0 12px 48px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ─── Page Layout ─── */
|
||||
.reg-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.reg-header {
|
||||
padding: 20px 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.reg-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.reg-logo-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: linear-gradient(135deg, var(--color-primary), #3b82f6);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.reg-logo-icon svg { width: 20px; height: 20px; }
|
||||
|
||||
.reg-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.reg-footer {
|
||||
padding: 20px 32px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ─── Card ─── */
|
||||
.reg-card {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.reg-card.center { text-align: center; }
|
||||
|
||||
.reg-card-header {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.reg-card-header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.reg-card-header p {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.reg-card h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ─── Step Badge ─── */
|
||||
.step-badge {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
background: var(--color-primary-light);
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
margin-bottom: 16px;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ─── User Info Bar ─── */
|
||||
.reg-user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.reg-user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reg-user-name { font-weight: 600; font-size: 14px; }
|
||||
.reg-user-email { font-size: 13px; color: var(--color-text-muted); }
|
||||
|
||||
/* ─── Forms ─── */
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.required { color: var(--color-error); }
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
font-size: 15px;
|
||||
font-family: inherit;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
outline: none;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.form-input::placeholder { color: var(--color-text-muted); }
|
||||
|
||||
select.form-input {
|
||||
cursor: pointer;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
padding-right: 36px;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* ─── Buttons ─── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
padding: 10px 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
text-decoration: none;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover { background: var(--color-primary-hover); }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.btn-outline {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.btn-outline:hover { background: var(--color-bg); border-color: var(--color-text-muted); }
|
||||
|
||||
.btn-lg { padding: 14px 24px; font-size: 16px; }
|
||||
.btn-full { width: 100%; }
|
||||
|
||||
/* ─── Error Box ─── */
|
||||
.reg-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background: var(--color-error-light);
|
||||
color: var(--color-error);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ─── Success / Error Icons ─── */
|
||||
.success-icon {
|
||||
color: var(--color-success);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.success-icon svg { width: 64px; height: 64px; }
|
||||
|
||||
.error-icon {
|
||||
color: var(--color-error);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-icon svg { width: 64px; height: 64px; }
|
||||
|
||||
/* ─── Spinner ─── */
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ─── Progress Bar (success state) ─── */
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--color-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--color-success);
|
||||
border-radius: 4px;
|
||||
animation: fill-progress 3s linear forwards;
|
||||
}
|
||||
|
||||
@keyframes fill-progress { from { width: 0; } to { width: 100%; } }
|
||||
|
||||
/* ─── Text Utilities ─── */
|
||||
.reg-text-muted { color: var(--color-text-secondary); font-size: 15px; }
|
||||
.reg-text-muted.small { font-size: 13px; margin-top: 16px; }
|
||||
.center-text { text-align: center; }
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
@media (max-width: 540px) {
|
||||
.reg-card { padding: 28px 24px; }
|
||||
.reg-card-header h1 { font-size: 20px; }
|
||||
.reg-header { padding: 16px 20px; }
|
||||
}
|
||||
Reference in New Issue
Block a user