@@ -1503,9 +1731,8 @@ export default function PaymentProviderConnectors({
)}
- API backup: if the dashboard webhook cannot be used,
- POST the matching provider-style JSON to this same
- URL.
+ Need API backup? Use the selected provider guide above
+ and click Use API instead.
{provider.webhook_token_last4 && (
diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts
index f99a43d..ead5fe2 100644
--- a/frontend/src/menuAside.ts
+++ b/frontend/src/menuAside.ts
@@ -38,16 +38,16 @@ export const customerMenuAside: MenuAsideItem[] = [
icon: icon.mdiViewDashboardOutline,
label: 'Workspace dashboard',
},
- {
- href: '/reviewflow',
- icon: icon.mdiStarOutline,
- label: 'Review Flow',
- },
{
href: '/growth-tools',
icon: icon.mdiStarCircleOutline,
label: 'Growth Tools',
},
+ {
+ href: '/setup',
+ icon: icon.mdiStarOutline,
+ label: 'Setup',
+ },
{
href: '/businesses/businesses-list',
label: 'Businesses',
diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx
index 964bb97..cbc65f4 100644
--- a/frontend/src/pages/dashboard.tsx
+++ b/frontend/src/pages/dashboard.tsx
@@ -439,7 +439,7 @@ const Dashboard = () => {
title: 'Review automation setup',
description: 'Configure the business profile, review request templates, and payment triggers.',
actions: [
- { label: 'Open Review Flow', href: '/reviewflow' },
+ { label: 'Open Setup', href: '/setup' },
{ label: `Manage ${businessLabel}`, href: '/businesses/businesses-list', permission: 'READ_BUSINESSES' },
{ label: 'Manage review requests', href: '/review_requests/review_requests-list', permission: 'READ_REVIEW_REQUESTS' },
],
diff --git a/frontend/src/pages/growth-tools.tsx b/frontend/src/pages/growth-tools.tsx
index 629502c..2576b6c 100644
--- a/frontend/src/pages/growth-tools.tsx
+++ b/frontend/src/pages/growth-tools.tsx
@@ -1,5 +1,4 @@
import {
- mdiCheckCircleOutline,
mdiCreditCardOutline,
mdiOpenInNew,
mdiRefresh,
@@ -8,7 +7,7 @@ import {
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
-import React, { FormEvent, ReactElement, useEffect, useMemo, useState } from 'react';
+import React, { ReactElement, useEffect, useMemo, useState } from 'react';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import FormField from '../components/FormField';
@@ -42,6 +41,14 @@ type ReviewBusiness = {
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 = {
@@ -139,6 +146,23 @@ const defaultSettings = {
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 = {
@@ -198,6 +222,14 @@ function businessToSettings(business?: ReviewBusiness | null) {
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,
};
}
@@ -240,7 +272,6 @@ export default function GrowthToolsPage() {
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(true);
- const [isSaving, setIsSaving] = useState(false);
const [isWorking, setIsWorking] = useState(false);
const businesses = summary?.businesses || [];
@@ -248,8 +279,6 @@ export default function GrowthToolsPage() {
() => businesses.find((business) => business.id === selectedBusinessId) || summary?.primaryBusiness || null,
[businesses, selectedBusinessId, summary?.primaryBusiness],
);
- const currentBusinessType = normalizeBusinessType(settingsForm.businessType);
- const destinationOptions = getDestinationsForBusinessType(currentBusinessType);
const isGrowPlan = subscriptionStatus?.subscription.planId === 'starter';
const hasSelectedBusiness = Boolean(selectedBusinessId || selectedBusiness?.id);
@@ -307,37 +336,6 @@ export default function GrowthToolsPage() {
setCompetitorInsights(null);
};
- const saveSettings = async (event?: FormEvent) => {
- event?.preventDefault();
- setIsSaving(true);
- setMessage('');
- setError('');
-
- try {
- const response = await axios.put('/reviewflow/growth-tools/business', {
- businessId: selectedBusinessId,
- ...settingsForm,
- delayDays: Number(settingsForm.delayDays),
- followupDelayDays: Number(settingsForm.followupDelayDays),
- maxFollowups: Number(settingsForm.maxFollowups),
- });
- const business = response.data.business as ReviewBusiness;
- setSelectedBusinessId(business.id);
- setSettingsForm(businessToSettings(business));
- setMessage('Growth settings saved. The workspace will now keep irrelevant options hidden for this business type.');
- await loadData();
- } catch (requestError) {
- console.error('Failed to save Growth Tools settings:', requestError);
- if (axios.isAxiosError(requestError) && requestError.response?.data) {
- setError(String(requestError.response.data));
- } else {
- setError('Could not save these settings. Please try again.');
- }
- } finally {
- setIsSaving(false);
- }
- };
-
const runDueAutomation = async () => {
setIsWorking(true);
setMessage('');
@@ -480,13 +478,13 @@ export default function GrowthToolsPage() {
- Automated review management · set it and forget it
+ Growth actions · campaigns · widgets · AI replies
- Keep review growth simple after business setup.
+ Use the tools that grow reviews after Setup is done.
- Local, Online, and Hybrid settings control which tools are visible. Grow handles the automated review engine. Pro unlocks AI replies, referrals, NPS, broadcasts, rebooking, and competitor insights.
+ Business configuration now lives in Setup. This page stays focused on the social widget, AI replies, campaigns, rebooking, NPS, competitor insights, and automation actions.
@@ -519,21 +517,21 @@ export default function GrowthToolsPage() {
)}
-
+
-
Setup
-
Business type and automation
+
Growth workspace
+
Use tools after Setup is complete
- This is the uncluttered switch: Local hides ecommerce-only tools, Online hides local-only tools, and Hybrid keeps both.
+ Growth Tools is now action-focused. Company info, payment connectors, review links, and branded templates live in Setup.
{businesses.length > 0 && (
-
+
selectBusiness(event.target.value)}>
{businesses.map((business) => (
{business.name || 'Business'}
@@ -542,91 +540,16 @@ export default function GrowthToolsPage() {
)}
-
+
+
+
+
@@ -679,7 +602,6 @@ export default function GrowthToolsPage() {
-
diff --git a/frontend/src/pages/reviewflow.tsx b/frontend/src/pages/reviewflow.tsx
index b7ee4fe..c460930 100644
--- a/frontend/src/pages/reviewflow.tsx
+++ b/frontend/src/pages/reviewflow.tsx
@@ -1,1184 +1,12 @@
-import {
- mdiAccountPlusOutline,
- mdiCreditCardOutline,
- mdiEmailOutline,
- mdiOpenInNew,
- mdiRefresh,
- mdiSend,
- mdiStarCircleOutline,
-} from '@mdi/js';
-import axios from 'axios';
-import Head from 'next/head';
-import Link from 'next/link';
-import React, {
- FormEvent,
- ReactElement,
- useEffect,
- useMemo,
- useState,
-} from 'react';
-import PaymentProviderConnectors, {
- ConnectorFormValues,
-} from '../components/ReviewFlow/PaymentProviderConnectors';
-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 { getBusinessProfileLimitLabel } from '../helpers/businessPlanLabels';
+import type { GetServerSideProps } from 'next';
-interface ReviewBusiness {
- id?: string;
- name?: string;
- business_type?: BusinessType;
- google_review_link?: string;
- yelp_review_link?: string;
- facebook_review_link?: string;
- trustpilot_review_link?: string;
- angi_review_link?: string;
- opentable_review_link?: string;
- custom_review_link?: string;
- review_destination?: string;
- delay_days?: number;
-}
-
-interface ReviewCustomer {
- name?: string;
- email?: string;
- phone?: string;
-}
-
-interface ReviewTransaction {
- id: string;
- payment_provider?: string;
- amount?: string | number;
- currency?: string;
- paid_at?: string;
- receipt_email?: string;
- description?: string;
- business?: ReviewBusiness;
- customer?: ReviewCustomer;
-}
-
-interface ReviewEvent {
- id: string;
- provider?: string;
- provider_event_type?: string;
- event_type?: string;
- processed?: boolean;
- processing_error?: string;
- createdAt?: string;
- business?: ReviewBusiness;
-}
-
-interface ReviewRequest {
- id: string;
- status?: string;
- scheduled_for?: string;
- email_subject?: string;
- email_body?: string;
- review_link?: string;
- review_platform?: string;
- review_rating?: number;
- failure_reason?: string;
- createdAt?: string;
- business?: ReviewBusiness;
- customer?: ReviewCustomer;
- transaction?: ReviewTransaction;
-}
-
-interface ReviewDeliveryAttempt {
- channel?: string;
- status?: string;
- to?: string;
- reason?: string;
-}
-
-interface ReviewDeliveryGroup {
- requestId?: string;
- deliveries?: ReviewDeliveryAttempt[];
-}
-
-interface ReviewDeliveryResponse {
- processed?: number;
- sent?: number;
- failed?: number;
- errors?: string[];
- deliveries?: ReviewDeliveryGroup[];
-}
-
-interface SummaryResponse {
- stats: {
- pending: number;
- sent: number;
- clicked: number;
- reviewed: number;
- customers: number;
- transactions: number;
- paymentEvents: number;
- };
- requests: ReviewRequest[];
- recentTransactions?: ReviewTransaction[];
- recentEvents?: ReviewEvent[];
- businesses?: ReviewBusiness[];
- primaryBusiness?: ReviewBusiness | null;
-}
-
-interface SubscriptionStatusResponse {
- subscription: {
- planId: string;
- planName: string;
- effectiveStatus: string;
- isActive: boolean;
- trialEndsAt?: string | null;
- trialDaysLeft?: number | null;
- };
- usage: {
- monthlyReviewRequests: number;
- businesses: number;
- teamMembers: number;
- paymentConnectors: number;
- };
- limits: {
- monthlyReviewRequests: number;
- businesses: number;
- teamMembers: number;
- paymentConnectors: number;
- };
-}
-
-type BusinessType = 'local' | 'online' | 'hybrid';
-
-const defaultForm = {
- businessName: 'Review Flow Studio',
- businessType: 'hybrid' as BusinessType,
- reviewDestination: 'google',
- reviewLink: 'https://g.page/r/example/review',
- delayDays: '0',
- customerName: '',
- customerEmail: '',
- phone: '',
-};
-
-const reviewDestinationOptions = [
- { key: 'google', label: 'Google', requiresLink: true, scope: 'local' },
- { key: 'facebook', label: 'Facebook', requiresLink: true, scope: 'local' },
- { key: 'yelp', label: 'Yelp', requiresLink: true, scope: 'local' },
- { key: 'angi', label: 'Angi', requiresLink: true, scope: 'local' },
- { key: 'opentable', label: 'OpenTable', requiresLink: true, scope: 'local' },
- { key: 'trustpilot', label: 'Trustpilot', requiresLink: true, scope: 'online' },
- { key: 'shopify_hosted', label: 'Shopify hosted product review', requiresLink: false, scope: 'online' },
- { key: 'custom', label: 'Custom review page', requiresLink: true, scope: 'hybrid' },
-];
-
-const businessTypeOptions: Array<{
- key: BusinessType;
- label: string;
- help: string;
-}> = [
- {
- key: 'local',
- label: 'Local / service business',
- help: 'Shows local review destinations like Google, Facebook, Yelp, Angi, and OpenTable.',
+export const getServerSideProps: GetServerSideProps = async () => ({
+ redirect: {
+ destination: '/setup',
+ permanent: false,
},
- {
- key: 'online',
- label: 'Online / ecommerce business',
- help: 'Shows ecommerce destinations like Shopify hosted reviews and Trustpilot.',
- },
- {
- key: 'hybrid',
- label: 'Hybrid business',
- help: 'Shows both local and online options for businesses that need both.',
- },
-];
+});
-function normalizeBusinessType(value?: string): BusinessType {
- if (value === 'local' || value === 'online' || value === 'hybrid') {
- return value;
- }
-
- return 'hybrid';
+export default function ReviewFlowRedirect() {
+ return null;
}
-
-function destinationAllowedForBusinessType(
- businessType: BusinessType,
- destination: (typeof reviewDestinationOptions)[number],
-) {
- if (businessType === 'hybrid' || destination.scope === 'hybrid') {
- return true;
- }
-
- return destination.scope === businessType;
-}
-
-function getReviewDestinationsForBusinessType(businessType: BusinessType) {
- return reviewDestinationOptions.filter((destination) =>
- destinationAllowedForBusinessType(businessType, destination),
- );
-}
-
-function getDefaultReviewDestinationForBusinessType(businessType: BusinessType) {
- if (businessType === 'online') return 'shopify_hosted';
- return 'google';
-}
-
-function getAllowedReviewDestination(
- businessType: BusinessType,
- reviewDestination?: string,
-) {
- const allowedDestinations = getReviewDestinationsForBusinessType(businessType);
- const isAllowed = allowedDestinations.some(
- (destination) => destination.key === reviewDestination,
- );
-
- return isAllowed
- ? reviewDestination || allowedDestinations[0].key
- : getDefaultReviewDestinationForBusinessType(businessType);
-}
-
-function getReviewLinkForDestination(
- business: ReviewBusiness,
- destination?: string,
-) {
- const destinationKey = destination || business.review_destination || 'google';
-
- if (destinationKey === 'google') return business.google_review_link || '';
- if (destinationKey === 'facebook') return business.facebook_review_link || '';
- if (destinationKey === 'yelp') return business.yelp_review_link || '';
- if (destinationKey === 'angi') return business.angi_review_link || '';
- if (destinationKey === 'opentable') return business.opentable_review_link || '';
- if (destinationKey === 'trustpilot') return business.trustpilot_review_link || '';
- if (destinationKey === 'custom') return business.custom_review_link || '';
-
- return '';
-}
-
-const statusStyles: Record
= {
- pending: 'bg-amber-100 text-amber-800 ring-amber-200',
- sent: 'bg-sky-100 text-sky-800 ring-sky-200',
- clicked: 'bg-violet-100 text-violet-800 ring-violet-200',
- reviewed: 'bg-emerald-100 text-emerald-800 ring-emerald-200',
- failed: 'bg-rose-100 text-rose-800 ring-rose-200',
-};
-
-const proFeaturePrompts = [
- ['Higher request volume', 'Queue up to 2,500 review requests per month.'],
- ['More business profiles', 'Manage up to 10 locations, brands, or service lines.'],
- ['Larger team access', 'Invite up to 10 team members with the same permission-controlled workflow.'],
-];
-
-function formatDate(value?: string | null) {
- if (!value) return 'Not scheduled';
-
- return new Intl.DateTimeFormat('en', {
- month: 'short',
- day: 'numeric',
- year: 'numeric',
- hour: 'numeric',
- minute: '2-digit',
- }).format(new Date(value));
-}
-
-function hasAuthToken() {
- return (
- typeof window !== 'undefined' && Boolean(localStorage.getItem('token'))
- );
-}
-
-function isUnauthorizedError(error: unknown) {
- return axios.isAxiosError(error) && error.response?.status === 401;
-}
-
-function formatAmount(amount?: string | number, currency?: string) {
- const numericAmount = Number(amount);
-
- if (!Number.isFinite(numericAmount)) {
- return 'Amount pending';
- }
-
- return new Intl.NumberFormat('en', {
- style: 'currency',
- currency: currency || 'USD',
- }).format(numericAmount);
-}
-
-type ReviewFlowDisclosureProps = {
- eyebrow?: string;
- title: string;
- description?: string;
- children: React.ReactNode;
- defaultOpen?: boolean;
- className?: string;
-};
-
-function ReviewFlowDisclosure({
- eyebrow,
- title,
- description,
- children,
- defaultOpen = false,
- className = '',
-}: ReviewFlowDisclosureProps) {
- const detailsProps = defaultOpen ? { open: true } : {};
-
- return (
-
-
-
- {eyebrow && (
-
- {eyebrow}
-
- )}
-
- {title}
-
- {description && (
-
- {description}
-
- )}
-
-
- ⌄
-
-
-
- {children}
-
-
- );
-}
-
-export default function ReviewFlowWorkspace() {
- const [form, setForm] = useState(defaultForm);
- const [summary, setSummary] = useState(null);
- const [selected, setSelected] = useState(null);
- const [created, setCreated] = useState(null);
- const [deliveryNotice, setDeliveryNotice] = useState('');
- const [isLoading, setIsLoading] = useState(true);
- const [isSubmitting, setIsSubmitting] = useState(false);
- const [error, setError] = useState('');
- const [subscriptionStatus, setSubscriptionStatus] =
- useState(null);
- const [isClientReady, setIsClientReady] = useState(false);
-
- const requests = summary?.requests ?? [];
- const recentTransactions = summary?.recentTransactions ?? [];
- const recentEvents = summary?.recentEvents ?? [];
- const stats = summary?.stats ?? {
- pending: 0,
- sent: 0,
- clicked: 0,
- reviewed: 0,
- customers: 0,
- transactions: 0,
- paymentEvents: 0,
- };
- const currentBusinessType = normalizeBusinessType(form.businessType);
- const filteredReviewDestinationOptions = getReviewDestinationsForBusinessType(currentBusinessType);
- const selectedReviewDestination =
- filteredReviewDestinationOptions.find(
- (destination) => destination.key === form.reviewDestination,
- ) || filteredReviewDestinationOptions[0];
- const isHostedReviewDestination = !selectedReviewDestination.requiresLink;
-
- const previewDate = useMemo(() => {
- if (!isClientReady) return 'after the selected delay';
-
- const days = Math.max(0, Number(form.delayDays) || 0);
- return formatDate(
- new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString(),
- );
- }, [form.delayDays, isClientReady]);
-
- const loadSummary = async () => {
- setIsLoading(true);
- try {
- const response = await axios.get('/reviewflow/summary');
- setSummary(response.data);
- const primaryBusiness = response.data.primaryBusiness as ReviewBusiness | null;
- if (primaryBusiness) {
- setForm((current) => {
- const businessType = normalizeBusinessType(primaryBusiness.business_type);
- const reviewDestination = getAllowedReviewDestination(
- businessType,
- primaryBusiness.review_destination || current.reviewDestination,
- );
-
- return {
- ...current,
- businessName:
- current.businessName === defaultForm.businessName && primaryBusiness.name
- ? primaryBusiness.name
- : current.businessName,
- businessType,
- reviewDestination,
- reviewLink: getReviewLinkForDestination(primaryBusiness, reviewDestination) || current.reviewLink,
- delayDays: current.delayDays,
- };
- });
- }
- if (!selected && response.data.requests?.length) {
- setSelected(response.data.requests[0]);
- }
- setError('');
- } catch (requestError) {
- if (!isUnauthorizedError(requestError)) {
- console.error('Failed to load Review Flow summary:', requestError);
- setError(
- 'Could not load your review queue. Please refresh or try again.',
- );
- }
- } finally {
- setIsLoading(false);
- }
- };
-
- const loadSubscriptionStatus = async () => {
- try {
- const response = await axios.get('/subscription/me');
- setSubscriptionStatus(response.data);
- } catch (requestError) {
- if (!isUnauthorizedError(requestError)) {
- console.error('Failed to load subscription status:', requestError);
- }
- }
- };
-
- useEffect(() => {
- setIsClientReady(true);
-
- if (!hasAuthToken()) {
- setIsLoading(false);
- return;
- }
-
- loadSummary();
- loadSubscriptionStatus();
- }, []);
-
- const updateForm = (key: keyof typeof defaultForm, value: string) => {
- setForm((current) => {
- if (key === 'businessType') {
- const businessType = normalizeBusinessType(value);
- return {
- ...current,
- businessType,
- reviewDestination: getAllowedReviewDestination(
- businessType,
- current.reviewDestination,
- ),
- };
- }
-
- return { ...current, [key]: value };
- });
- };
-
- const handleSubmit = async (event: FormEvent) => {
- event.preventDefault();
- setIsSubmitting(true);
- setError('');
- setCreated(null);
- setDeliveryNotice('');
-
- try {
- const response = await axios.post('/reviewflow/request', {
- ...form,
- reviewLink: isHostedReviewDestination ? '' : form.reviewLink,
- delayDays: Number(form.delayDays),
- });
- const newRequest = response.data.request;
- const delivery = response.data.delivery as ReviewDeliveryResponse | null;
- const deliveryAttempts = delivery?.deliveries?.flatMap(
- (group) => group.deliveries || [],
- ) || [];
- const smsAttempt = deliveryAttempts.find(
- (attempt) => attempt.channel === 'sms',
- );
- const failedMessage = delivery?.errors?.[0];
- const noticeParts: string[] = [];
-
- if (delivery?.sent) {
- noticeParts.push('Email sent through SMTP.');
- }
-
- if (smsAttempt?.status === 'sent') {
- noticeParts.push('SMS sent.');
- } else if (smsAttempt?.status === 'skipped' && smsAttempt.reason) {
- noticeParts.push(`SMS skipped: ${smsAttempt.reason}`);
- } else if (smsAttempt?.status === 'failed' && smsAttempt.reason) {
- noticeParts.push(`SMS failed: ${smsAttempt.reason}`);
- }
-
- setDeliveryNotice(noticeParts.join(' '));
- setCreated(newRequest);
- setSelected(newRequest);
-
- if (failedMessage || newRequest.status === 'failed') {
- setError(
- `Request was created, but delivery failed: ${failedMessage || newRequest.failure_reason || 'Unknown delivery error'}`,
- );
- }
- setForm((current) => ({
- ...current,
- customerName: '',
- customerEmail: '',
- phone: '',
- }));
- await Promise.all([loadSummary(), loadSubscriptionStatus()]);
- } catch (requestError) {
- console.error('Failed to create review request:', requestError);
- if (axios.isAxiosError(requestError) && requestError.response?.data) {
- setError(String(requestError.response.data));
- } else {
- setError(
- 'Could not create the review request. Please check the fields and try again.',
- );
- }
- } finally {
- setIsSubmitting(false);
- }
- };
-
- const handleProviderConnected = async (
- _business: unknown,
- connectorForm: ConnectorFormValues,
- ) => {
- setForm((current) => ({
- ...current,
- businessName: connectorForm.businessName,
- businessType: normalizeBusinessType(connectorForm.businessType),
- reviewDestination: connectorForm.reviewDestination,
- reviewLink: connectorForm.reviewLink,
- delayDays: connectorForm.delayDays,
- }));
- await Promise.all([loadSummary(), loadSubscriptionStatus()]);
- };
-
- const currentSubscription = subscriptionStatus?.subscription;
- const currentUsage = subscriptionStatus?.usage;
- const currentLimits = subscriptionStatus?.limits;
- const reviewRequestsUsed = currentUsage?.monthlyReviewRequests ?? 0;
- const reviewRequestsLimit = currentLimits?.monthlyReviewRequests ?? 0;
- const reviewRequestsRemaining = Math.max(
- 0,
- reviewRequestsLimit - reviewRequestsUsed,
- );
- const reviewRequestsPercent = reviewRequestsLimit
- ? Math.min(100, Math.round((reviewRequestsUsed / reviewRequestsLimit) * 100))
- : 0;
- const businessesUsed = currentUsage?.businesses ?? 0;
- const businessesLimit = currentLimits?.businesses ?? 0;
- const businessesRemaining = Math.max(0, businessesLimit - businessesUsed);
- const isStarterPlan = currentSubscription?.planId === 'starter';
- const isSubscriptionInactive =
- currentSubscription && !currentSubscription.isActive;
- const isReviewRequestLimitReached = Boolean(
- currentSubscription &&
- reviewRequestsLimit > 0 &&
- reviewRequestsUsed >= reviewRequestsLimit,
- );
- const isReviewRequestBlocked = Boolean(
- isSubscriptionInactive || isReviewRequestLimitReached,
- );
- const focusMetrics = [
- ['Pending', stats.pending, 'Needs attention'],
- ['Sent', stats.sent, 'Waiting for a customer'],
- ['Clicked', stats.clicked, 'Opened the review link'],
- ['Reviewed', stats.reviewed, 'Completed reviews'],
- ];
-
- return (
- <>
-
- {getPageTitle('Review Flow')}
-
-
-
- {''}
-
-
-
-
-
-
- Clean workspace · quick request queue · message preview
-
-
- Focus on the review requests that need action now.
-
-
- The essentials stay visible. Setup, payment connectors, and webhook history are now tucked into dropdowns below.
-
-
-
- {focusMetrics.map(([label, value, description]) => (
-
-
{value}
-
{label}
-
{description}
-
- ))}
-
-
-
-
- {currentSubscription && currentUsage && currentLimits && (
-
-
-
-
- Plan and usage
-
-
- {currentSubscription.planName} · {currentSubscription.effectiveStatus}
-
-
- {currentSubscription.trialDaysLeft !== null &&
- currentSubscription.trialDaysLeft !== undefined
- ? `${currentSubscription.trialDaysLeft} trial days left. `
- : ''}
- {reviewRequestsRemaining.toLocaleString()} review requests and {getBusinessProfileLimitLabel(businessesRemaining)} remaining on this plan.
-
-
-
-
-
- Monthly review requests
- {reviewRequestsUsed.toLocaleString()} / {reviewRequestsLimit.toLocaleString()}
-
-
-
= 80 ? 'h-full rounded-full bg-amber-500' : 'h-full rounded-full bg-emerald-500'}
- style={{ width: `${reviewRequestsPercent}%` }}
- />
-
-
-
-
-
-
- )}
-
- {created && (
-
-
- {created.status === 'sent'
- ? 'Review request sent.'
- : created.status === 'failed'
- ? 'Review request delivery failed.'
- : 'Review request scheduled.'}
- {' '}
- {created.customer?.email} {created.status === 'sent' ? 'was emailed' : 'is scheduled'} for {formatDate(created.scheduled_for)}.
- {deliveryNotice &&
{deliveryNotice}
}
-
- )}
- {error && (
-
-
{error}
- {error.includes('Upgrade to Pro') && (
-
- )}
-
- )}
-
-
-
-
-
-
- Quick action
-
-
- Ask a customer for a review
-
-
- Enter the customer first. Business setup, destination, timing,
- and phone are available in Optional setup.
-
-
-
-
-
-
-
- {isReviewRequestBlocked && (
-
-
- {isSubscriptionInactive ? 'Subscription inactive' : 'Monthly request limit reached'}
-
-
- {isSubscriptionInactive
- ? 'Review requests are paused until this account has an active plan.'
- : `${currentSubscription?.planName} includes ${reviewRequestsLimit.toLocaleString()} review requests per month, and this account has already used ${reviewRequestsUsed.toLocaleString()}.`}
- {' '}Existing queued requests stay available.
-
-
-
- )}
-
-
-
-
- updateForm('customerName', event.target.value)
- }
- placeholder='Customer name'
- />
-
- updateForm('customerEmail', event.target.value)
- }
- placeholder='customer@example.com'
- />
-
-
- 0 ? `scheduled ${previewDate}` : 'sends immediately'}`}
- className='mb-5 shadow-none'
- >
- option.key === currentBusinessType)?.help}
- >
-
- updateForm('businessName', event.target.value)
- }
- placeholder='Business name'
- />
-
- updateForm('businessType', event.target.value)
- }
- >
- {businessTypeOptions.map((option) => (
-
- {option.label}
-
- ))}
-
-
- updateForm('reviewDestination', event.target.value)
- }
- >
- {filteredReviewDestinationOptions.map((destination) => (
-
- {destination.label}
-
- ))}
-
-
-
- {isHostedReviewDestination ? (
-
- Review Flow will create a secure hosted product-review link for this request.
-
- ) : (
-
- updateForm('reviewLink', event.target.value)
- }
- placeholder='https://your-review-destination.example/review'
- />
- )}
-
-
-
- updateForm('delayDays', event.target.value)
- }
- placeholder='Delay days'
- />
- updateForm('phone', event.target.value)}
- placeholder='Optional phone'
- />
-
-
-
-
- 0 ? 'Schedule review request' : 'Send review request now'}
- color='info'
- disabled={isSubmitting || isReviewRequestBlocked}
- />
-
-
-
-
-
-
-
-
-
-
- Queue
-
-
- Recent requests
-
-
-
-
- {isLoading ? (
-
- Loading review queue...
-
- ) : requests.length === 0 ? (
-
-
-
-
-
- No requests yet
-
-
- Create one manually or send a successful provider payment
- webhook.
-
-
- ) : (
-
- {requests.map((request) => {
- const status = request.status || 'pending';
- return (
-
setSelected(request)}
- className={`w-full rounded-2xl border p-4 text-left transition hover:-translate-y-0.5 hover:shadow-lg ${selected?.id === request.id ? 'border-indigo-300 bg-indigo-50 dark:bg-indigo-950/30' : 'border-slate-200 bg-white dark:border-dark-700 dark:bg-dark-900'}`}
- >
-
-
-
- {request.customer?.name ||
- request.customer?.email ||
- 'Customer'}
-
-
- {request.business?.name || 'Business'} ·{' '}
- {formatDate(request.scheduled_for)}
-
- {request.transaction?.payment_provider && (
-
- From {request.transaction.payment_provider}{' '}
- payment
-
- )}
-
-
- {status}
-
-
-
- );
- })}
-
- )}
-
-
-
-
- Detail
-
-
- Message preview
-
- {selected ? (
-
-
-
- To
-
-
- {selected.customer?.email}
-
-
-
-
- Subject
-
-
- {selected.email_subject}
-
-
-
- {(selected.email_body || '')
- .split('\n')
- .map((line, index) => (
-
- {line}
-
- ))}
-
-
-
- Review link / hosted form
-
-
- {selected.review_link}
-
-
-
- ) : (
-
- Select a request to preview the outgoing message.
-
- )}
-
-
-
-
-
-
-
-
-
- {isStarterPlan && (
-
-
-
-
- Unlock higher Review Flow limits.
-
-
- Grow keeps set-it-and-forget-it review automation running. Pro adds AI replies, referrals, NPS, broadcasts, rebooking, competitor insights, and higher limits.
-
-
-
-
- {proFeaturePrompts.map(([title, copy]) => (
-
- ))}
-
-
-
- )}
-
-
-
-
-
-
-
- Webhook intake
-
-
- Recent payment events
-
-
-
- {recentEvents.length === 0 ? (
-
- No provider webhooks received yet.
-
- ) : (
-
- {recentEvents.map((event) => (
-
-
-
-
- {(event.provider || 'provider').toUpperCase()} ·{' '}
- {event.provider_event_type ||
- event.event_type ||
- 'unknown event'}
-
-
- {event.business?.name || 'Business'} ·{' '}
- {formatDate(event.createdAt)}
-
- {event.processing_error && (
-
- {event.processing_error}
-
- )}
-
-
- {event.processed ? 'processed' : 'pending'}
-
-
-
- ))}
-
- )}
-
-
-
-
-
-
- Payments
-
-
- Recent transactions
-
-
-
- {recentTransactions.length === 0 ? (
-
- No transactions created from webhooks yet.
-
- ) : (
-
- {recentTransactions.map((transaction) => (
-
-
-
-
- {formatAmount(
- transaction.amount,
- transaction.currency,
- )}
-
-
- {transaction.payment_provider || 'provider'} ·{' '}
- {transaction.customer?.email ||
- transaction.receipt_email ||
- 'No email'}{' '}
- · {formatDate(transaction.paid_at)}
-
-
-
- {transaction.currency || 'USD'}
-
-
-
- ))}
-
- )}
-
-
-
-
-
- >
- );
-}
-
-ReviewFlowWorkspace.getLayout = function getLayout(page: ReactElement) {
- return
{page} ;
-};
diff --git a/frontend/src/pages/setup.tsx b/frontend/src/pages/setup.tsx
new file mode 100644
index 0000000..3a34fa9
--- /dev/null
+++ b/frontend/src/pages/setup.tsx
@@ -0,0 +1,1056 @@
+import {
+ mdiCheckCircleOutline,
+ mdiCreditCardOutline,
+ mdiOpenInNew,
+ mdiRefresh,
+ mdiStarCircleOutline,
+} from '@mdi/js';
+import axios from 'axios';
+import Head from 'next/head';
+import React, { ReactElement, useEffect, useMemo, useState } from 'react';
+import PaymentProviderConnectors, {
+ ConnectorFormValues,
+} from '../components/ReviewFlow/PaymentProviderConnectors';
+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';
+
+type BusinessType = 'local' | 'online' | 'hybrid';
+
+type ReviewBusiness = {
+ id: string;
+ name?: string;
+ business_type?: BusinessType;
+ review_destination?: string;
+ delay_days?: number;
+ 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;
+ google_review_link?: string;
+ yelp_review_link?: string;
+ facebook_review_link?: string;
+ trustpilot_review_link?: string;
+ angi_review_link?: string;
+ opentable_review_link?: string;
+ custom_review_link?: string;
+ providers?: Array<{ connected?: boolean }>;
+};
+
+type ReviewTransaction = {
+ id: string;
+ payment_provider?: string;
+ amount?: string | number;
+ currency?: string;
+ paid_at?: string;
+ receipt_email?: string;
+ customer?: { email?: string };
+};
+
+type ReviewEvent = {
+ id: string;
+ provider?: string;
+ provider_event_type?: string;
+ event_type?: string;
+ processed?: boolean;
+ processing_error?: string;
+ createdAt?: string;
+ business?: { name?: string };
+};
+
+type SummaryResponse = {
+ stats: {
+ pending: number;
+ sent: number;
+ clicked: number;
+ reviewed: number;
+ customers: number;
+ transactions: number;
+ paymentEvents: number;
+ };
+ businesses?: ReviewBusiness[];
+ primaryBusiness?: ReviewBusiness | null;
+ recentTransactions?: ReviewTransaction[];
+ recentEvents?: ReviewEvent[];
+};
+
+type SubscriptionStatusResponse = {
+ subscription: {
+ planId: string;
+ planName: string;
+ effectiveStatus: string;
+ isActive: boolean;
+ };
+};
+
+const businessTypeOptions: Array<{ key: BusinessType; label: string; help: string }> = [
+ {
+ key: 'local',
+ label: 'Local / service business',
+ help: 'Best for local profile reviews such as Google, Facebook, Yelp, Angi, and OpenTable.',
+ },
+ {
+ key: 'online',
+ label: 'Online / ecommerce business',
+ help: 'Best for online stores and ecommerce brands using hosted product reviews or Trustpilot.',
+ },
+ {
+ key: 'hybrid',
+ label: 'Hybrid business',
+ help: 'Best when the same company needs both local and online review workflows.',
+ },
+];
+
+const reviewDestinationOptions = [
+ { key: 'google', label: 'Google', scope: 'local', field: 'googleReviewLink' },
+ { key: 'facebook', label: 'Facebook', scope: 'local', field: 'facebookReviewLink' },
+ { key: 'yelp', label: 'Yelp', scope: 'local', field: 'yelpReviewLink' },
+ { key: 'angi', label: 'Angi', scope: 'local', field: 'angiReviewLink' },
+ { key: 'opentable', label: 'OpenTable', scope: 'local', field: 'opentableReviewLink' },
+ { key: 'shopify_hosted', label: 'Shopify hosted product review', scope: 'online', field: '' },
+ { key: 'trustpilot', label: 'Trustpilot', scope: 'online', field: 'trustpilotReviewLink' },
+ { key: 'custom', label: 'Custom review page', scope: 'hybrid', field: 'customReviewLink' },
+] as const;
+
+type ReviewDestinationOption = (typeof reviewDestinationOptions)[number];
+type ReviewDestinationKey = ReviewDestinationOption['key'];
+type ReviewDestinationField = Exclude
;
+
+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}',
+ googleReviewLink: '',
+ yelpReviewLink: '',
+ facebookReviewLink: '',
+ trustpilotReviewLink: '',
+ angiReviewLink: '',
+ opentableReviewLink: '',
+ customReviewLink: '',
+};
+
+type SetupSettings = typeof defaultSettings;
+type SetupSettingsKey = keyof SetupSettings;
+
+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 getReviewDestinationOption(key?: string) {
+ return reviewDestinationOptions.find((option) => option.key === key);
+}
+
+function isReviewDestinationKey(value: string): value is ReviewDestinationKey {
+ return reviewDestinationOptions.some((option) => option.key === value);
+}
+
+function getSelectedDestinationsFromSettings(settings: SetupSettings): ReviewDestinationKey[] {
+ const businessType = normalizeBusinessType(settings.businessType);
+ const allowedDestinations = getDestinationsForBusinessType(businessType);
+ const selected = allowedDestinations
+ .filter((destination) => {
+ if (destination.field) {
+ return Boolean(String(settings[destination.field as ReviewDestinationField] || '').trim());
+ }
+
+ return destination.key === settings.reviewDestination;
+ })
+ .map((destination) => destination.key);
+
+ return selected;
+}
+
+function getSelectedDestinationOptions(
+ businessType: BusinessType,
+ selectedKeys: ReviewDestinationKey[],
+) {
+ return getDestinationsForBusinessType(businessType).filter((destination) =>
+ selectedKeys.includes(destination.key),
+ );
+}
+
+function getPrimaryReviewDestination(
+ settings: SetupSettings,
+ selectedKeys: ReviewDestinationKey[],
+) {
+ const businessType = normalizeBusinessType(settings.businessType);
+ const selectedDestinations = getSelectedDestinationOptions(businessType, selectedKeys);
+ const firstLinkedDestination = selectedDestinations.find((destination) =>
+ destination.field && String(settings[destination.field as ReviewDestinationField] || '').trim(),
+ );
+
+ return firstLinkedDestination?.key || selectedDestinations[0]?.key || coerceDestination(businessType, settings.reviewDestination);
+}
+
+function getReviewOutputIsComplete(settings: SetupSettings, selectedKeys: ReviewDestinationKey[]) {
+ const businessType = normalizeBusinessType(settings.businessType);
+ const selectedDestinations = getSelectedDestinationOptions(businessType, selectedKeys);
+
+ return selectedDestinations.length > 0 && selectedDestinations.every((destination) => {
+ if (!destination.field) return true;
+
+ return Boolean(String(settings[destination.field as ReviewDestinationField] || '').trim());
+ });
+}
+
+function formatReviewDestinationList(destinations: ReviewDestinationOption[]) {
+ if (destinations.length === 0) return 'No review sites selected yet';
+ if (destinations.length === 1) return destinations[0].label;
+
+ return destinations.map((destination) => destination.label).join(', ');
+}
+
+function businessToSettings(business?: ReviewBusiness | null): SetupSettings {
+ 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,
+ googleReviewLink: business.google_review_link || '',
+ yelpReviewLink: business.yelp_review_link || '',
+ facebookReviewLink: business.facebook_review_link || '',
+ trustpilotReviewLink: business.trustpilot_review_link || '',
+ angiReviewLink: business.angi_review_link || '',
+ opentableReviewLink: business.opentable_review_link || '',
+ customReviewLink: business.custom_review_link || '',
+ };
+}
+
+function escapePreviewRegExp(value: string) {
+ return value
+ .split('')
+ .map((character) => ('^$*+?.()|{}[]\\'.includes(character) ? '\\' + character : character))
+ .join('');
+}
+
+function renderPreviewTemplate(template: string, replacements: Record) {
+ return Object.entries(replacements).reduce((output, [key, value]) => {
+ const safeKey = escapePreviewRegExp(key);
+ return output
+ .replace(new RegExp(`{{\\s*${safeKey}\\s*}}`, 'g'), value)
+ .replace(new RegExp(`{${safeKey}}`, 'g'), value);
+ }, template || '');
+}
+
+function getSafePreviewColor(value: string) {
+ return /^#[0-9a-fA-F]{6}$/.test(value) || /^#[0-9a-fA-F]{3}$/.test(value)
+ ? value
+ : defaultSettings.brandPrimaryColor;
+}
+
+function formatDate(value?: string | null) {
+ if (!value) return 'Not received yet';
+
+ return new Intl.DateTimeFormat('en', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ }).format(new Date(value));
+}
+
+function formatAmount(amount?: string | number, currency?: string) {
+ const numericAmount = Number(amount);
+
+ if (!Number.isFinite(numericAmount)) {
+ return 'Amount pending';
+ }
+
+ return new Intl.NumberFormat('en', {
+ style: 'currency',
+ currency: currency || 'USD',
+ }).format(numericAmount);
+}
+
+function setupStepClass(isComplete: boolean) {
+ return isComplete
+ ? 'border-emerald-200 bg-emerald-50 text-emerald-950 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-50'
+ : 'border-slate-200 bg-white text-slate-900 dark:border-dark-700 dark:bg-dark-900 dark:text-white';
+}
+
+export default function SetupPage() {
+ const [summary, setSummary] = useState(null);
+ const [subscriptionStatus, setSubscriptionStatus] = useState(null);
+ const [selectedBusinessId, setSelectedBusinessId] = useState('');
+ const [settingsForm, setSettingsForm] = useState(defaultSettings);
+ const [message, setMessage] = useState('');
+ const [error, setError] = useState('');
+ const [isLoading, setIsLoading] = useState(true);
+ const [isSaving, setIsSaving] = useState(false);
+ const [selectedReviewDestinations, setSelectedReviewDestinations] = useState([
+ 'google',
+ ]);
+ const [reviewOutputFinished, setReviewOutputFinished] = useState(false);
+
+ const businesses = summary?.businesses || [];
+ const recentEvents = summary?.recentEvents || [];
+ const recentTransactions = summary?.recentTransactions || [];
+ const selectedBusiness = useMemo(
+ () => businesses.find((business) => business.id === selectedBusinessId) || summary?.primaryBusiness || null,
+ [businesses, selectedBusinessId, summary?.primaryBusiness],
+ );
+ const currentBusinessType = normalizeBusinessType(settingsForm.businessType);
+ const destinationOptions = getDestinationsForBusinessType(currentBusinessType);
+ const selectedDestinationOptions = getSelectedDestinationOptions(
+ currentBusinessType,
+ selectedReviewDestinations,
+ );
+ const primaryReviewDestinationKey = getPrimaryReviewDestination(settingsForm, selectedReviewDestinations);
+ const selectedReviewDestination = getReviewDestinationOption(primaryReviewDestinationKey) || destinationOptions[0];
+ const selectedDestinationField = selectedReviewDestination?.field || '';
+ const brandedMessagingLocked = subscriptionStatus?.subscription.planId === 'starter';
+ const previewReplacements = useMemo(() => ({
+ businessName: settingsForm.businessName || 'Your Business',
+ customerName: 'Riley',
+ reviewLink: selectedDestinationField
+ ? String(settingsForm[selectedDestinationField as SetupSettingsKey] || 'https://example.com/review')
+ : 'Hosted review page',
+ }), [selectedDestinationField, settingsForm]);
+ const brandPreviewColor = getSafePreviewColor(settingsForm.brandPrimaryColor);
+ const emailPreviewSubject = renderPreviewTemplate(settingsForm.emailSubjectTemplate, previewReplacements);
+ const emailPreviewBody = renderPreviewTemplate(settingsForm.emailBodyTemplate, previewReplacements);
+ const emailPreviewFooter = renderPreviewTemplate(settingsForm.emailFooterText, previewReplacements);
+ const smsPreview = renderPreviewTemplate(settingsForm.smsTemplate, previewReplacements);
+
+ const hasBusinessInfo = Boolean(settingsForm.businessName.trim());
+ const hasPaymentConnection = Boolean(
+ selectedBusiness?.id && selectedBusiness.providers?.some((provider: any) => provider.connected),
+ );
+ const hasReviewOutput = getReviewOutputIsComplete(settingsForm, selectedReviewDestinations);
+
+ const setupSteps = [
+ {
+ title: 'Business info',
+ description: hasBusinessInfo ? settingsForm.businessName : 'Add the company name and type.',
+ complete: hasBusinessInfo,
+ },
+ {
+ title: 'Payment system connect',
+ description: hasPaymentConnection ? 'At least one payment/order source is connected.' : 'Connect Stripe, Square, PayPal, Shopify, or WooCommerce.',
+ complete: hasPaymentConnection,
+ },
+ {
+ title: 'Review output connect',
+ description: hasReviewOutput
+ ? formatReviewDestinationList(selectedDestinationOptions)
+ : 'Choose where customers should leave reviews.',
+ complete: hasReviewOutput,
+ },
+ ];
+
+ 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) {
+ const nextSettings = businessToSettings(primaryBusiness);
+ setSelectedBusinessId(primaryBusiness.id);
+ setSettingsForm(nextSettings);
+ setSelectedReviewDestinations(getSelectedDestinationsFromSettings(nextSettings));
+ setReviewOutputFinished(getReviewOutputIsComplete(nextSettings, getSelectedDestinationsFromSettings(nextSettings)));
+ }
+ } catch (requestError) {
+ console.error('Failed to load Setup:', requestError);
+ setError('Could not load Setup. Refresh the page or try again.');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ loadData();
+ }, []);
+
+ const updateSettings = (key: SetupSettingsKey, value: string | boolean) => {
+ setSettingsForm((current) => {
+ if (key === 'businessType') {
+ const businessType = normalizeBusinessType(String(value));
+ const nextReviewDestination = coerceDestination(businessType, current.reviewDestination);
+ const nextSelectedDestinations = selectedReviewDestinations.filter((destinationKey) =>
+ getDestinationsForBusinessType(businessType).some((destination) => destination.key === destinationKey),
+ );
+
+ if (nextSelectedDestinations.length === 0 && isReviewDestinationKey(nextReviewDestination)) {
+ nextSelectedDestinations.push(nextReviewDestination);
+ }
+
+ setSelectedReviewDestinations(nextSelectedDestinations);
+ setReviewOutputFinished(false);
+
+ return {
+ ...current,
+ businessType,
+ reviewDestination: nextReviewDestination,
+ };
+ }
+
+ if (key === 'reviewDestination') {
+ setReviewOutputFinished(false);
+ }
+
+ return { ...current, [key]: value };
+ });
+ };
+
+ const selectBusiness = (businessId: string) => {
+ const business = businesses.find((item) => item.id === businessId);
+ const nextSettings = businessToSettings(business);
+ setSelectedBusinessId(businessId);
+ setSettingsForm(nextSettings);
+ setSelectedReviewDestinations(getSelectedDestinationsFromSettings(nextSettings));
+ setReviewOutputFinished(getReviewOutputIsComplete(nextSettings, getSelectedDestinationsFromSettings(nextSettings)));
+ };
+
+ const toggleReviewDestination = (destinationKey: ReviewDestinationKey) => {
+ const destination = getReviewDestinationOption(destinationKey);
+
+ if (!destination) return;
+
+ const isCurrentlySelected = selectedReviewDestinations.includes(destinationKey);
+
+ setSelectedReviewDestinations((current) => (
+ current.includes(destinationKey)
+ ? current.filter((key) => key !== destinationKey)
+ : [...current, destinationKey]
+ ));
+
+ if (isCurrentlySelected && destination.field) {
+ const field = destination.field as ReviewDestinationField;
+ setSettingsForm((current) => ({ ...current, [field]: '' }));
+ }
+
+ setReviewOutputFinished(false);
+ };
+
+ const validateReviewOutput = () => {
+ const selectedDestinations = getSelectedDestinationOptions(
+ currentBusinessType,
+ selectedReviewDestinations,
+ );
+
+ if (selectedDestinations.length === 0) {
+ setError('Please check at least one review site before pressing Finish.');
+ return false;
+ }
+
+ const missingDestination = selectedDestinations.find((destination) =>
+ destination.field && !String(settingsForm[destination.field as ReviewDestinationField] || '').trim(),
+ );
+
+ if (missingDestination) {
+ setError(`Please paste the review page link for ${missingDestination.label}.`);
+ return false;
+ }
+
+ return true;
+ };
+
+ const saveSettings = async (options: { finishReviewOutput?: boolean } = {}) => {
+ if (options.finishReviewOutput) {
+ setMessage('');
+ }
+
+ if (options.finishReviewOutput && !validateReviewOutput()) {
+ return;
+ }
+
+ setIsSaving(true);
+ setMessage('');
+ setError('');
+
+ try {
+ const reviewDestination = getPrimaryReviewDestination(settingsForm, selectedReviewDestinations);
+ const response = await axios.put('/reviewflow/growth-tools/business', {
+ businessId: selectedBusinessId,
+ ...settingsForm,
+ reviewDestination,
+ delayDays: Number(settingsForm.delayDays),
+ followupDelayDays: Number(settingsForm.followupDelayDays),
+ maxFollowups: Number(settingsForm.maxFollowups),
+ });
+ const business = response.data.business as ReviewBusiness;
+ const nextSettings = businessToSettings(business);
+ setSelectedBusinessId(business.id);
+ setSettingsForm(nextSettings);
+ setSelectedReviewDestinations(getSelectedDestinationsFromSettings(nextSettings));
+ setReviewOutputFinished(Boolean(options.finishReviewOutput));
+ setMessage(options.finishReviewOutput
+ ? 'Review destinations saved to the company profile. Email and SMS messages will use these review links.'
+ : 'Setup saved. Your Growth Tools now use these company, payment, and review-output defaults.');
+ await loadData();
+ } catch (requestError) {
+ console.error('Failed to save Setup:', requestError);
+ if (axios.isAxiosError(requestError) && requestError.response?.data) {
+ setError(String(requestError.response.data));
+ } else {
+ setError('Could not save Setup. Please try again.');
+ }
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ const finishReviewOutput = () => saveSettings({ finishReviewOutput: true });
+
+ const handleProviderConnected = async (
+ business: ReviewBusiness,
+ connectorForm: ConnectorFormValues,
+ ) => {
+ const businessType = normalizeBusinessType(connectorForm.businessType);
+ setSelectedBusinessId(business.id);
+ setSettingsForm((current) => ({
+ ...businessToSettings(business),
+ businessName: connectorForm.businessName || business.name || current.businessName,
+ businessType,
+ reviewDestination: coerceDestination(businessType, connectorForm.reviewDestination),
+ delayDays: connectorForm.delayDays || current.delayDays,
+ }));
+ setMessage(`${connectorForm.provider.toUpperCase()} connected. Copy the generated webhook URL into that provider to finish the payment trigger.`);
+ await loadData();
+ };
+
+ return (
+ <>
+
+ {getPageTitle('Setup')}
+
+
+
+ {''}
+
+
+
+
+
+
+ One-time configuration · return whenever details change
+
+
+ Connect the company, payments, and review destinations here.
+
+
+ Setup is now the home for business info, payment/order source connections, and review output messaging. Growth Tools stays focused on actions that grow reviews after setup is complete.
+
+
+
+ {setupSteps.map((step, index) => (
+
+
+
+ {index + 1}
+
+
+
{step.title}
+
{step.description}
+
+
+
+ ))}
+
+
+
+
+ {message && (
+
+ Done. {message}
+
+ )}
+ {error && (
+
+
{error}
+ {error.includes('Upgrade to Pro') && (
+
+ )}
+
+ )}
+
+
+ {setupSteps.map((step) => (
+
+
{step.complete ? 'Complete' : 'Needs setup'}
+
{step.title}
+
{step.description}
+
+ ))}
+
+
+
+
+
+
Step 1
+
Business info
+
+ This tells the app who the business is and which workflow type should be shown. For your logistics/transportation app, most companies will usually start as Local or Hybrid.
+
+
+
+
+
+ {businesses.length > 0 && (
+
+ selectBusiness(event.target.value)}>
+ {businesses.map((business) => (
+ {business.name || 'Business'}
+ ))}
+
+
+ )}
+
+ option.key === currentBusinessType)?.help}>
+ updateSettings('businessName', event.target.value)}
+ placeholder='Business name'
+ />
+ updateSettings('businessType', event.target.value)}>
+ {businessTypeOptions.map((option) => (
+ {option.label}
+ ))}
+
+
+
+
+ updateSettings('delayDays', event.target.value)}
+ placeholder='Initial delay days'
+ />
+ updateSettings('followupDelayDays', event.target.value)}
+ placeholder='Follow-up delay days'
+ />
+ updateSettings('maxFollowups', event.target.value)}
+ placeholder='Max follow-ups'
+ />
+
+
+
+ updateSettings('followupEnabled', event.target.checked)}
+ />
+
+ Enable follow-ups
+ Prepare follow-up handoffs for customers who have not clicked the first request.
+
+
+
+
+
+
+
+
+
+
Step 3
+
Review output connect
+
+ Choose where review requests send customers, then set the brand and message customers will see.
+
+
+ {brandedMessagingLocked ? (
+
+ ) : (
+
Pro branding enabled
+ )}
+
+
+ {reviewOutputFinished && hasReviewOutput ? (
+
+
+
+
Finished
+
Current review selections
+
+ These choices are saved on the company profile. Review Flow stores the links here so email and SMS review notifications can use them.
+
+
+
setReviewOutputFinished(false)} />
+
+
+
+
+
Business type
+
{businessTypeOptions.find((option) => option.key === currentBusinessType)?.label}
+
+
+
Default email/SMS link
+
{selectedReviewDestination?.label || 'Review link'}
+
This is the link used by the current {'{reviewLink}'} message placeholder.
+
+
+
+
+ {selectedDestinationOptions.map((destination) => {
+ const link = destination.field
+ ? String(settingsForm[destination.field as ReviewDestinationField] || '').trim()
+ : '';
+
+ return (
+
+
{destination.label}
+ {link ? (
+
+ {link}
+
+ ) : (
+
Hosted review page. No outside link is needed.
+ )}
+
+ );
+ })}
+
+
+ ) : (
+
+
+
Simple instructions
+
+ Pick the business type. This controls which review sites appear.
+ Check every review site you want customers to use. You can choose more than one.
+ For each checked site, paste the exact review page link. This is the page customers should open from the email or text message.
+ Press Finish . Your choices will save to the company profile.
+
+
This review-site setup is available on every plan.
+
+
+
+ updateSettings('businessType', event.target.value)}>
+ {businessTypeOptions.map((option) => (
+ {option.label}
+ ))}
+
+
+
+
+
Available review sites
+
+ Check all the places where you want customers to leave reviews. Unchecking a site removes its saved link from this setup.
+
+
+ {destinationOptions.map((destination) => {
+ const isSelected = selectedReviewDestinations.includes(destination.key);
+
+ return (
+
+ toggleReviewDestination(destination.key)}
+ type='checkbox'
+ />
+
+ {destination.label}
+
+ {destination.field
+ ? 'Needs a review page link after you check it.'
+ : 'No outside link needed. Review Flow creates the hosted review page.'}
+
+
+
+ );
+ })}
+
+
+
+ {selectedDestinationOptions.length > 0 && (
+
+
Review page links
+
+ Paste one link for each checked site. If you do not know the link yet, open that review site, copy the page address, and paste it here.
+
+
+ {selectedDestinationOptions.map((destination) => {
+ if (!destination.field) {
+ return (
+
+
{destination.label}
+
No link is required. Review Flow creates the hosted review page for paid Shopify orders.
+
+ );
+ }
+
+ const field = destination.field as ReviewDestinationField;
+
+ return (
+
+ {
+ setReviewOutputFinished(false);
+ updateSettings(field, event.target.value);
+ }}
+ placeholder='Paste review page link here'
+ />
+
+ );
+ })}
+
+
+ )}
+
+
+ Email and SMS templates use the saved review link placeholder: {'{reviewLink}'} . The first checked site with a link becomes the default link for current notifications.
+
+
+
+
+
+
+ )}
+
+ {brandedMessagingLocked && (
+
+ Grow can use the default Review Flow message. Upgrade to Pro to save custom sender, logo, color, email, and SMS templates.
+
+ )}
+
+
+
+
+ updateSettings('brandLogoUrl', event.target.value)} placeholder='https://example.com/logo.png' />
+ updateSettings('brandPrimaryColor', event.target.value)} placeholder='#4f46e5' />
+ updateSettings('emailSenderName', event.target.value)} placeholder='Sender name, e.g. Acme Logistics' />
+ updateSettings('emailReplyTo', event.target.value)} placeholder='Reply-to email, e.g. support@example.com' />
+
+
+
+ updateSettings('emailSubjectTemplate', event.target.value)} placeholder='Email subject' />
+ updateSettings('emailBodyTemplate', event.target.value)} placeholder='Email body' />
+
+
+
+ updateSettings('emailFooterText', event.target.value)} placeholder='Email footer text' />
+ updateSettings('smsTemplate', event.target.value)} placeholder='SMS template' />
+
+
+
+
+
+
Email preview
+
+ {settingsForm.brandLogoUrl ? (
+
+ ) : (
+
Logo optional
+ )}
+
{emailPreviewSubject}
+
{emailPreviewBody}
+
Leave a review
+
+
{emailPreviewFooter}
+
+
+
+
+
SMS preview
+
{smsPreview.length} characters
+
+
{smsPreview}
+
+
+
+
+
+
+ saveSettings()} disabled={isSaving} />
+
+
+
+
+
+ Troubleshooting
+ Recent payment events
+ Use this only when checking whether provider webhooks are arriving.
+ {recentEvents.length === 0 ? (
+ No provider webhooks received yet.
+ ) : (
+
+ {recentEvents.slice(0, 5).map((event) => (
+
+
+
+
{(event.provider || 'provider').toUpperCase()} · {event.provider_event_type || event.event_type || 'unknown event'}
+
{event.business?.name || 'Business'} · {formatDate(event.createdAt)}
+ {event.processing_error &&
{event.processing_error}
}
+
+
{event.processed ? 'processed' : 'pending'}
+
+
+ ))}
+
+ )}
+
+
+
+ Troubleshooting
+ Recent transactions
+ Transactions prove a connected payment/order source can create review triggers.
+ {recentTransactions.length === 0 ? (
+ No transactions created from webhooks yet.
+ ) : (
+
+ {recentTransactions.slice(0, 5).map((transaction) => (
+
+
+
+
{formatAmount(transaction.amount, transaction.currency)}
+
{transaction.payment_provider || 'provider'} · {transaction.customer?.email || transaction.receipt_email || 'No email'} · {formatDate(transaction.paid_at)}
+
+
{transaction.currency || 'USD'}
+
+
+ ))}
+
+ )}
+
+
+
+ >
+ );
+}
+
+SetupPage.getLayout = function getLayout(page: ReactElement) {
+ return {page} ;
+};
diff --git a/frontend/src/subscriptionPlans.ts b/frontend/src/subscriptionPlans.ts
index 1e61fe2..9d8272f 100644
--- a/frontend/src/subscriptionPlans.ts
+++ b/frontend/src/subscriptionPlans.ts
@@ -68,6 +68,7 @@ export const subscriptionPlans: SubscriptionPlan[] = [
'NPS survey campaign queueing',
'Marketing broadcasts and repeat-business campaigns',
'Competitor insight workspace',
+ 'Branded email and SMS templates',
'2,500 review requests per month',
'Up to 10 business profiles',
'Up to 10 team members',