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
565 lines
22 KiB
JavaScript
565 lines
22 KiB
JavaScript
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: '18–24', AGE_25_34: '25–34', AGE_35_44: '35–44',
|
||
AGE_45_54: '45–54', AGE_55_64: '55–64', 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 & Launch
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|