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

This commit is contained in:
Grae Jones
2026-03-21 17:54:42 -07:00
parent 3647b304a3
commit fdb3e117a9
203 changed files with 35733 additions and 18189 deletions

View File

@@ -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>
);
}

View 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>
);
}

View 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>;
}

View 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 = '';

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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 12 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);

View 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}`);
}

View File

@@ -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; }
}

View 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);
}