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

@@ -0,0 +1,564 @@
import React, { useState, memo } from 'react';
import { useAuth } from '../../../auth/AuthProvider';
import { useWizardConfig } from '../../../context/WizardConfigContext';
import { stageInitiative, launchInitiative } from '../../../services/initiativeApi';
import HelpIcon from '../../../components/HelpIcon';
const statusIcons = {
submitted: '✓', pending_review: '⏳', error: '✗', pending: '…',
};
const statusLabels = {
submitted: 'Submitted', pending_review: 'Queued for Review',
error: 'Failed', pending: 'Pending',
};
const ageLabels = {
AGE_18_24: '1824', AGE_25_34: '2534', AGE_35_44: '3544',
AGE_45_54: '4554', AGE_55_64: '5564', AGE_65_UP: '65+',
};
const genderLabels = { MALE: 'Male', FEMALE: 'Female', UNDETERMINED: 'All Genders' };
const scopeLabels = { local: 'Local', regional: 'Regional', national: 'National' };
const skewLabels = { young: 'Younger', mature: 'Mature' };
function SectionCard({ title, icon, children, onEdit, issues, onFix }) {
const hasIssues = issues && issues.length > 0;
return (
<div className={`review-section ${hasIssues ? 'has-issues' : ''}`}>
<div className="review-section-header">
<div className="review-section-title">
<span className="review-icon">{icon}</span>
<h3>{title}</h3>
</div>
<div className="review-section-actions">
{hasIssues && (
<button className="btn btn-sm btn-fix" onClick={onFix || onEdit}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Fix
</button>
)}
{onEdit && (
<button className="btn btn-sm btn-outline" onClick={onEdit}>Edit</button>
)}
</div>
</div>
{hasIssues && (
<div className="review-section-issues">
{issues.map((msg, idx) => (
<span key={idx} className="review-issue-tag">{msg}</span>
))}
</div>
)}
<div className="review-section-body">{children}</div>
</div>
);
}
function ReviewRow({ label, value, mono }) {
return (
<div className="review-row">
<span className="review-label">{label}</span>
<span className={`review-value ${mono ? 'mono' : ''}`}>{value || '—'}</span>
</div>
);
}
const ReviewStep = memo(function ReviewStep({ stepData, onGoToStep }) {
const { sessionToken } = useAuth();
const { toDisplayLabel, getChannelLabel, getChannelColor } = useWizardConfig();
const [phase, setPhase] = useState('review');
const [error, setError] = useState(null);
const [initiativeId, setInitiativeId] = useState(null);
const [billing, setBilling] = useState(null);
const [launchResults, setLaunchResults] = useState(null);
const [showConfirm, setShowConfirm] = useState(false);
const [termsAccepted, setTermsAccepted] = useState(false);
// ── Step data: 1=Objective, 2=Audience, 3=Budget, 4=Channels+Allocation, 5=Creative ──
const objective = stepData?.[1] || {};
const audience = stepData?.[2] || {};
const budget = stepData?.[3] || {};
const channelsAlloc = stepData?.[4] || {};
const creative = stepData?.[5] || {};
const selectedChannels = channelsAlloc.selectedChannels || [];
const isMultiChannel = selectedChannels.length > 1;
const totalBudget = parseFloat(budget.totalBudget) || 0;
const allocations = channelsAlloc.allocations || {};
const objectiveLabel = toDisplayLabel(objective.objective);
const categoryLabel = toDisplayLabel(objective.businessCategory);
const formatBudget = (val) => {
const n = parseFloat(val);
return isNaN(n) ? '—' : `$${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
// Validation
const issues = [];
if (!objective.campaignName) issues.push({ step: 1, msg: 'Campaign name is required' });
if (!objective.objective) issues.push({ step: 1, msg: 'Objective is required' });
if (selectedChannels.length === 0) issues.push({ step: 4, msg: 'Select at least one channel' });
if (!totalBudget || totalBudget <= 0) issues.push({ step: 3, msg: 'Budget must be set' });
if (isMultiChannel) {
const totalPct = Object.values(allocations).reduce((s, v) => s + (v || 0), 0);
if (Math.abs(totalPct - 100) > 0.01) issues.push({ step: 4, msg: 'Channel allocations must total 100%' });
}
// ── Stage ──
const handleStage = async () => {
if (issues.length > 0) return;
setError(null);
setPhase('staging');
const channelPayloads = selectedChannels.map(ch => ({
channelType: ch,
allocationPct: isMultiChannel ? (allocations[ch] || 0) : 100,
}));
const payload = {
name: objective.campaignName,
objective: objective.objective,
totalBudget: totalBudget,
budgetPeriod: budget.budgetPeriod || 'monthly',
startDate: budget.startDate,
endDate: budget.hasEndDate ? budget.endDate : null,
allocationStrategy: channelsAlloc.allocationStrategy || 'manual',
businessCategory: objective.businessCategory,
channels: channelPayloads,
// Audience factors
ageSkew: audience.ageSkew || null,
marketScope: audience.marketScope || null,
locations: audience.locations || [],
demographics: audience.demographics || {},
...(initiativeId ? { initiativeId } : {}),
};
const stageRes = await stageInitiative(payload, sessionToken);
if (!stageRes.ok) {
setPhase('error');
setError(stageRes.error || stageRes.data?.error || 'Failed to stage initiative');
return;
}
const newId = stageRes.data?.initiativeId;
if (!newId) {
setPhase('error');
setError('Initiative staged but no ID returned');
return;
}
setInitiativeId(newId);
setBilling(stageRes.data?.billing || null);
setTermsAccepted(false);
setShowConfirm(true);
setPhase('confirm');
};
// ── Launch ──
const handleConfirmLaunch = async () => {
setShowConfirm(false);
setError(null);
setPhase('launching');
const launchRes = await launchInitiative(initiativeId, sessionToken);
if (launchRes.ok || launchRes.data?.channels) {
setLaunchResults(launchRes.data);
setPhase('done');
} else {
setLaunchResults(launchRes.data);
setPhase('done');
if (!launchRes.data?.channels?.length) {
setError(launchRes.error || launchRes.data?.error || 'Launch failed — campaigns saved but not dispatched');
}
}
};
const handleCancelConfirm = () => {
setShowConfirm(false);
setPhase('review');
};
// ── Success / Results Screen ──
if (phase === 'done') {
const channelResults = launchResults?.channels || [];
const successCount = channelResults.filter(c => c.status === 'submitted' || c.status === 'pending_review').length;
const failCount = channelResults.filter(c => c.status === 'error').length;
return (
<div className="wizard-step-content">
<div className="submit-success">
<div className="success-icon">
{failCount === 0 ? (
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#10B981" strokeWidth="2">
<path d="M22 11.08V12a10 10 0 11-5.93-9.14" />
<path d="M22 4L12 14.01l-3-3" />
</svg>
) : successCount > 0 ? (
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#F59E0B" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
) : (
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#EF4444" 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>
{failCount === 0
? `Campaign ${isMultiChannel ? 'Initiative ' : ''}Launched!`
: successCount > 0
? 'Partially Launched'
: 'Launch Failed'}
</h2>
<p>
{failCount === 0
? `Your campaign "${objective.campaignName}" has been submitted${isMultiChannel ? ` across ${selectedChannels.length} channels` : ''}.`
: successCount > 0
? `${successCount} of ${channelResults.length} channels submitted successfully.`
: 'All channel dispatches failed. Your campaign has been saved — you can retry from the Campaigns view.'}
</p>
{channelResults.length > 0 && (
<div className="launch-results">
{channelResults.map((ch, idx) => (
<div key={idx} className={`launch-result-row ${ch.status}`}>
<span className="launch-ch-dot" style={{ background: getChannelColor(ch.channelType) }} />
<span className="launch-ch-name">{getChannelLabel(ch.channelType)}</span>
<span className={`launch-status ${ch.status}`}>
{statusIcons[ch.status] || '?'} {statusLabels[ch.status] || ch.status}
</span>
{ch.error && <span className="launch-error">{ch.error}</span>}
</div>
))}
</div>
)}
</div>
</div>
);
}
// ── Loading states ──
if (phase === 'staging') {
return (
<div className="wizard-step-content">
<div className="phase-loading">
<div className="loading-spinner" />
<h3>Preparing your campaign</h3>
<p>Calculating fees and validating configuration</p>
</div>
</div>
);
}
if (phase === 'launching') {
return (
<div className="wizard-step-content">
<div className="phase-loading">
<div className="loading-spinner" />
<h3>Launching campaign</h3>
<p>Submitting to {isMultiChannel ? `${selectedChannels.length} ad networks` : 'ad network'}</p>
</div>
</div>
);
}
// ── Audience summary helpers ──
const locationSummary = audience.locations?.length > 0
? audience.locations.map(l => l.name).join(', ')
: 'All regions (national)';
const ageSummary = audience.demographics?.ageRanges?.length > 0
? audience.demographics.ageRanges.map(a => ageLabels[a] || a).join(', ')
: 'All ages';
const genderSummary = audience.demographics?.genders?.length > 0
? audience.demographics.genders.map(g => genderLabels[g] || g).join(', ')
: 'All genders';
// Group issues by step for inline display
const issuesByStep = {};
issues.forEach(issue => {
if (!issuesByStep[issue.step]) issuesByStep[issue.step] = [];
issuesByStep[issue.step].push(issue.msg);
});
// ── Review Layout ──
return (
<div className="wizard-step-content">
<div className="step-intro">
<div className="step-intro-header">
<h2>Review your campaign</h2>
<HelpIcon helpKey="client.wizard.review" label="About this step" />
</div>
<p>Double-check everything below before launching.</p>
</div>
{error && <div className="error-box">{error}</div>}
{/* Campaign Summary - Compact Two-Column */}
<div className="review-grid">
{/* Left Column: Campaign + Audience */}
<div className="review-grid-col">
<SectionCard
title="Campaign" icon="🎯"
onEdit={() => onGoToStep(1)}
issues={issuesByStep[1]}
onFix={() => onGoToStep(1)}
>
<ReviewRow label="Name" value={objective.campaignName} />
<ReviewRow label="Objective" value={objectiveLabel} />
<ReviewRow label="Category" value={categoryLabel} />
</SectionCard>
<SectionCard title="Audience" icon="👥" onEdit={() => onGoToStep(2)}>
<ReviewRow label="Locations" value={locationSummary} />
<ReviewRow label="Age" value={ageSummary} />
<ReviewRow label="Gender" value={genderSummary} />
<div className="review-factors" style={{ marginTop: 8 }}>
<span className="review-factor-chip">
{scopeLabels[audience.marketScope] || 'National'}
</span>
<span className="review-factor-chip">
{audience.ageSkew ? skewLabels[audience.ageSkew] : 'Balanced'}
</span>
</div>
</SectionCard>
</div>
{/* Right Column: Budget + Channels + Creative */}
<div className="review-grid-col">
<SectionCard
title="Budget" icon="💰"
onEdit={() => onGoToStep(3)}
issues={issuesByStep[3]}
onFix={() => onGoToStep(3)}
>
<ReviewRow
label={budget.budgetPeriod === 'daily' ? 'Daily' : 'Monthly'}
value={formatBudget(totalBudget)}
/>
<ReviewRow label="Start" value={budget.startDate} />
{budget.hasEndDate && <ReviewRow label="End" value={budget.endDate} />}
</SectionCard>
<SectionCard
title="Channels" icon="📡"
onEdit={() => onGoToStep(4)}
issues={issuesByStep[4]}
onFix={() => onGoToStep(4)}
>
<div className="review-channels">
{selectedChannels.length > 0 ? selectedChannels.map(ch => (
<span key={ch} className="review-channel-chip" style={{ borderColor: getChannelColor(ch) }}>
<span className="review-ch-dot" style={{ background: getChannelColor(ch) }} />
{getChannelLabel(ch)}
{isMultiChannel && <span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--color-text-muted)' }}>{allocations[ch] || 0}%</span>}
</span>
)) : (
<div className="review-empty-note">No channels selected</div>
)}
</div>
</SectionCard>
<SectionCard title="Creative" icon="🖼️" onEdit={() => onGoToStep(5)}>
{(creative.headlines?.length > 0 || creative.descriptions?.length > 0) ? (
<div className="review-creative-summary">
<span>{creative.headlines?.length || 0} headline{(creative.headlines?.length || 0) !== 1 ? 's' : ''}</span>
<span className="review-dot-sep">·</span>
<span>{creative.descriptions?.length || 0} description{(creative.descriptions?.length || 0) !== 1 ? 's' : ''}</span>
<span className="review-dot-sep">·</span>
<span>{creative.images?.length || 0} image{(creative.images?.length || 0) !== 1 ? 's' : ''}</span>
</div>
) : (
<div className="review-empty-note">No creative assets added yet</div>
)}
</SectionCard>
</div>
</div>
{/* Submit */}
<div className="submit-bar">
<div className="submit-bar-info">
<strong>{issues.length > 0 ? 'Almost there' : 'Ready to launch?'}</strong>
<span>
{issues.length > 0
? `Fix ${issues.length} issue${issues.length > 1 ? 's' : ''} above to enable launch.`
: isMultiChannel
? `Your campaign will be submitted to ${selectedChannels.map(ch => getChannelLabel(ch)).join(' and ')} for review.`
: `Your campaign will be submitted to ${getChannelLabel(selectedChannels[0])} for review.`}
</span>
</div>
<button
className="btn btn-primary btn-lg"
onClick={handleStage}
disabled={issues.length > 0}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" />
</svg>
{' '}Launch Campaign
</button>
</div>
{/* Confirmation Modal */}
{showConfirm && billing && (
<ConfirmLaunchModal
campaignName={objective.campaignName}
objectiveLabel={objectiveLabel}
billing={billing}
selectedChannels={selectedChannels}
isMultiChannel={isMultiChannel}
termsAccepted={termsAccepted}
onToggleTerms={() => setTermsAccepted(!termsAccepted)}
onConfirm={handleConfirmLaunch}
onCancel={handleCancelConfirm}
formatBudget={formatBudget}
getChannelLabel={getChannelLabel}
getChannelColor={getChannelColor}
/>
)}
</div>
);
});
export default ReviewStep;
// ────────────────────────────────────────────────
// Confirmation Modal
// ────────────────────────────────────────────────
function ConfirmLaunchModal({
campaignName, objectiveLabel, billing,
selectedChannels, isMultiChannel,
termsAccepted, onToggleTerms, onConfirm, onCancel, formatBudget,
getChannelLabel, getChannelColor,
}) {
const periodLabel = billing.budgetPeriod === 'daily' ? 'day' : 'month';
const marginPct = ((billing.marginRate || 0) * 100).toFixed(0);
const channelBilling = billing.channels || [];
return (
<div className="confirm-overlay" onClick={onCancel}>
<div className="confirm-modal" onClick={e => e.stopPropagation()}>
<div className="confirm-header">
<div className="confirm-icon-wrap">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#d97706" strokeWidth="2">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</div>
<h2>Confirm Campaign Launch</h2>
</div>
<p className="confirm-subtitle">
You are about to launch <strong>{campaignName}</strong>. By proceeding,
you authorize AdPlatform to charge your payment method on file for the
advertising spend and management fees described below.
</p>
<div className="confirm-billing">
<div className="confirm-billing-row">
<span>Campaign objective</span>
<span>{objectiveLabel}</span>
</div>
<div className="confirm-billing-row">
<span>Ad spend</span>
<span>{formatBudget(billing.adSpend)} / {periodLabel}</span>
</div>
{isMultiChannel && channelBilling.map((ch, idx) => (
<div key={idx} className="confirm-billing-row confirm-billing-sub">
<span>
<span className="confirm-ch-dot" style={{ background: getChannelColor(ch.channelType) }} />
{getChannelLabel(ch.channelType)} ({ch.allocationPct}%)
</span>
<span>{formatBudget(ch.adSpend)} / {periodLabel}</span>
</div>
))}
<div className="confirm-billing-row">
<span>Management fee ({marginPct}%)</span>
<span>{formatBudget(billing.managementFee)} / {periodLabel}</span>
</div>
{isMultiChannel && channelBilling.map((ch, idx) => (
<div key={`fee-${idx}`} className="confirm-billing-row confirm-billing-sub">
<span>
<span className="confirm-ch-dot" style={{ background: getChannelColor(ch.channelType) }} />
{getChannelLabel(ch.channelType)}
</span>
<span>{formatBudget(ch.managementFee)} / {periodLabel}</span>
</div>
))}
{billing.platformFee > 0 && (
<div className="confirm-billing-row">
<span>Platform fee</span>
<span>{formatBudget(billing.platformFee)} / {periodLabel}</span>
</div>
)}
<div className="confirm-billing-row confirm-billing-total">
<span>Total recurring charge</span>
<span className="confirm-amount">{formatBudget(billing.totalCharge)} / {periodLabel}</span>
</div>
</div>
<div className="confirm-note">
Ad spend will be billed to your payment method on file. Actual ad network
charges may vary based on delivery and auction dynamics. Management fees
are calculated at {marginPct}% of ad spend
{billing.minManagementFee > 0 && ` (${formatBudget(billing.minManagementFee)}/mo minimum)`}.
You can pause or cancel campaigns at any time from the Campaigns dashboard.
{billing.pricingSource === 'client' && (
<span className="confirm-pricing-source" title="This client has a negotiated rate">
{' '} Custom rate applied
</span>
)}
</div>
<label className="confirm-terms">
<input
type="checkbox"
checked={termsAccepted}
onChange={onToggleTerms}
/>
<span>
I authorize AdPlatform to charge up to <strong>{formatBudget(billing.totalCharge)} / {periodLabel}</strong> to
my payment method on file. I understand that ad networks may take up
to 48 hours to review and approve my campaigns, and that actual
delivery and charges may vary.
</span>
</label>
<div className="confirm-actions">
<button className="btn btn-outline" onClick={onCancel}>
Cancel
</button>
<button
className="btn btn-primary btn-lg"
onClick={onConfirm}
disabled={!termsAccepted}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
{' '}Confirm &amp; Launch
</button>
</div>
</div>
</div>
);
}