678 lines
29 KiB
TypeScript
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>;
|
|
};
|