Files
AdPlatform-Client/Client-Client/src/components/wizard/steps/ReviewStep.jsx
Grae Jones fdb3e117a9
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
First build
2026-03-21 17:54:42 -07:00

565 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}