40346-vm/frontend/src/pages/growth-tools.tsx
2026-06-30 00:50:52 +00:00

678 lines
29 KiB
TypeScript

import {
mdiCreditCardOutline,
mdiOpenInNew,
mdiRefresh,
mdiSend,
mdiStarCircleOutline,
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import FormField from '../components/FormField';
import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import LayoutAuthenticated from '../layouts/Authenticated';
import { getPageTitle } from '../config';
import { aiResponse } from '../stores/openAiSlice';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
type BusinessType = 'local' | 'online' | 'hybrid';
type ReviewBusiness = {
id: string;
name?: string;
business_type?: BusinessType;
review_destination?: string;
delay_days?: number;
automation_mode?: string;
followup_enabled?: boolean;
followup_delay_days?: number;
max_followups?: number;
ai_reply_enabled?: boolean;
referral_enabled?: boolean;
referral_offer?: string;
nps_enabled?: boolean;
nps_question?: string;
social_widget_enabled?: boolean;
broadcast_enabled?: boolean;
rebooking_enabled?: boolean;
competitor_insights_enabled?: boolean;
competitor_urls?: string;
review_widget_theme?: string;
brand_logo_url?: string;
brand_primary_color?: string;
email_sender_name?: string;
email_reply_to?: string;
email_footer_text?: string;
email_subject_template?: string;
email_body_template?: string;
sms_template?: string;
};
type SummaryResponse = {
stats: {
pending: number;
sent: number;
clicked: number;
reviewed: number;
customers: number;
transactions: number;
paymentEvents: number;
};
businesses?: ReviewBusiness[];
primaryBusiness?: ReviewBusiness | null;
};
type SubscriptionStatusResponse = {
subscription: {
planId: string;
planName: string;
effectiveStatus: string;
isActive: boolean;
};
};
type WidgetResponse = {
embedCode?: string;
business?: { name?: string };
reviews?: Array<{
id: string;
rating?: number;
title?: string;
content?: string;
reviewer?: string;
source?: string;
}>;
};
type CompetitorInsightsResponse = {
competitors: string[];
metrics: {
reviewed: number;
pending: number;
sent: number;
customers: number;
};
recommendations: string[];
};
const businessTypeOptions: Array<{ key: BusinessType; label: string; help: string }> = [
{
key: 'local',
label: 'Local / service business',
help: 'Use this for businesses that collect local profile reviews such as Google, Facebook, Yelp, Angi, or OpenTable.',
},
{
key: 'online',
label: 'Online / ecommerce business',
help: 'Use this for stores and online brands that collect product or ecommerce reviews.',
},
{
key: 'hybrid',
label: 'Hybrid business',
help: 'Use this when the same business needs both local and online review workflows.',
},
];
const reviewDestinationOptions = [
{ key: 'google', label: 'Google', scope: 'local' },
{ key: 'facebook', label: 'Facebook', scope: 'local' },
{ key: 'yelp', label: 'Yelp', scope: 'local' },
{ key: 'angi', label: 'Angi', scope: 'local' },
{ key: 'opentable', label: 'OpenTable', scope: 'local' },
{ key: 'shopify_hosted', label: 'Shopify hosted product review', scope: 'online' },
{ key: 'trustpilot', label: 'Trustpilot', scope: 'online' },
{ key: 'custom', label: 'Custom review page', scope: 'hybrid' },
];
const defaultSettings = {
businessName: 'Review Flow Business',
businessType: 'hybrid' as BusinessType,
reviewDestination: 'google',
delayDays: '7',
followupEnabled: true,
followupDelayDays: '3',
maxFollowups: '1',
aiReplyEnabled: false,
referralEnabled: false,
referralOffer: 'Give $25, get $25 when a referred customer completes their first purchase.',
npsEnabled: false,
npsQuestion: 'How likely are you to recommend us to a friend?',
socialWidgetEnabled: true,
broadcastEnabled: false,
rebookingEnabled: false,
competitorInsightsEnabled: false,
competitorUrls: '',
reviewWidgetTheme: 'light',
brandLogoUrl: '',
brandPrimaryColor: '#4f46e5',
emailSenderName: '',
emailReplyTo: '',
emailFooterText: 'Sent by Review Flow for {businessName}.',
emailSubjectTemplate: 'How was your experience with {businessName}?',
emailBodyTemplate: [
'Hi {customerName},',
'',
'Thank you for choosing {businessName}. We would love to hear about your experience.',
'',
'Leave a review: {reviewLink}',
'',
'Thank you,',
'{businessName}',
].join('\n'),
smsTemplate: 'Thanks for choosing {businessName}. Please leave a review: {reviewLink}',
};
const defaultCampaign = {
campaignType: 'broadcast',
subject: 'Quick update from our team',
message: 'Thanks for being a customer. We appreciate your support and wanted to share a quick update.',
};
function normalizeBusinessType(value?: string): BusinessType {
if (value === 'local' || value === 'online' || value === 'hybrid') return value;
return 'hybrid';
}
function destinationAllowedForBusinessType(businessType: BusinessType, destination: typeof reviewDestinationOptions[number]) {
if (businessType === 'hybrid' || destination.scope === 'hybrid') return true;
return destination.scope === businessType;
}
function getDestinationsForBusinessType(businessType: BusinessType) {
return reviewDestinationOptions.filter((destination) =>
destinationAllowedForBusinessType(businessType, destination),
);
}
function getDefaultDestination(businessType: BusinessType) {
return businessType === 'online' ? 'shopify_hosted' : 'google';
}
function coerceDestination(businessType: BusinessType, destination?: string) {
const destinations = getDestinationsForBusinessType(businessType);
return destinations.some((option) => option.key === destination)
? destination || destinations[0].key
: getDefaultDestination(businessType);
}
function businessToSettings(business?: ReviewBusiness | null) {
if (!business) return defaultSettings;
const businessType = normalizeBusinessType(business.business_type);
return {
businessName: business.name || defaultSettings.businessName,
businessType,
reviewDestination: coerceDestination(businessType, business.review_destination),
delayDays: String(business.delay_days ?? 7),
followupEnabled: business.followup_enabled !== false,
followupDelayDays: String(business.followup_delay_days ?? 3),
maxFollowups: String(business.max_followups ?? 1),
aiReplyEnabled: Boolean(business.ai_reply_enabled),
referralEnabled: Boolean(business.referral_enabled),
referralOffer: business.referral_offer || defaultSettings.referralOffer,
npsEnabled: Boolean(business.nps_enabled),
npsQuestion: business.nps_question || defaultSettings.npsQuestion,
socialWidgetEnabled: business.social_widget_enabled !== false,
broadcastEnabled: Boolean(business.broadcast_enabled),
rebookingEnabled: Boolean(business.rebooking_enabled),
competitorInsightsEnabled: Boolean(business.competitor_insights_enabled),
competitorUrls: business.competitor_urls || '',
reviewWidgetTheme: business.review_widget_theme || 'light',
brandLogoUrl: business.brand_logo_url || defaultSettings.brandLogoUrl,
brandPrimaryColor: business.brand_primary_color || defaultSettings.brandPrimaryColor,
emailSenderName: business.email_sender_name || defaultSettings.emailSenderName,
emailReplyTo: business.email_reply_to || defaultSettings.emailReplyTo,
emailFooterText: business.email_footer_text || defaultSettings.emailFooterText,
emailSubjectTemplate: business.email_subject_template || defaultSettings.emailSubjectTemplate,
emailBodyTemplate: business.email_body_template || defaultSettings.emailBodyTemplate,
smsTemplate: business.sms_template || defaultSettings.smsTemplate,
};
}
function extractAiResponseText(response: any) {
const output = response?.output || response?.data?.output || [];
for (const item of output) {
if (item?.type !== 'message') continue;
for (const content of item.content || []) {
if (content?.type === 'output_text' && content.text) {
return String(content.text);
}
}
}
return '';
}
function getCampaignLabel(campaignType: string) {
if (campaignType === 'referral') return 'Referral campaign';
if (campaignType === 'nps') return 'NPS survey';
if (campaignType === 'rebooking') return 'Repeat business / rebooking';
return 'Marketing broadcast';
}
export default function GrowthToolsPage() {
const dispatch = useAppDispatch();
const { isAskingResponse, errorMessage: aiErrorMessage } = useAppSelector((state) => state.openAi);
const [summary, setSummary] = useState<SummaryResponse | null>(null);
const [subscriptionStatus, setSubscriptionStatus] = useState<SubscriptionStatusResponse | null>(null);
const [selectedBusinessId, setSelectedBusinessId] = useState('');
const [settingsForm, setSettingsForm] = useState(defaultSettings);
const [campaignForm, setCampaignForm] = useState(defaultCampaign);
const [aiReviewText, setAiReviewText] = useState('Great service, fast communication, and the team made everything easy.');
const [aiTone, setAiTone] = useState('friendly, concise, and professional');
const [aiSuggestion, setAiSuggestion] = useState('');
const [widget, setWidget] = useState<WidgetResponse | null>(null);
const [competitorInsights, setCompetitorInsights] = useState<CompetitorInsightsResponse | null>(null);
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isWorking, setIsWorking] = useState(false);
const businesses = summary?.businesses || [];
const selectedBusiness = useMemo(
() => businesses.find((business) => business.id === selectedBusinessId) || summary?.primaryBusiness || null,
[businesses, selectedBusinessId, summary?.primaryBusiness],
);
const isGrowPlan = subscriptionStatus?.subscription.planId === 'starter';
const hasSelectedBusiness = Boolean(selectedBusinessId || selectedBusiness?.id);
const loadData = async () => {
setIsLoading(true);
setError('');
try {
const [summaryResponse, subscriptionResponse] = await Promise.all([
axios.get('/reviewflow/summary'),
axios.get('/subscription/me'),
]);
const loadedSummary = summaryResponse.data as SummaryResponse;
const primaryBusiness = loadedSummary.primaryBusiness || loadedSummary.businesses?.[0] || null;
setSummary(loadedSummary);
setSubscriptionStatus(subscriptionResponse.data);
if (primaryBusiness) {
setSelectedBusinessId(primaryBusiness.id);
setSettingsForm(businessToSettings(primaryBusiness));
}
} catch (requestError) {
console.error('Failed to load Growth Tools:', requestError);
setError('Could not load Growth Tools. Refresh the page or try again.');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const updateSettings = (key: keyof typeof defaultSettings, value: string | boolean) => {
setSettingsForm((current) => {
if (key === 'businessType') {
const businessType = normalizeBusinessType(String(value));
return {
...current,
businessType,
reviewDestination: coerceDestination(businessType, current.reviewDestination),
};
}
return { ...current, [key]: value };
});
};
const selectBusiness = (businessId: string) => {
const business = businesses.find((item) => item.id === businessId);
setSelectedBusinessId(businessId);
setSettingsForm(businessToSettings(business));
setWidget(null);
setCompetitorInsights(null);
};
const runDueAutomation = async () => {
setIsWorking(true);
setMessage('');
setError('');
try {
const response = await axios.post('/reviewflow/automation/run-due', { limit: 100 });
setMessage(
`Set-it-and-forget-it run complete: ${response.data.processed} processed, ${response.data.sent} handed off, ${response.data.failed} failed.`,
);
await loadData();
} catch (requestError) {
console.error('Failed to run due review automation:', requestError);
if (axios.isAxiosError(requestError) && requestError.response?.data) {
setError(String(requestError.response.data));
} else {
setError('Could not run due automation. Please try again.');
}
} finally {
setIsWorking(false);
}
};
const loadWidget = async () => {
if (!hasSelectedBusiness) return;
setIsWorking(true);
setMessage('');
setError('');
try {
const response = await axios.get(`/reviewflow/social-widget/${selectedBusinessId || selectedBusiness?.id}`);
setWidget(response.data);
setMessage('Social proof widget refreshed. Copy the embed code into a website page where you want reviews to appear.');
} catch (requestError) {
console.error('Failed to load social proof widget:', requestError);
if (axios.isAxiosError(requestError) && requestError.response?.data) {
setError(String(requestError.response.data));
} else {
setError('Could not load the social proof widget.');
}
} finally {
setIsWorking(false);
}
};
const launchCampaign = async () => {
setIsWorking(true);
setMessage('');
setError('');
try {
const response = await axios.post('/reviewflow/growth-tools/broadcast', {
businessId: selectedBusinessId,
...campaignForm,
});
setMessage(`${getCampaignLabel(campaignForm.campaignType)} queued: ${response.data.queued} customers, ${response.data.skipped} skipped.`);
} catch (requestError) {
console.error('Failed to queue Growth Tools campaign:', requestError);
if (axios.isAxiosError(requestError) && requestError.response?.data) {
setError(String(requestError.response.data));
} else {
setError('Could not queue this campaign. Please try again.');
}
} finally {
setIsWorking(false);
}
};
const generateAiReply = async () => {
setAiSuggestion('');
setError('');
if (isGrowPlan) {
setError('Grow does not include AI review replies. Upgrade to Pro to unlock it.');
return;
}
const payload = {
input: [
{
role: 'system',
content: 'You write short, warm, non-defensive review replies for a small business. Keep replies under 90 words and do not mention private customer data.',
},
{
role: 'user',
content: `Business: ${settingsForm.businessName}\nTone: ${aiTone}\nCustomer review: ${aiReviewText}\nWrite one public reply.`,
},
],
options: { poll_interval: 5, poll_timeout: 300 },
};
const resultAction = await dispatch(aiResponse(payload));
if (aiResponse.fulfilled.match(resultAction)) {
const text = extractAiResponseText(resultAction.payload);
setAiSuggestion(text || 'AI returned a response, but no text output was found.');
return;
}
console.error('AI reply assistant failed:', resultAction.payload || resultAction.error);
setError('AI reply assistant failed. Check the AI proxy configuration and try again.');
};
const runCompetitorInsights = async () => {
setIsWorking(true);
setCompetitorInsights(null);
setError('');
setMessage('');
try {
const response = await axios.post('/reviewflow/growth-tools/competitor-insights', {
businessId: selectedBusinessId,
competitorUrls: settingsForm.competitorUrls,
});
setCompetitorInsights(response.data);
setMessage('Competitor insight checklist updated.');
} catch (requestError) {
console.error('Failed to build competitor insights:', requestError);
if (axios.isAxiosError(requestError) && requestError.response?.data) {
setError(String(requestError.response.data));
} else {
setError('Could not build competitor insights. Please try again.');
}
} finally {
setIsWorking(false);
}
};
return (
<>
<Head>
<title>{getPageTitle('Growth Tools')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiStarCircleOutline} title='Growth Tools' main>
{''}
</SectionTitleLineWithButton>
<div className='mb-6 overflow-hidden rounded-3xl bg-gradient-to-br from-slate-950 via-indigo-950 to-emerald-950 p-6 text-white shadow-2xl'>
<div className='grid gap-6 lg:grid-cols-[1.2fr_0.8fr] lg:items-center'>
<div>
<p className='mb-3 inline-flex rounded-full bg-white/10 px-4 py-1 text-sm font-semibold text-emerald-200 ring-1 ring-white/20'>
Growth actions · campaigns · widgets · AI replies
</p>
<h2 className='max-w-3xl text-4xl font-black tracking-tight md:text-5xl'>
Use the tools that grow reviews after Setup is done.
</h2>
<p className='mt-4 max-w-2xl text-base text-slate-200 md:text-lg'>
Business configuration now lives in Setup. This page stays focused on the social widget, AI replies, campaigns, rebooking, NPS, competitor insights, and automation actions.
</p>
</div>
<div className='grid grid-cols-2 gap-3'>
{[
['Pending', summary?.stats.pending ?? 0],
['Sent', summary?.stats.sent ?? 0],
['Clicked', summary?.stats.clicked ?? 0],
['Reviewed', summary?.stats.reviewed ?? 0],
].map(([label, value]) => (
<div key={label} className='rounded-2xl bg-white/10 p-4 ring-1 ring-white/15 backdrop-blur'>
<div className='text-3xl font-black'>{value}</div>
<div className='text-sm text-slate-300'>{label}</div>
</div>
))}
</div>
</div>
</div>
{message && (
<div className='mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-emerald-900'>
<strong>Done.</strong> {message}
</div>
)}
{(error || aiErrorMessage) && (
<div className='mb-6 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-rose-900'>
<p>{error || aiErrorMessage}</p>
{(error || aiErrorMessage).includes('Upgrade to Pro') && (
<BaseButton href='/subscription' icon={mdiCreditCardOutline} label='Upgrade to Pro' color='danger' className='mt-3' />
)}
</div>
)}
<div className='mb-6 grid gap-6 xl:grid-cols-[0.75fr_1.25fr]'>
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
<div className='mb-5 flex items-start justify-between gap-4'>
<div>
<p className='text-sm font-bold uppercase tracking-[0.25em] text-emerald-500'>Growth workspace</p>
<h3 className='text-2xl font-black text-slate-900 dark:text-white'>Use tools after Setup is complete</h3>
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400'>
Growth Tools is now action-focused. Company info, payment connectors, review links, and branded templates live in Setup.
</p>
</div>
<BaseButton icon={mdiRefresh} label='Refresh' color='whiteDark' onClick={loadData} disabled={isLoading} />
</div>
{businesses.length > 0 && (
<FormField label='Business profile' help='Choose which business profile these tools should use.'>
<select value={selectedBusinessId} onChange={(event) => selectBusiness(event.target.value)}>
{businesses.map((business) => (
<option key={business.id} value={business.id}>{business.name || 'Business'}</option>
))}
</select>
</FormField>
)}
<div className='rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm leading-6 text-slate-600 dark:border-dark-700 dark:bg-dark-800 dark:text-slate-300'>
<p className='font-black text-slate-900 dark:text-white'>{selectedBusiness?.name || settingsForm.businessName || 'No business selected'}</p>
<p className='mt-1'>Review workflow: {settingsForm.businessType} · destination: {settingsForm.reviewDestination}</p>
<p className='mt-1'>If this looks wrong, update it in Setup instead of editing it here.</p>
</div>
<div className='mt-5 flex flex-wrap gap-3'>
<BaseButton href='/setup' icon={mdiOpenInNew} label='Open Setup' color='info' />
<BaseButton icon={mdiSend} label='Run due automation' color='success' onClick={runDueAutomation} disabled={isWorking} />
</div>
</CardBox>
<div className='grid gap-6'>
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
<p className='text-sm font-bold uppercase tracking-[0.25em] text-indigo-500'>Grow</p>
<h3 className='mt-1 text-2xl font-black text-slate-900 dark:text-white'>Social proof widget</h3>
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400'>
Grow includes an embeddable widget for hosted reviews. It displays verified reviews after customers submit them through Review Flow.
</p>
<BaseButton icon={mdiOpenInNew} label='Refresh widget code' color='info' className='mt-4' onClick={loadWidget} disabled={!hasSelectedBusiness || isWorking} />
{widget?.embedCode && (
<div className='mt-4 rounded-2xl bg-slate-950 p-4 text-sm text-emerald-100'>
<p className='mb-2 font-black text-white'>Embed code</p>
<code className='break-all'>{widget.embedCode}</code>
</div>
)}
{widget?.reviews && (
<div className='mt-4 grid gap-3'>
{widget.reviews.length === 0 ? (
<div className='rounded-2xl border border-dashed border-slate-200 p-5 text-center text-slate-500'>No hosted reviews yet.</div>
) : widget.reviews.map((review) => (
<div key={review.id} className='rounded-2xl bg-slate-50 p-4 dark:bg-dark-800'>
<p className='font-black text-amber-500'>{'★'.repeat(review.rating || 5)}</p>
<p className='mt-1 font-bold text-slate-900 dark:text-white'>{review.title || 'Customer review'}</p>
<p className='mt-1 text-sm text-slate-500'>{review.content}</p>
</div>
))}
</div>
)}
</CardBox>
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
<div className='flex items-start justify-between gap-3'>
<div>
<p className='text-sm font-bold uppercase tracking-[0.25em] text-fuchsia-500'>Pro</p>
<h3 className='mt-1 text-2xl font-black text-slate-900 dark:text-white'>AI review reply assistant</h3>
</div>
{isGrowPlan && <span className='rounded-full bg-indigo-100 px-3 py-1 text-xs font-black text-indigo-700'>Pro</span>}
</div>
<FormField label='Review and tone' help='Uses the existing /api/ai/response proxy through Redux.'>
<textarea value={aiReviewText} onChange={(event) => setAiReviewText(event.target.value)} placeholder='Paste customer review text' />
<input value={aiTone} onChange={(event) => setAiTone(event.target.value)} placeholder='Tone' />
</FormField>
<BaseButton icon={mdiSend} label={isAskingResponse ? 'Generating...' : 'Generate reply'} color='info' onClick={generateAiReply} disabled={isAskingResponse || !aiReviewText.trim() || isGrowPlan} />
{aiSuggestion && (
<div className='mt-4 rounded-2xl border border-indigo-100 bg-indigo-50 p-4 text-sm leading-6 text-indigo-950'>
{aiSuggestion}
</div>
)}
</CardBox>
</div>
</div>
<div className='grid gap-6 xl:grid-cols-2'>
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
<div className='flex items-start justify-between gap-3'>
<div>
<p className='text-sm font-bold uppercase tracking-[0.25em] text-emerald-500'>Pro campaigns</p>
<h3 className='mt-1 text-2xl font-black text-slate-900 dark:text-white'>Referrals, NPS, broadcasts, and rebooking</h3>
</div>
{isGrowPlan && <BaseButton href='/subscription' icon={mdiCreditCardOutline} label='Upgrade' color='info' />}
</div>
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400'>
These campaign actions queue customer messages in Email Delivery so a provider handoff can process them. They do not hide failures; invalid or missing emails are skipped.
</p>
<FormField label='Campaign' help='Choose the type, then write the subject and message.'>
<select value={campaignForm.campaignType} onChange={(event) => setCampaignForm((current) => ({ ...current, campaignType: event.target.value }))}>
<option value='broadcast'>Marketing broadcast</option>
<option value='referral'>Referral campaign</option>
<option value='nps'>NPS survey</option>
<option value='rebooking'>Repeat business / rebooking</option>
</select>
<input value={campaignForm.subject} onChange={(event) => setCampaignForm((current) => ({ ...current, subject: event.target.value }))} placeholder='Subject' />
</FormField>
<FormField label='Message' help='This is stored in the delivery log details for provider handoff.'>
<textarea value={campaignForm.message} onChange={(event) => setCampaignForm((current) => ({ ...current, message: event.target.value }))} placeholder='Message' />
</FormField>
<BaseButton icon={mdiSend} label='Queue campaign' color='info' onClick={launchCampaign} disabled={isWorking} />
</CardBox>
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
<div className='flex items-start justify-between gap-3'>
<div>
<p className='text-sm font-bold uppercase tracking-[0.25em] text-amber-500'>Pro insights</p>
<h3 className='mt-1 text-2xl font-black text-slate-900 dark:text-white'>Competitor insight checklist</h3>
</div>
{isGrowPlan && <span className='rounded-full bg-indigo-100 px-3 py-1 text-xs font-black text-indigo-700'>Pro</span>}
</div>
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400'>
Save focused competitors and generate an internal action checklist from your own review stats. This does not scrape competitor sites; it keeps the workspace lightweight and safe.
</p>
<BaseButton icon={mdiRefresh} label='Build insights' color='warning' className='mt-4' onClick={runCompetitorInsights} disabled={isWorking || !settingsForm.competitorUrls.trim()} />
{competitorInsights && (
<div className='mt-4 space-y-4'>
<div className='grid grid-cols-2 gap-3'>
{Object.entries(competitorInsights.metrics).map(([label, value]) => (
<div key={label} className='rounded-2xl bg-slate-50 p-4 dark:bg-dark-800'>
<p className='text-2xl font-black'>{value}</p>
<p className='text-sm capitalize text-slate-500'>{label}</p>
</div>
))}
</div>
<div className='rounded-2xl border border-slate-200 p-4 dark:border-dark-700'>
<p className='font-black text-slate-900 dark:text-white'>Tracked competitors</p>
<ul className='mt-2 list-disc space-y-1 pl-5 text-sm text-slate-500'>
{competitorInsights.competitors.map((competitor) => <li key={competitor}>{competitor}</li>)}
</ul>
</div>
<div className='rounded-2xl bg-amber-50 p-4 text-amber-950'>
<p className='font-black'>Recommended next actions</p>
<ul className='mt-2 list-disc space-y-2 pl-5 text-sm leading-6'>
{competitorInsights.recommendations.map((recommendation) => <li key={recommendation}>{recommendation}</li>)}
</ul>
</div>
</div>
)}
</CardBox>
</div>
</SectionMain>
</>
);
}
GrowthToolsPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated portal='customer'>{page}</LayoutAuthenticated>;
};