First build
Some checks failed
Client Admin / build-deploy (push) Failing after 8s
Client Client / build-deploy (push) Failing after 3s
Client Registration / build-deploy (push) Failing after 20s
Client Tech / build-deploy (push) Failing after 1s
Client Home / build-deploy (push) Successful in 14s
Some checks failed
Client Admin / build-deploy (push) Failing after 8s
Client Client / build-deploy (push) Failing after 3s
Client Registration / build-deploy (push) Failing after 20s
Client Tech / build-deploy (push) Failing after 1s
Client Home / build-deploy (push) Successful in 14s
This commit is contained in:
@@ -1,321 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
63
Client-Registration/src/app/App.js
Normal file
63
Client-Registration/src/app/App.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import { RegistrationProvider, useRegistration } from '../context/RegistrationContext';
|
||||
import Shell from '../components/Shell';
|
||||
import ProgressStepper from '../components/ProgressStepper';
|
||||
import SignInStep from '../components/steps/SignInStep';
|
||||
import ContactStep from '../components/steps/ContactStep';
|
||||
import BusinessStep from '../components/steps/BusinessStep';
|
||||
import ReviewStep from '../components/steps/ReviewStep';
|
||||
import ConfirmationStep from '../components/steps/ConfirmationStep';
|
||||
|
||||
// Step renderer — selects component based on current step
|
||||
function WizardContent() {
|
||||
const { isSignedIn } = useAuth();
|
||||
const { currentStep, onSignInComplete } = useRegistration();
|
||||
|
||||
// When auth completes, advance past sign-in step
|
||||
useEffect(() => {
|
||||
if (isSignedIn && currentStep === 0) {
|
||||
onSignInComplete();
|
||||
}
|
||||
}, [isSignedIn, currentStep, onSignInComplete]);
|
||||
|
||||
const steps = [SignInStep, ContactStep, BusinessStep, ReviewStep, ConfirmationStep];
|
||||
const StepComponent = steps[currentStep] || SignInStep;
|
||||
|
||||
return (
|
||||
<div className="wizard-container">
|
||||
<ProgressStepper currentStep={currentStep} />
|
||||
<StepComponent />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { isLoading } = useAuth();
|
||||
const [minWait, setMinWait] = useState(true);
|
||||
|
||||
// Brief minimum wait prevents flash while MSAL hydrates cache
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setMinWait(false), 400);
|
||||
return () => clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
if (isLoading || minWait) {
|
||||
return (
|
||||
<Shell>
|
||||
<div className="loading-container">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<RegistrationProvider>
|
||||
<WizardContent />
|
||||
</RegistrationProvider>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
199
Client-Registration/src/auth/AuthProvider.jsx
Normal file
199
Client-Registration/src/auth/AuthProvider.jsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
||||
import { PublicClientApplication, InteractionRequiredAuthError } from '@azure/msal-browser';
|
||||
import { msalConfig, loginRequest } from './authConfig';
|
||||
|
||||
/**
|
||||
* AuthProvider for Entra External ID (Customer Identity / CIAM).
|
||||
*
|
||||
* Uses @azure/msal-browser v3 with popup flow.
|
||||
* Supports Google, Apple, and Microsoft via identity_provider_hint.
|
||||
*
|
||||
* Exposed context shape:
|
||||
* isLoading — true while MSAL initializes or a login is in progress
|
||||
* isSignedIn — true when a user is authenticated
|
||||
* user — { entraSubjectId, email, displayName, firstName, surname, provider }
|
||||
* error — string | null
|
||||
* signIn(hint) — initiates login popup for 'google' | 'apple' | 'microsoft'
|
||||
* signOut() — clears session
|
||||
* getAccessToken()— returns a fresh ID token for authenticating API calls
|
||||
* clearError() — clears the error state
|
||||
*/
|
||||
|
||||
// ── Provider hint → Entra External ID identity_provider_hint ─────────────
|
||||
// Values must match the social IdP names configured in the CIAM tenant.
|
||||
const PROVIDER_HINTS = {
|
||||
google: 'google',
|
||||
apple: 'apple.com',
|
||||
microsoft: undefined, // No hint = email/password or MSA
|
||||
};
|
||||
|
||||
// ── MSAL instance — module-level singleton ────────────────────────────────
|
||||
const msalInstance = new PublicClientApplication(msalConfig);
|
||||
|
||||
// ── Context ───────────────────────────────────────────────────────────────
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within <AuthProvider>');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ── Map ID token claims → user object ─────────────────────────────────────
|
||||
function claimsToUser(claims, provider) {
|
||||
return {
|
||||
entraSubjectId: claims.oid ?? claims.sub ?? null,
|
||||
email: claims.email ?? claims.preferred_username ?? null,
|
||||
displayName: claims.name ?? null,
|
||||
firstName: claims.given_name ?? null,
|
||||
surname: claims.family_name ?? null,
|
||||
provider: provider ?? 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
// ── Provider ──────────────────────────────────────────────────────────────
|
||||
export function AuthProvider({ children }) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [user, setUser] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const initialized = useRef(false);
|
||||
|
||||
const isSignedIn = !!user;
|
||||
|
||||
// ── Initialize MSAL + handle redirect response on mount ───────────
|
||||
useEffect(() => {
|
||||
if (initialized.current) return;
|
||||
initialized.current = true;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await msalInstance.initialize();
|
||||
|
||||
// Handle returning from loginRedirect
|
||||
const redirectResult = await msalInstance.handleRedirectPromise();
|
||||
if (redirectResult?.idTokenClaims) {
|
||||
const provider = sessionStorage.getItem('adp_pending_provider') ?? 'unknown';
|
||||
sessionStorage.removeItem('adp_pending_provider');
|
||||
setUser(claimsToUser(redirectResult.idTokenClaims, provider));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore existing cached account
|
||||
const accounts = msalInstance.getAllAccounts();
|
||||
if (accounts.length > 0) {
|
||||
try {
|
||||
const silent = await msalInstance.acquireTokenSilent({
|
||||
...loginRequest,
|
||||
account: accounts[0],
|
||||
});
|
||||
if (silent?.idTokenClaims) {
|
||||
const idp = accounts[0].idTokenClaims?.idp ?? 'unknown';
|
||||
setUser(claimsToUser(silent.idTokenClaims, idp));
|
||||
}
|
||||
} catch (silentErr) {
|
||||
if (silentErr instanceof InteractionRequiredAuthError) {
|
||||
await msalInstance.clearCache();
|
||||
} else {
|
||||
console.warn('[Auth] Silent refresh failed:', silentErr.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Auth] MSAL init error:', err);
|
||||
setError('Authentication service unavailable. Please refresh the page.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// ── Sign in ────────────────────────────────────────────────────────
|
||||
const signIn = useCallback(async (providerHint) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const hint = PROVIDER_HINTS[providerHint];
|
||||
const request = {
|
||||
...loginRequest,
|
||||
...(hint && { extraQueryParameters: { identity_provider_hint: hint } }),
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await msalInstance.loginPopup(request);
|
||||
if (result?.idTokenClaims) {
|
||||
setUser(claimsToUser(result.idTokenClaims, providerHint));
|
||||
}
|
||||
} catch (popupErr) {
|
||||
const msg = popupErr?.message ?? '';
|
||||
|
||||
if (msg.includes('popup_window_error')) {
|
||||
try {
|
||||
sessionStorage.setItem('adp_pending_provider', providerHint ?? 'unknown');
|
||||
await msalInstance.loginRedirect(request);
|
||||
return;
|
||||
} catch (redirectErr) {
|
||||
console.error('[Auth] Redirect fallback failed:', redirectErr);
|
||||
setError('Sign-in failed. Please try again.');
|
||||
}
|
||||
} else if (msg.includes('user_cancelled') || msg.includes('access_denied')) {
|
||||
setError(null);
|
||||
} else {
|
||||
console.error('[Auth] Sign-in error:', popupErr);
|
||||
setError('Sign-in failed. Please try again.');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Get access token for API calls ────────────────────────────────
|
||||
// Returns the ID token — used as Bearer token when calling the
|
||||
// Registration Function. The Function validates it against the CIAM
|
||||
// tenant and extracts the OID claim as the user's identity.
|
||||
const getAccessToken = useCallback(async () => {
|
||||
const accounts = msalInstance.getAllAccounts();
|
||||
if (accounts.length === 0) throw new Error('Not signed in');
|
||||
|
||||
try {
|
||||
const result = await msalInstance.acquireTokenSilent({
|
||||
...loginRequest,
|
||||
account: accounts[0],
|
||||
});
|
||||
return result.idToken;
|
||||
} catch (err) {
|
||||
if (err instanceof InteractionRequiredAuthError) {
|
||||
throw new Error('Session expired. Please sign in again.');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Sign out ───────────────────────────────────────────────────────
|
||||
const signOut = useCallback(async () => {
|
||||
const accounts = msalInstance.getAllAccounts();
|
||||
setUser(null);
|
||||
try {
|
||||
if (accounts.length > 0) {
|
||||
await msalInstance.logoutPopup({ account: accounts[0] });
|
||||
}
|
||||
} catch {
|
||||
// Local state already cleared
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => setError(null), []);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
isLoading,
|
||||
isSignedIn,
|
||||
user,
|
||||
error,
|
||||
signIn,
|
||||
signOut,
|
||||
getAccessToken,
|
||||
clearError,
|
||||
}), [isLoading, isSignedIn, user, error, signIn, signOut, getAccessToken, clearError]);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
32
Client-Registration/src/auth/authConfig.js
Normal file
32
Client-Registration/src/auth/authConfig.js
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* MSAL Configuration for Entra External ID (Customer Identity)
|
||||
*
|
||||
* This uses the External ID tenant (CIAM) — different from the
|
||||
* internal Entra tenant used by the Admin/Management console.
|
||||
*
|
||||
* TODO: Replace placeholder values with actual External ID tenant details.
|
||||
*/
|
||||
export const msalConfig = {
|
||||
auth: {
|
||||
clientId: '154c9111-14a0-4c0f-8132-7bc68254a74e',
|
||||
authority: 'https://usimclients.ciamlogin.com/891f98f1-ed34-42a1-9b6c-28b0554d92c2',
|
||||
redirectUri: window.location.origin,
|
||||
postLogoutRedirectUri: window.location.origin,
|
||||
knownAuthorities: ['usimclients.ciamlogin.com'],
|
||||
},
|
||||
cache: {
|
||||
cacheLocation: 'sessionStorage',
|
||||
storeAuthStateInCookie: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const loginRequest = {
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
};
|
||||
|
||||
// Registration Function API
|
||||
export const API_BASE_URL = 'https://adpregapi.usimdev.com';
|
||||
|
||||
// Function key for Registration API (AuthorizationLevel.Function)
|
||||
// TODO: Set this from your Azure Function → App Keys → default host key
|
||||
export const API_FUNCTION_KEY = '';
|
||||
32
Client-Registration/src/components/ProgressStepper.jsx
Normal file
32
Client-Registration/src/components/ProgressStepper.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { STEPS } from '../context/RegistrationContext';
|
||||
|
||||
export default function ProgressStepper({ currentStep }) {
|
||||
return (
|
||||
<div className="stepper">
|
||||
{STEPS.map((step, idx) => {
|
||||
const isComplete = idx < currentStep;
|
||||
const isCurrent = idx === currentStep;
|
||||
const stepClass = [
|
||||
'stepper-item',
|
||||
isComplete ? 'stepper-complete' : '',
|
||||
isCurrent ? 'stepper-active' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.key}>
|
||||
<div className={stepClass}>
|
||||
<div className="stepper-circle">
|
||||
{isComplete ? '✓' : idx + 1}
|
||||
</div>
|
||||
<span className="stepper-label">{step.label}</span>
|
||||
</div>
|
||||
{idx < STEPS.length - 1 && (
|
||||
<div className={`stepper-line ${isComplete ? 'stepper-line-complete' : ''}`} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
Client-Registration/src/components/Shell.jsx
Normal file
31
Client-Registration/src/components/Shell.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
|
||||
export default function Shell({ children }) {
|
||||
const { isSignedIn, user, signOut } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<header className="shell-header">
|
||||
<div className="shell-logo">
|
||||
<span className="logo-icon">◆</span>
|
||||
<span className="logo-text">AdPlatform</span>
|
||||
<span className="logo-badge">Registration</span>
|
||||
</div>
|
||||
|
||||
{isSignedIn && user && (
|
||||
<div className="shell-user">
|
||||
<span className="user-name">{user.displayName || user.email}</span>
|
||||
<button onClick={signOut} className="btn-signout">Sign Out</button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<main className="shell-content">{children}</main>
|
||||
|
||||
<footer className="shell-footer">
|
||||
<span>AdPlatform Registration Portal v1.0</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
172
Client-Registration/src/components/steps/BusinessStep.jsx
Normal file
172
Client-Registration/src/components/steps/BusinessStep.jsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useRegistration } from '../../context/RegistrationContext';
|
||||
|
||||
// Business industry categories (from tbBusinessCategory)
|
||||
const INDUSTRY_CATEGORIES = [
|
||||
{ value: 'retail', label: 'Retail & E-Commerce' },
|
||||
{ value: 'technology', label: 'Technology' },
|
||||
{ value: 'healthcare', label: 'Healthcare' },
|
||||
{ value: 'finance', label: 'Finance & Insurance' },
|
||||
{ value: 'real_estate', label: 'Real Estate' },
|
||||
{ value: 'food_beverage', label: 'Restaurant & Food Service' },
|
||||
{ value: 'professional', label: 'Professional Services' },
|
||||
{ value: 'home_services', label: 'Home Services' },
|
||||
{ value: 'education', label: 'Education' },
|
||||
{ value: 'entertainment', label: 'Entertainment & Media' },
|
||||
{ value: 'automotive', label: 'Automotive' },
|
||||
{ value: 'travel', label: 'Travel & Hospitality' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
|
||||
// Account type options — map to tbClientCategory (General/Franchisee/Franchisor)
|
||||
const ACCOUNT_TYPES = [
|
||||
{
|
||||
value: 'General',
|
||||
icon: '🏢',
|
||||
label: 'Independent Business',
|
||||
description: 'A standalone business running its own advertising campaigns.',
|
||||
},
|
||||
{
|
||||
value: 'Franchisee',
|
||||
icon: '🏪',
|
||||
label: 'Franchisee',
|
||||
description: 'You operate one or more franchise locations under a brand.',
|
||||
},
|
||||
{
|
||||
value: 'Franchisor',
|
||||
icon: '🏗️',
|
||||
label: 'Franchisor / Brand',
|
||||
description: 'You manage advertising across a network of franchise locations.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function BusinessStep() {
|
||||
const { businessData, setBusinessData, saveBusiness, goBack, loading, error } = useRegistration();
|
||||
|
||||
const handleChange = useCallback((e) => {
|
||||
const { name, value } = e.target;
|
||||
setBusinessData(prev => ({ ...prev, [name]: value }));
|
||||
}, [setBusinessData]);
|
||||
|
||||
const handleAccountType = useCallback((value) => {
|
||||
setBusinessData(prev => ({ ...prev, clientCategory: value }));
|
||||
}, [setBusinessData]);
|
||||
|
||||
const handleSubmit = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
saveBusiness();
|
||||
}, [saveBusiness]);
|
||||
|
||||
return (
|
||||
<div className="step-card">
|
||||
<div className="step-header">
|
||||
<span className="step-icon">🏢</span>
|
||||
<h2>Business Information</h2>
|
||||
<p className="step-description">
|
||||
Tell us about your business so we can set up your advertising accounts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
|
||||
{/* ── Account Type ─────────────────────────────────── */}
|
||||
<div className="form-group">
|
||||
<label>Account Type <span className="required">*</span></label>
|
||||
<span className="form-help" style={{ display: 'block', marginBottom: '10px' }}>
|
||||
How best describes your business structure?
|
||||
</span>
|
||||
<div className="account-type-list">
|
||||
{ACCOUNT_TYPES.map(type => (
|
||||
<button
|
||||
key={type.value}
|
||||
type="button"
|
||||
className={`account-type-option${businessData.clientCategory === type.value ? ' selected' : ''}`}
|
||||
onClick={() => handleAccountType(type.value)}
|
||||
>
|
||||
<span className="account-type-icon">{type.icon}</span>
|
||||
<div className="account-type-text">
|
||||
<span className="account-type-label">{type.label}</span>
|
||||
<span className="account-type-desc">{type.description}</span>
|
||||
</div>
|
||||
<span className="account-type-check">
|
||||
{businessData.clientCategory === type.value ? '✓' : ''}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Business Name ────────────────────────────────── */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="businessName">Business Name <span className="required">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
id="businessName"
|
||||
name="businessName"
|
||||
value={businessData.businessName}
|
||||
onChange={handleChange}
|
||||
placeholder="Acme Corp"
|
||||
required
|
||||
/>
|
||||
<span className="form-help">This name will appear on your advertising accounts</span>
|
||||
</div>
|
||||
|
||||
{/* ── Website ──────────────────────────────────────── */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="websiteUrl">Website</label>
|
||||
<input
|
||||
type="url"
|
||||
id="websiteUrl"
|
||||
name="websiteUrl"
|
||||
value={businessData.websiteUrl}
|
||||
onChange={handleChange}
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Industry ─────────────────────────────────────── */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="businessCategory">Industry / Category</label>
|
||||
<select
|
||||
id="businessCategory"
|
||||
name="businessCategory"
|
||||
value={businessData.businessCategory}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="">Select a category...</option>
|
||||
{INDUSTRY_CATEGORIES.map(cat => (
|
||||
<option key={cat.value} value={cat.value}>{cat.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* ── Description ──────────────────────────────────── */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="businessDescription">About Your Business</label>
|
||||
<textarea
|
||||
id="businessDescription"
|
||||
name="businessDescription"
|
||||
value={businessData.businessDescription}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
placeholder="Tell us about your business, your advertising goals, and your target audience..."
|
||||
/>
|
||||
<span className="form-help">
|
||||
This helps our team understand your needs and set up the right campaigns
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="step-actions">
|
||||
<button type="button" className="btn-secondary" onClick={goBack}>
|
||||
← Back
|
||||
</button>
|
||||
<button type="submit" className="btn-primary" disabled={loading}>
|
||||
{loading ? 'Saving...' : 'Continue →'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { useAuth } from '../../auth/AuthProvider';
|
||||
import { useRegistration } from '../../context/RegistrationContext';
|
||||
|
||||
export default function ConfirmationStep() {
|
||||
const { user } = useAuth();
|
||||
const { businessData, registrationId } = useRegistration();
|
||||
|
||||
return (
|
||||
<div className="step-card">
|
||||
<div className="confirmation-hero">
|
||||
<div className="confirmation-check">✓</div>
|
||||
<h2>Application Submitted!</h2>
|
||||
<p className="step-description">
|
||||
Thank you{businessData.businessName ? `, ${businessData.businessName}` : ''}.
|
||||
Your application is now under review.
|
||||
We'll notify you at <strong>{user?.email}</strong> once approved.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{registrationId && (
|
||||
<div className="info-card">
|
||||
<div className="info-card-secondary">Registration ID</div>
|
||||
<div className="info-card-primary"><code>{registrationId}</code></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="next-steps">
|
||||
<h3 className="next-steps-title">What happens next?</h3>
|
||||
|
||||
<div className="next-steps-list">
|
||||
<div className="next-step-item">
|
||||
<div className="next-step-icon">🔍</div>
|
||||
<div>
|
||||
<div className="next-step-label">Application Review</div>
|
||||
<div className="next-step-desc">
|
||||
Our team reviews your information (typically 1–2 business days)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="next-step-item">
|
||||
<div className="next-step-icon">🏗️</div>
|
||||
<div>
|
||||
<div className="next-step-label">Account Creation</div>
|
||||
<div className="next-step-desc">
|
||||
Upon approval, we create your accounts on Google Ads, Meta, and TikTok
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="next-step-item">
|
||||
<div className="next-step-icon">🚀</div>
|
||||
<div>
|
||||
<div className="next-step-label">Get Started</div>
|
||||
<div className="next-step-desc">
|
||||
You'll receive login credentials and can begin creating campaigns
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="info-banner info-banner-highlight">
|
||||
<strong>Account naming convention:</strong> Your advertising accounts will be
|
||||
created as{' '}
|
||||
<code>ADP-{businessData.businessName.replace(/\s+/g, '') || 'YourBusiness'}-XXXX</code>{' '}
|
||||
across all platforms for easy identification.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
Client-Registration/src/components/steps/ContactStep.jsx
Normal file
99
Client-Registration/src/components/steps/ContactStep.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useAuth } from '../../auth/AuthProvider';
|
||||
import { useRegistration } from '../../context/RegistrationContext';
|
||||
|
||||
export default function ContactStep() {
|
||||
const { user } = useAuth();
|
||||
const { contactData, setContactData, saveContact, goBack, loading, error } = useRegistration();
|
||||
|
||||
// Pre-fill from auth claims on mount
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setContactData(prev => ({
|
||||
contactName: prev.contactName || user.displayName || '',
|
||||
contactEmail: prev.contactEmail || user.email || '',
|
||||
contactPhone: prev.contactPhone || '',
|
||||
}));
|
||||
}
|
||||
}, [user, setContactData]);
|
||||
|
||||
const handleChange = useCallback((e) => {
|
||||
const { name, value } = e.target;
|
||||
setContactData(prev => ({ ...prev, [name]: value }));
|
||||
}, [setContactData]);
|
||||
|
||||
const handleSubmit = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
saveContact();
|
||||
}, [saveContact]);
|
||||
|
||||
return (
|
||||
<div className="step-card">
|
||||
<div className="step-header">
|
||||
<span className="step-icon">👤</span>
|
||||
<h2>Contact Information</h2>
|
||||
<p className="step-description">
|
||||
How should we reach you about your advertising account?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
<div className="info-card">
|
||||
<div className="info-card-primary">{user.displayName}</div>
|
||||
<div className="info-card-secondary">{user.email}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="contactName">Full Name <span className="required">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
id="contactName"
|
||||
name="contactName"
|
||||
value={contactData.contactName}
|
||||
onChange={handleChange}
|
||||
placeholder="Jane Smith"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="contactEmail">Email <span className="required">*</span></label>
|
||||
<input
|
||||
type="email"
|
||||
id="contactEmail"
|
||||
name="contactEmail"
|
||||
value={contactData.contactEmail}
|
||||
onChange={handleChange}
|
||||
placeholder="jane@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="contactPhone">Phone</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="contactPhone"
|
||||
name="contactPhone"
|
||||
value={contactData.contactPhone}
|
||||
onChange={handleChange}
|
||||
placeholder="(555) 123-4567"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="step-actions">
|
||||
<button type="button" className="btn-secondary" onClick={goBack}>
|
||||
← Back
|
||||
</button>
|
||||
<button type="submit" className="btn-primary" disabled={loading}>
|
||||
{loading ? 'Saving...' : 'Continue →'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
Client-Registration/src/components/steps/ReviewStep.jsx
Normal file
76
Client-Registration/src/components/steps/ReviewStep.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import { useAuth } from '../../auth/AuthProvider';
|
||||
import { useRegistration } from '../../context/RegistrationContext';
|
||||
|
||||
function ReviewSection({ title, items }) {
|
||||
const filtered = items.filter(([, value]) => value);
|
||||
if (filtered.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="review-section">
|
||||
<h3 className="review-section-title">{title}</h3>
|
||||
<div className="review-section-body">
|
||||
{filtered.map(([label, value]) => (
|
||||
<div key={label} className="review-row">
|
||||
<span className="review-label">{label}</span>
|
||||
<span className="review-value">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReviewStep() {
|
||||
const { user } = useAuth();
|
||||
const { contactData, businessData, submitApplication, goBack, loading, error } = useRegistration();
|
||||
|
||||
return (
|
||||
<div className="step-card">
|
||||
<div className="step-header">
|
||||
<span className="step-icon">📋</span>
|
||||
<h2>Review Your Application</h2>
|
||||
<p className="step-description">
|
||||
Please verify all information before submitting.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<ReviewSection title="Account" items={[
|
||||
['Signed in as', user?.displayName],
|
||||
['Email', user?.email],
|
||||
['Provider', user?.provider],
|
||||
]} />
|
||||
|
||||
<ReviewSection title="Contact" items={[
|
||||
['Name', contactData.contactName],
|
||||
['Email', contactData.contactEmail],
|
||||
['Phone', contactData.contactPhone],
|
||||
]} />
|
||||
|
||||
<ReviewSection title="Business" items={[
|
||||
['Business Name', businessData.businessName],
|
||||
['Website', businessData.websiteUrl],
|
||||
['Category', businessData.businessCategory],
|
||||
['Description', businessData.businessDescription],
|
||||
]} />
|
||||
|
||||
<div className="info-banner">
|
||||
<strong>What happens next?</strong> After submission, our team will review your
|
||||
application. Upon approval, your advertising accounts will be created across
|
||||
Google Ads, Meta, and TikTok with the naming convention{' '}
|
||||
<code>ADP-{businessData.businessName.replace(/\s+/g, '') || 'YourBusiness'}-XXXX</code>.
|
||||
</div>
|
||||
|
||||
<div className="step-actions">
|
||||
<button className="btn-secondary" onClick={goBack}>
|
||||
← Back
|
||||
</button>
|
||||
<button className="btn-primary" onClick={submitApplication} disabled={loading}>
|
||||
{loading ? 'Submitting...' : 'Submit Application ✓'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
Client-Registration/src/components/steps/SignInStep.jsx
Normal file
54
Client-Registration/src/components/steps/SignInStep.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { useAuth } from '../../auth/AuthProvider';
|
||||
|
||||
export default function SignInStep() {
|
||||
const { signIn, isLoading, error } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="step-card">
|
||||
<div className="step-header">
|
||||
<span className="step-icon">🔐</span>
|
||||
<h2>Create Your Account</h2>
|
||||
<p className="step-description">
|
||||
Sign in with your preferred provider to get started with AdPlatform.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<div className="provider-list">
|
||||
<button
|
||||
className="btn-provider btn-provider-google"
|
||||
onClick={() => signIn('google')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<span className="provider-icon">G</span>
|
||||
{isLoading ? 'Connecting...' : 'Continue with Google'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-provider btn-provider-apple"
|
||||
onClick={() => signIn('apple')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<span className="provider-icon">🍎</span>
|
||||
{isLoading ? 'Connecting...' : 'Continue with Apple'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-provider btn-provider-microsoft"
|
||||
onClick={() => signIn('microsoft')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<span className="provider-icon">⊞</span>
|
||||
{isLoading ? 'Connecting...' : 'Continue with Microsoft'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="step-fine-print">
|
||||
By continuing, you agree to AdPlatform's Terms of Service and Privacy Policy.
|
||||
Your account will be managed through Microsoft Entra External ID.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
189
Client-Registration/src/context/RegistrationContext.jsx
Normal file
189
Client-Registration/src/context/RegistrationContext.jsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import { submitRegistration } from '../services/api';
|
||||
|
||||
/**
|
||||
* Registration Wizard Context
|
||||
*
|
||||
* Manages multi-step form state and orchestrates submission to the
|
||||
* Registration Function. The wizard collects data across steps then
|
||||
* submits everything in a single authenticated POST to /api/registration/register.
|
||||
*
|
||||
* entraSubjectId is NOT included in the submission payload — the server
|
||||
* extracts it from the validated Bearer token so it cannot be spoofed.
|
||||
*
|
||||
* Steps:
|
||||
* 0 — Sign In (handled by AuthProvider)
|
||||
* 1 — Contact Info (contactName, contactEmail, contactPhone)
|
||||
* 2 — Business Info (businessName, websiteUrl, businessCategory, businessDescription, clientCategory)
|
||||
* 3 — Review & Submit
|
||||
* 4 — Confirmation
|
||||
*/
|
||||
|
||||
export const STEPS = [
|
||||
{ key: 'signin', label: 'Sign In', icon: '🔐' },
|
||||
{ key: 'contact', label: 'Contact', icon: '👤' },
|
||||
{ key: 'business', label: 'Business', icon: '🏢' },
|
||||
{ key: 'review', label: 'Review', icon: '📋' },
|
||||
{ key: 'confirm', label: 'Complete', icon: '✓' },
|
||||
];
|
||||
|
||||
|
||||
// ─── Context ───────────────────────────────────────────────────────────────
|
||||
|
||||
const RegistrationContext = createContext(null);
|
||||
|
||||
export function useRegistration() {
|
||||
const ctx = useContext(RegistrationContext);
|
||||
if (!ctx) throw new Error('useRegistration must be used within <RegistrationProvider>');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
|
||||
// ─── Provider ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function RegistrationProvider({ children }) {
|
||||
const { user, getAccessToken } = useAuth();
|
||||
|
||||
// Wizard navigation
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [registrationId, setRegistrationId] = useState(null);
|
||||
|
||||
// Form data — maps to RegisterRequest model fields (minus entraSubjectId)
|
||||
const [contactData, setContactData] = useState({
|
||||
contactName: '',
|
||||
contactEmail: '',
|
||||
contactPhone: '',
|
||||
});
|
||||
|
||||
const [businessData, setBusinessData] = useState({
|
||||
businessName: '',
|
||||
websiteUrl: '',
|
||||
businessCategory: '',
|
||||
businessDescription: '',
|
||||
clientCategory: 'General', // General | Franchisee | Franchisor
|
||||
});
|
||||
|
||||
// ─── Navigation ─────────────────────────────────────────────────────
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
setCurrentStep(s => Math.min(s + 1, STEPS.length - 1));
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
setCurrentStep(s => Math.max(s - 1, 1)); // Can't go back past sign-in
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => setError(null), []);
|
||||
|
||||
// ─── Validation ─────────────────────────────────────────────────────
|
||||
|
||||
const validateContact = useCallback(() => {
|
||||
if (!contactData.contactName.trim()) return 'Contact name is required';
|
||||
if (!contactData.contactEmail.trim()) return 'Email is required';
|
||||
return null;
|
||||
}, [contactData]);
|
||||
|
||||
const validateBusiness = useCallback(() => {
|
||||
if (!businessData.businessName.trim()) return 'Business name is required';
|
||||
return null;
|
||||
}, [businessData]);
|
||||
|
||||
// ─── Step handlers ───────────────────────────────────────────────────
|
||||
|
||||
const onSignInComplete = useCallback(() => setCurrentStep(1), []);
|
||||
|
||||
const saveContact = useCallback(() => {
|
||||
const err = validateContact();
|
||||
if (err) { setError(err); return; }
|
||||
goNext();
|
||||
}, [validateContact, goNext]);
|
||||
|
||||
const saveBusiness = useCallback(() => {
|
||||
const err = validateBusiness();
|
||||
if (err) { setError(err); return; }
|
||||
goNext();
|
||||
}, [validateBusiness, goNext]);
|
||||
|
||||
// ─── Submit registration ─────────────────────────────────────────────
|
||||
|
||||
const submitApplication = useCallback(async () => {
|
||||
if (!user) { setError('Not signed in'); return; }
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Acquire a fresh token — the Function validates this and extracts
|
||||
// the user's entraSubjectId from the OID claim server-side.
|
||||
let token;
|
||||
try {
|
||||
token = await getAccessToken();
|
||||
} catch (tokenErr) {
|
||||
throw new Error(tokenErr.message || 'Could not get authentication token. Please sign in again.');
|
||||
}
|
||||
|
||||
// Build the payload — entraSubjectId is intentionally excluded;
|
||||
// the server stamps it from the validated JWT.
|
||||
const request = {
|
||||
...contactData,
|
||||
...businessData,
|
||||
};
|
||||
|
||||
// Pre-fill email from auth if the user left it blank
|
||||
if (!request.contactEmail && user.email) {
|
||||
request.contactEmail = user.email;
|
||||
}
|
||||
|
||||
console.log('[Registration] Submitting for entra user:', user.entraSubjectId);
|
||||
const result = await submitRegistration(request, token);
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error || 'Registration failed');
|
||||
}
|
||||
|
||||
setRegistrationId(result.registrationId);
|
||||
setCurrentStep(4); // Confirmation step
|
||||
} catch (err) {
|
||||
console.error('[Registration] Submit error:', err);
|
||||
setError(err.message || 'Submission failed. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [user, getAccessToken, contactData, businessData]);
|
||||
|
||||
|
||||
// ─── Context value ───────────────────────────────────────────────────
|
||||
|
||||
const value = useMemo(() => ({
|
||||
currentStep,
|
||||
loading,
|
||||
error,
|
||||
clearError,
|
||||
contactData,
|
||||
setContactData,
|
||||
businessData,
|
||||
setBusinessData,
|
||||
onSignInComplete,
|
||||
saveContact,
|
||||
saveBusiness,
|
||||
submitApplication,
|
||||
goBack,
|
||||
registrationId,
|
||||
}), [
|
||||
currentStep, loading, error, clearError,
|
||||
contactData, businessData,
|
||||
onSignInComplete, saveContact, saveBusiness, submitApplication, goBack,
|
||||
registrationId,
|
||||
]);
|
||||
|
||||
return (
|
||||
<RegistrationContext.Provider value={value}>
|
||||
{children}
|
||||
</RegistrationContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
import { AuthProvider } from './auth/AuthProvider';
|
||||
import App from './app/App';
|
||||
import './styles/app.css';
|
||||
|
||||
createRoot(document.getElementById('root')).render(<App />);
|
||||
const root = createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
100
Client-Registration/src/services/api.js
Normal file
100
Client-Registration/src/services/api.js
Normal file
@@ -0,0 +1,100 @@
|
||||
import { API_BASE_URL, API_FUNCTION_KEY } from '../auth/authConfig';
|
||||
|
||||
/**
|
||||
* Registration API Service
|
||||
*
|
||||
* Calls the Registration Azure Function endpoints.
|
||||
* Matches the contract in RegistrationFunctions.cs:
|
||||
*
|
||||
* POST /api/registration/register ← requires Bearer token (CIAM JWT)
|
||||
* GET /api/registration/pending ← admin; Function key only
|
||||
* GET /api/registration/item/{id} ← admin; Function key only
|
||||
* POST /api/registration/action/{id}/complete ← admin; Function key only
|
||||
* POST /api/registration/action/{id}/reject ← admin; Function key only
|
||||
*
|
||||
* Note: entraSubjectId is NOT sent by the client — the server extracts it
|
||||
* from the validated JWT token so it cannot be spoofed.
|
||||
*/
|
||||
|
||||
const USE_MOCK = !API_BASE_URL;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function apiFetch(path, method = 'GET', body = null, token = null) {
|
||||
const url = `${API_BASE_URL}${path}`;
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
|
||||
// Bearer token — used on the Register endpoint so the Function can
|
||||
// validate the caller's identity via the CIAM JWT.
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Function key — used on admin endpoints called by Management API.
|
||||
// Not required for the public Register endpoint.
|
||||
if (API_FUNCTION_KEY) {
|
||||
headers['x-functions-key'] = API_FUNCTION_KEY;
|
||||
}
|
||||
|
||||
const opts = { method, headers };
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, opts);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
console.error(`[API] ${method} ${path} → ${res.status}:`, text.substring(0, 200));
|
||||
return { ok: false, error: `HTTP ${res.status}: ${text.substring(0, 100)}` };
|
||||
}
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
console.error(`[API] Network error on ${path}:`, err.message);
|
||||
return { ok: false, error: 'Network error: ' + err.message };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ─── Mock Implementation ───────────────────────────────────────────────────
|
||||
|
||||
const mockDelay = (ms = 500) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
const MockApi = {
|
||||
async register(request) {
|
||||
await mockDelay(800);
|
||||
console.log('[Mock] register:', request);
|
||||
return {
|
||||
ok: true,
|
||||
registrationId: 'reg-' + crypto.randomUUID().slice(0, 8),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// ─── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Submit a new registration.
|
||||
* Maps to POST /api/registration/register
|
||||
*
|
||||
* @param {object} data — matches RegisterRequest model (without entraSubjectId —
|
||||
* the server extracts that from the validated Bearer token)
|
||||
* @param {string} token — CIAM ID token from MSAL (required in production)
|
||||
*
|
||||
* @returns {{ ok, registrationId?, error? }}
|
||||
*/
|
||||
export async function submitRegistration(data, token) {
|
||||
if (USE_MOCK) return MockApi.register(data);
|
||||
return apiFetch('/api/registration/register', 'POST', data, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check registration status by ID (for returning users / admin use).
|
||||
* Maps to GET /api/registration/item/{id}
|
||||
*/
|
||||
export async function getRegistrationStatus(registrationId) {
|
||||
if (USE_MOCK) {
|
||||
await mockDelay(300);
|
||||
return { ok: true, applicant: null };
|
||||
}
|
||||
return apiFetch(`/api/registration/item/${registrationId}`);
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
/* ─── 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; }
|
||||
}
|
||||
689
Client-Registration/src/styles/app.css
Normal file
689
Client-Registration/src/styles/app.css
Normal file
@@ -0,0 +1,689 @@
|
||||
/* ============================================================
|
||||
AdPlatform Registration Portal Styles
|
||||
============================================================ */
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #f4f5f7;
|
||||
--surface: #ffffff;
|
||||
--card-bg: #ffffff;
|
||||
--border: rgba(0,0,0,0.09);
|
||||
--border-light: rgba(0,0,0,0.05);
|
||||
--text: #1a1d23;
|
||||
--text-secondary: #5f6672;
|
||||
--text-dim: #9099a4;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--accent-light: rgba(59,130,246,0.08);
|
||||
--danger: #dc2626;
|
||||
--danger-bg: #fef2f2;
|
||||
--success: #16a34a;
|
||||
--success-bg: #dcfce7;
|
||||
--warning-bg: #fef3cd;
|
||||
--warning-border: #ffc107;
|
||||
--warning-text: #856404;
|
||||
--header-height: 56px;
|
||||
--card-radius: 12px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Shell Layout
|
||||
============================================================ */
|
||||
|
||||
.shell { min-height: 100vh; display: flex; flex-direction: column; }
|
||||
|
||||
.shell-header {
|
||||
background: #1a1a2e;
|
||||
color: #fff;
|
||||
padding: 0 24px;
|
||||
height: var(--header-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.shell-logo { display: flex; align-items: center; gap: 8px; }
|
||||
.logo-icon { font-size: 24px; color: var(--accent); }
|
||||
.logo-text { font-size: 18px; font-weight: 600; }
|
||||
.logo-badge {
|
||||
font-size: 11px;
|
||||
background: var(--accent);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.shell-user { display: flex; align-items: center; gap: 16px; }
|
||||
.user-name { font-size: 13px; opacity: 0.9; }
|
||||
|
||||
.btn-signout {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255,255,255,0.3);
|
||||
color: #fff;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-signout:hover { background: rgba(255,255,255,0.1); }
|
||||
|
||||
.shell-content {
|
||||
flex: 1;
|
||||
padding: 32px 24px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.shell-footer {
|
||||
background: #1a1a2e;
|
||||
color: rgba(255,255,255,0.5);
|
||||
padding: 12px 24px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Loading
|
||||
============================================================ */
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 0;
|
||||
color: var(--text-dim);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Wizard Container
|
||||
============================================================ */
|
||||
|
||||
.wizard-container {
|
||||
width: 100%;
|
||||
max-width: 580px;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Progress Stepper
|
||||
============================================================ */
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
margin-bottom: 28px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.stepper-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 56px;
|
||||
}
|
||||
|
||||
.stepper-circle {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
background: #e5e7eb;
|
||||
color: var(--text-dim);
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.stepper-active .stepper-circle {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stepper-complete .stepper-circle {
|
||||
background: var(--success);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stepper-label {
|
||||
font-size: 11px;
|
||||
margin-top: 4px;
|
||||
color: var(--text-dim);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.stepper-active .stepper-label {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stepper-complete .stepper-label {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.stepper-line {
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
background: #e5e7eb;
|
||||
margin: 15px 8px 0;
|
||||
transition: background 0.25s ease;
|
||||
}
|
||||
|
||||
.stepper-line-complete {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Step Card
|
||||
============================================================ */
|
||||
|
||||
.step-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--card-radius);
|
||||
padding: 32px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.03);
|
||||
}
|
||||
|
||||
.step-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.step-icon {
|
||||
font-size: 32px;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.step-header h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.step-description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.step-fine-print {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.6;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Sign-In Providers
|
||||
============================================================ */
|
||||
|
||||
.provider-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-width: 360px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.btn-provider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-provider:hover { border-color: var(--accent); background: var(--accent-light); }
|
||||
.btn-provider:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.btn-provider-google { background: #fff; border-color: #d1d5db; }
|
||||
.btn-provider-apple { background: #000; color: #fff; border-color: #000; }
|
||||
.btn-provider-apple:hover { background: #1a1a1a; border-color: #1a1a1a; }
|
||||
.btn-provider-microsoft { background: #0078d4; color: #fff; border-color: #0078d4; }
|
||||
.btn-provider-microsoft:hover { background: #006abc; border-color: #006abc; }
|
||||
|
||||
.provider-icon {
|
||||
font-size: 18px;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Forms
|
||||
============================================================ */
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.required { color: var(--danger); }
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
transition: border-color 0.15s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-light);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.form-group select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-help {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Buttons
|
||||
============================================================ */
|
||||
|
||||
.step-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 10px 24px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover { background: var(--accent-hover); }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.btn-secondary {
|
||||
padding: 10px 24px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-secondary:hover { background: var(--bg); }
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Info Cards & Banners
|
||||
============================================================ */
|
||||
|
||||
.info-card {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.info-card-primary {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-card-secondary {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.info-banner {
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
color: #1e40af;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.info-banner code {
|
||||
background: #dbeafe;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.info-banner-highlight {
|
||||
background: #fefce8;
|
||||
border-color: #fde68a;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.info-banner-highlight code {
|
||||
background: #fef3c7;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Error
|
||||
============================================================ */
|
||||
|
||||
.error-message {
|
||||
background: var(--danger-bg);
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
padding: 10px 16px;
|
||||
color: #991b1b;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Review Step
|
||||
============================================================ */
|
||||
|
||||
.review-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.review-section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.review-section-body {
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.review-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.review-row:last-child { border-bottom: none; }
|
||||
|
||||
.review-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.review-value {
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
max-width: 60%;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Confirmation Step
|
||||
============================================================ */
|
||||
|
||||
.confirmation-hero {
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.confirmation-check {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--success), #059669);
|
||||
color: #fff;
|
||||
font-size: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
.confirmation-hero h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
|
||||
/* ── Next Steps ── */
|
||||
|
||||
.next-steps {
|
||||
background: var(--bg);
|
||||
border-radius: var(--card-radius);
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.next-steps-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.next-steps-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.next-step-item {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.next-step-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.next-step-label {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.next-step-desc {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Responsive
|
||||
============================================================ */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.shell-content { padding: 20px 12px; }
|
||||
.step-card { padding: 24px 20px; }
|
||||
.stepper-label { font-size: 10px; }
|
||||
.stepper-item { min-width: 44px; }
|
||||
.review-row { flex-direction: column; gap: 2px; }
|
||||
.review-value { text-align: left; max-width: 100%; }
|
||||
}
|
||||
|
||||
/* ── Account Type Selector ─────────────────────────────────────────────── */
|
||||
|
||||
.account-type-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.account-type-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
background: var(--surface);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.account-type-option:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.account-type-option.selected {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
.account-type-icon {
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.account-type-text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.account-type-label {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.account-type-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.account-type-check {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
background: transparent;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.account-type-option.selected .account-type-check {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
Reference in New Issue
Block a user