From fd155575d438768b3da2e89ac02eb658fb043c4d Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Tue, 30 Jun 2026 02:58:29 +0000 Subject: [PATCH] Base --- ...30000-add-basic-business-profile-fields.js | 75 + backend/src/db/models/businesses.js | 40 + backend/src/routes/reviewflow.js | 45 +- backend/src/services/reviewflow.js | 10 + frontend/src/pages/setup.tsx | 1434 +++++++++++++---- 5 files changed, 1284 insertions(+), 320 deletions(-) create mode 100644 backend/src/db/migrations/20260630030000-add-basic-business-profile-fields.js diff --git a/backend/src/db/migrations/20260630030000-add-basic-business-profile-fields.js b/backend/src/db/migrations/20260630030000-add-basic-business-profile-fields.js new file mode 100644 index 0000000..18d5e3f --- /dev/null +++ b/backend/src/db/migrations/20260630030000-add-basic-business-profile-fields.js @@ -0,0 +1,75 @@ +'use strict'; + +const businessColumns = { + street_address: { type: 'TEXT' }, + address_line_2: { type: 'TEXT' }, + city: { type: 'TEXT' }, + state: { type: 'TEXT' }, + postal_code: { type: 'TEXT' }, + country: { type: 'TEXT' }, + website_url: { type: 'TEXT' }, + business_phone: { type: 'TEXT' }, + business_email: { type: 'TEXT' }, + timezone: { type: 'TEXT' }, +}; + +function normalizeColumnDefinition(Sequelize, definition) { + const normalized = { ...definition }; + + if (definition.type === 'TEXT') { + normalized.type = Sequelize.DataTypes.TEXT; + } + + return normalized; +} + +async function addColumnsIfMissing(queryInterface, Sequelize, transaction, tableName, columns) { + const table = await queryInterface.describeTable(tableName); + + for (const [columnName, definition] of Object.entries(columns)) { + if (!table[columnName]) { + await queryInterface.addColumn( + tableName, + columnName, + normalizeColumnDefinition(Sequelize, definition), + { transaction }, + ); + } + } +} + +async function removeColumnsIfPresent(queryInterface, transaction, tableName, columns) { + const table = await queryInterface.describeTable(tableName); + + for (const columnName of Object.keys(columns).reverse()) { + if (table[columnName]) { + await queryInterface.removeColumn(tableName, columnName, { transaction }); + } + } +} + +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'businesses', businessColumns); + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + await removeColumnsIfPresent(queryInterface, transaction, 'businesses', businessColumns); + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/backend/src/db/models/businesses.js b/backend/src/db/models/businesses.js index aa0abaf..d573da0 100644 --- a/backend/src/db/models/businesses.js +++ b/backend/src/db/models/businesses.js @@ -15,6 +15,46 @@ name: { }, +street_address: { + type: DataTypes.TEXT, + }, + +address_line_2: { + type: DataTypes.TEXT, + }, + +city: { + type: DataTypes.TEXT, + }, + +state: { + type: DataTypes.TEXT, + }, + +postal_code: { + type: DataTypes.TEXT, + }, + +country: { + type: DataTypes.TEXT, + }, + +website_url: { + type: DataTypes.TEXT, + }, + +business_phone: { + type: DataTypes.TEXT, + }, + +business_email: { + type: DataTypes.TEXT, + }, + +timezone: { + type: DataTypes.TEXT, + }, + google_review_link: { type: DataTypes.TEXT, diff --git a/backend/src/routes/reviewflow.js b/backend/src/routes/reviewflow.js index a076e3b..453e37b 100644 --- a/backend/src/routes/reviewflow.js +++ b/backend/src/routes/reviewflow.js @@ -108,6 +108,21 @@ function normalizeOptionalUrl(value, message) { return normalized; } +function normalizeOptionalWebsiteUrl(value) { + const normalized = normalizeString(value); + + if (!normalized) { + return ''; + } + + const url = /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(normalized) + ? normalized + : `https://${normalized}`; + + validateUrl(url, 'Website must be a valid URL.'); + return url; +} + function normalizeOptionalDate(value, message) { const normalized = normalizeString(value); @@ -136,11 +151,11 @@ function normalizeDefaultReviewPlatform(value, fallback = 'google') { return DEFAULT_REVIEW_PLATFORMS.has(fallback) ? fallback : 'google'; } -function normalizeOptionalEmail(value) { +function normalizeOptionalEmail(value, message = 'Reply-to email must be a valid email address.') { const normalized = normalizeString(value).toLowerCase(); if (normalized && !EMAIL_PATTERN.test(normalized)) { - const error = new Error('Reply-to email must be a valid email address.'); + const error = new Error(message); error.code = 400; throw error; } @@ -507,6 +522,19 @@ router.put('/growth-tools/business', wrapAsync(async (req, res) => { business = await db.businesses.create({ name: businessName, business_type: normalizeBusinessType(body.businessType || body.business_type, 'hybrid'), + street_address: normalizeString(body.streetAddress ?? body.street_address), + address_line_2: normalizeString(body.addressLine2 ?? body.address_line_2), + city: normalizeString(body.city), + state: normalizeString(body.state), + postal_code: normalizeString(body.postalCode ?? body.postal_code), + country: normalizeString(body.country), + website_url: normalizeOptionalWebsiteUrl(body.websiteUrl ?? body.website_url), + business_phone: normalizeString(body.businessPhone ?? body.business_phone), + business_email: normalizeOptionalEmail( + body.businessEmail ?? body.business_email, + 'Business email must be a valid email address.', + ), + timezone: normalizeString(body.timezone), automation_mode: 'set_and_forget', is_active: parseBoolean(body.isActive ?? body.is_active, true), createdById: currentUser.id, @@ -557,6 +585,19 @@ router.put('/growth-tools/business', wrapAsync(async (req, res) => { const updatePayload = { name: businessName || business.name, business_type: businessType, + street_address: normalizeString(body.streetAddress ?? body.street_address ?? business.street_address), + address_line_2: normalizeString(body.addressLine2 ?? body.address_line_2 ?? business.address_line_2), + city: normalizeString(body.city ?? business.city), + state: normalizeString(body.state ?? business.state), + postal_code: normalizeString(body.postalCode ?? body.postal_code ?? business.postal_code), + country: normalizeString(body.country ?? business.country), + website_url: normalizeOptionalWebsiteUrl(body.websiteUrl ?? body.website_url ?? business.website_url), + business_phone: normalizeString(body.businessPhone ?? body.business_phone ?? business.business_phone), + business_email: normalizeOptionalEmail( + body.businessEmail ?? body.business_email ?? business.business_email, + 'Business email must be a valid email address.', + ), + timezone: normalizeString(body.timezone ?? business.timezone), automation_mode: normalizeString(body.automationMode || body.automation_mode) || 'set_and_forget', ownerId, review_destination: reviewDestination, diff --git a/backend/src/services/reviewflow.js b/backend/src/services/reviewflow.js index 31e8869..2682ece 100644 --- a/backend/src/services/reviewflow.js +++ b/backend/src/services/reviewflow.js @@ -1013,6 +1013,16 @@ function serializeBusiness(req, business) { return { id: business.id, name: business.name, + street_address: business.street_address || '', + address_line_2: business.address_line_2 || '', + city: business.city || '', + state: business.state || '', + postal_code: business.postal_code || '', + country: business.country || '', + website_url: business.website_url || '', + business_phone: business.business_phone || '', + business_email: business.business_email || '', + timezone: business.timezone || '', ownerId: business.ownerId || '', is_active: business.is_active !== false, stripe_account_reference: business.stripe_account_reference || '', diff --git a/frontend/src/pages/setup.tsx b/frontend/src/pages/setup.tsx index 977ed25..06c9c3a 100644 --- a/frontend/src/pages/setup.tsx +++ b/frontend/src/pages/setup.tsx @@ -4,6 +4,7 @@ import { mdiOpenInNew, mdiRefresh, mdiStarCircleOutline, + mdiUpload, } from '@mdi/js'; import axios from 'axios'; import Head from 'next/head'; @@ -12,6 +13,7 @@ import React, { ReactElement, useEffect, useMemo, useState } from 'react'; import PaymentProviderConnectors, { ConnectorFormValues, } from '../components/ReviewFlow/PaymentProviderConnectors'; +import FileUploader from '../components/Uploaders/UploadService'; import BaseButton from '../components/BaseButton'; import CardBox from '../components/CardBox'; import FormField from '../components/FormField'; @@ -33,6 +35,16 @@ type ReviewBusiness = { stripe_connected_at?: string | null; default_review_platform?: string; business_type?: BusinessType; + street_address?: string; + address_line_2?: string; + city?: string; + state?: string; + postal_code?: string; + country?: string; + website_url?: string; + business_phone?: string; + business_email?: string; + timezone?: string; review_destination?: string; delay_days?: number; followup_enabled?: boolean; @@ -119,7 +131,11 @@ type SubscriptionStatusResponse = { }; }; -const businessTypeOptions: Array<{ key: BusinessType; label: string; help: string }> = [ +const businessTypeOptions: Array<{ + key: BusinessType; + label: string; + help: string; +}> = [ { key: 'local', label: 'Local / service business', @@ -139,13 +155,38 @@ const businessTypeOptions: Array<{ key: BusinessType; label: string; help: strin const reviewDestinationOptions = [ { key: 'google', label: 'Google', scope: 'local', field: 'googleReviewLink' }, - { key: 'facebook', label: 'Facebook', scope: 'local', field: 'facebookReviewLink' }, + { + 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' }, + { + 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]; @@ -156,6 +197,16 @@ const defaultSettings = { ownerId: '', businessName: 'Review Flow Business', businessType: 'hybrid' as BusinessType, + streetAddress: '', + addressLine2: '', + city: '', + state: '', + postalCode: '', + country: 'United States', + websiteUrl: '', + businessPhone: '', + businessEmail: '', + timezone: '', reviewDestination: 'google', delayDays: '7', followupEnabled: true, @@ -163,7 +214,8 @@ const defaultSettings = { maxFollowups: '1', aiReplyEnabled: false, referralEnabled: false, - referralOffer: 'Give $25, get $25 when a referred customer completes their first purchase.', + 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, @@ -188,7 +240,8 @@ const defaultSettings = { 'Thank you,', '{businessName}', ].join('\n'), - smsTemplate: 'Thanks for choosing {businessName}. Please leave a review: {reviewLink}', + smsTemplate: + 'Thanks for choosing {businessName}. Please leave a review: {reviewLink}', googleReviewLink: '', yelpReviewLink: '', facebookReviewLink: '', @@ -203,11 +256,18 @@ const defaultSettings = { defaultReviewPlatform: 'google', }; +const emailLogoUploadSchema = { + image: true, + size: 2 * 1024 * 1024, + formats: ['png', 'jpg', 'jpeg', 'webp', 'gif', 'svg'], +}; + type SetupSettings = typeof defaultSettings; type SetupSettingsKey = keyof SetupSettings; function normalizeBusinessType(value?: string): BusinessType { - if (value === 'local' || value === 'online' || value === 'hybrid') return value; + if (value === 'local' || value === 'online' || value === 'hybrid') + return value; return 'hybrid'; } @@ -244,13 +304,19 @@ function isReviewDestinationKey(value: string): value is ReviewDestinationKey { return reviewDestinationOptions.some((option) => option.key === value); } -function getSelectedDestinationsFromSettings(settings: SetupSettings): ReviewDestinationKey[] { +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 Boolean( + String( + settings[destination.field as ReviewDestinationField] || '', + ).trim(), + ); } return destination.key === settings.reviewDestination; @@ -274,23 +340,47 @@ function getPrimaryReviewDestination( 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(), + 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); + return ( + firstLinkedDestination?.key || + selectedDestinations[0]?.key || + coerceDestination(businessType, settings.reviewDestination) + ); } -function getReviewOutputIsComplete(settings: SetupSettings, selectedKeys: ReviewDestinationKey[]) { +function getReviewOutputIsComplete( + settings: SetupSettings, + selectedKeys: ReviewDestinationKey[], +) { const businessType = normalizeBusinessType(settings.businessType); - const selectedDestinations = getSelectedDestinationOptions(businessType, selectedKeys); + const selectedDestinations = getSelectedDestinationOptions( + businessType, + selectedKeys, + ); - return selectedDestinations.length > 0 && selectedDestinations.every((destination) => { - if (!destination.field) return true; + return ( + selectedDestinations.length > 0 && + selectedDestinations.every((destination) => { + if (!destination.field) return true; - return Boolean(String(settings[destination.field as ReviewDestinationField] || '').trim()); - }); + return Boolean( + String( + settings[destination.field as ReviewDestinationField] || '', + ).trim(), + ); + }) + ); } function formatReviewDestinationList(destinations: ReviewDestinationOption[]) { @@ -305,6 +395,15 @@ function getBlankBusinessSettings(ownerId = ''): SetupSettings { ...defaultSettings, ownerId, businessName: '', + streetAddress: '', + addressLine2: '', + city: '', + state: '', + postalCode: '', + websiteUrl: '', + businessPhone: '', + businessEmail: '', + timezone: '', delayDays: '', emailSubjectTemplate: '', emailBodyTemplate: '', @@ -329,7 +428,10 @@ function toDateTimeLocalValue(value?: string | null) { } function getBusinessTypeLabel(businessType: BusinessType) { - return businessTypeOptions.find((option) => option.key === businessType)?.label || 'Hybrid business'; + return ( + businessTypeOptions.find((option) => option.key === businessType)?.label || + 'Hybrid business' + ); } function getDefaultReviewPlatformLabel(value?: string) { @@ -352,7 +454,20 @@ function businessToSettings(business?: ReviewBusiness | null): SetupSettings { ownerId: business.ownerId || defaultSettings.ownerId, businessName: business.name || '', businessType, - reviewDestination: coerceDestination(businessType, business.review_destination || business.default_review_platform), + streetAddress: business.street_address || '', + addressLine2: business.address_line_2 || '', + city: business.city || '', + state: business.state || '', + postalCode: business.postal_code || '', + country: business.country || defaultSettings.country, + websiteUrl: business.website_url || '', + businessPhone: business.business_phone || '', + businessEmail: business.business_email || '', + timezone: business.timezone || '', + reviewDestination: coerceDestination( + businessType, + business.review_destination || business.default_review_platform, + ), delayDays: String(business.delay_days ?? 7), followupEnabled: business.followup_enabled !== false, followupDelayDays: String(business.followup_delay_days ?? 3), @@ -369,12 +484,17 @@ function businessToSettings(business?: ReviewBusiness | null): SetupSettings { 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, + 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, + 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 || '', @@ -394,11 +514,16 @@ function businessToSettings(business?: ReviewBusiness | null): SetupSettings { function escapePreviewRegExp(value: string) { return value .split('') - .map((character) => ('^$*+?.()|{}[]\\'.includes(character) ? '\\' + character : character)) + .map((character) => + '^$*+?.()|{}[]\\'.includes(character) ? '\\' + character : character, + ) .join(''); } -function renderPreviewTemplate(template: string, replacements: Record) { +function renderPreviewTemplate( + template: string, + replacements: Record, +) { return Object.entries(replacements).reduce((output, [key, value]) => { const safeKey = escapePreviewRegExp(key); return output @@ -448,18 +573,22 @@ export default function SetupPage() { const router = useRouter(); const { currentUser } = useAppSelector((state) => state.auth); const [summary, setSummary] = useState(null); - const [subscriptionStatus, setSubscriptionStatus] = useState(null); + const [subscriptionStatus, setSubscriptionStatus] = + useState(null); const [selectedBusinessId, setSelectedBusinessId] = useState(''); - const [settingsForm, setSettingsForm] = useState(defaultSettings); + const [settingsForm, setSettingsForm] = + useState(defaultSettings); const [message, setMessage] = useState(''); const [error, setError] = useState(''); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); + const [isUploadingLogo, setIsUploadingLogo] = useState(false); const [businessInfoEditing, setBusinessInfoEditing] = useState(true); - const [isCreatingBusinessProfile, setIsCreatingBusinessProfile] = useState(false); - const [selectedReviewDestinations, setSelectedReviewDestinations] = useState([ - 'google', - ]); + const [isCreatingBusinessProfile, setIsCreatingBusinessProfile] = + useState(false); + const [selectedReviewDestinations, setSelectedReviewDestinations] = useState< + ReviewDestinationKey[] + >(['google']); const [reviewOutputFinished, setReviewOutputFinished] = useState(false); const businesses = summary?.businesses || []; @@ -468,57 +597,111 @@ export default function SetupPage() { const selectedBusiness = useMemo(() => { if (isCreatingBusinessProfile) return null; if (selectedBusinessId) { - return businesses.find((business) => business.id === selectedBusinessId) || null; + return ( + businesses.find((business) => business.id === selectedBusinessId) || + null + ); } return summary?.primaryBusiness || null; - }, [businesses, isCreatingBusinessProfile, selectedBusinessId, summary?.primaryBusiness]); + }, [ + businesses, + isCreatingBusinessProfile, + selectedBusinessId, + summary?.primaryBusiness, + ]); const currentBusinessType = normalizeBusinessType(settingsForm.businessType); - const destinationOptions = getDestinationsForBusinessType(currentBusinessType); + const destinationOptions = + getDestinationsForBusinessType(currentBusinessType); const selectedDestinationOptions = getSelectedDestinationOptions( currentBusinessType, selectedReviewDestinations, ); - const primaryReviewDestinationKey = getPrimaryReviewDestination(settingsForm, selectedReviewDestinations); - const selectedReviewDestination = getReviewDestinationOption(primaryReviewDestinationKey) || destinationOptions[0]; + const primaryReviewDestinationKey = getPrimaryReviewDestination( + settingsForm, + selectedReviewDestinations, + ); + const selectedReviewDestination = + getReviewDestinationOption(primaryReviewDestinationKey) || + destinationOptions[0]; const selectedDestinationField = selectedReviewDestination?.field || ''; - const brandedMessagingLocked = subscriptionStatus?.subscription.planId === 'starter'; - const currentUserName = [currentUser?.firstName, currentUser?.lastName].filter(Boolean).join(' '); + const brandedMessagingLocked = + subscriptionStatus?.subscription.planId === 'starter'; + const currentUserName = [currentUser?.firstName, currentUser?.lastName] + .filter(Boolean) + .join(' '); const ownerLabel = currentUserName || currentUser?.email || 'Current user'; const businessLimit = Number(subscriptionStatus?.limits?.businesses ?? 1); - const businessCount = Number(subscriptionStatus?.usage?.businesses ?? businesses.length); + const businessCount = Number( + subscriptionStatus?.usage?.businesses ?? businesses.length, + ); const isMultiBusinessPlan = businessLimit > 1; const canStartNewBusiness = businessCount < businessLimit; const canAddAnotherBusiness = isMultiBusinessPlan && canStartNewBusiness; const businessLimitText = `${subscriptionStatus?.subscription.planName || 'Your plan'} includes ${businessLimit} ${businessLimit === 1 ? 'business profile' : 'business profiles'}.`; - 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(!isCreatingBusinessProfile && selectedBusinessId && settingsForm.businessName.trim()); - const hasPaymentConnection = Boolean( - selectedBusiness?.id && selectedBusiness.providers?.some((provider: any) => provider.connected), + 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( + !isCreatingBusinessProfile && + selectedBusinessId && + settingsForm.businessName.trim(), + ); + const hasPaymentConnection = Boolean( + selectedBusiness?.id && + selectedBusiness.providers?.some((provider: any) => provider.connected), + ); + const hasReviewOutput = getReviewOutputIsComplete( + settingsForm, + selectedReviewDestinations, + ); + const hasMessageTemplates = Boolean( + settingsForm.emailSubjectTemplate.trim() && + settingsForm.emailBodyTemplate.trim() && + settingsForm.smsTemplate.trim(), ); - const hasReviewOutput = getReviewOutputIsComplete(settingsForm, selectedReviewDestinations); const setupSteps = [ { title: 'Business info', - description: hasBusinessInfo ? settingsForm.businessName : 'Add the company name and type.', + description: hasBusinessInfo + ? settingsForm.businessName + : 'Add basic profile and contact details.', complete: hasBusinessInfo, }, { title: 'Payment system connect', - description: hasPaymentConnection ? 'At least one payment/order source is connected.' : 'Connect Stripe, Square, PayPal, Shopify, or WooCommerce.', + description: hasPaymentConnection + ? 'At least one payment/order source is connected.' + : 'Connect Stripe, Square, PayPal, Shopify, or WooCommerce.', complete: hasPaymentConnection, }, { @@ -528,6 +711,13 @@ export default function SetupPage() { : 'Choose where customers should leave reviews.', complete: hasReviewOutput, }, + { + title: 'Email & SMS templates', + description: hasMessageTemplates + ? 'Message templates are ready.' + : 'Set email layout, email wording, and basic SMS text.', + complete: hasMessageTemplates, + }, ]; const loadData = async (options: { startNewBusiness?: boolean } = {}) => { @@ -540,12 +730,19 @@ export default function SetupPage() { axios.get('/subscription/me'), ]); const loadedSummary = summaryResponse.data as SummaryResponse; - const loadedSubscription = subscriptionResponse.data as SubscriptionStatusResponse; + const loadedSubscription = + subscriptionResponse.data as SubscriptionStatusResponse; const loadedBusinesses = loadedSummary.businesses || []; - const primaryBusiness = loadedSummary.primaryBusiness || loadedBusinesses[0] || null; - const loadedBusinessLimit = Number(loadedSubscription.limits?.businesses ?? 1); - const loadedBusinessCount = Number(loadedSubscription.usage?.businesses ?? loadedBusinesses.length); - const loadedPlanName = loadedSubscription.subscription.planName || 'Your plan'; + const primaryBusiness = + loadedSummary.primaryBusiness || loadedBusinesses[0] || null; + const loadedBusinessLimit = Number( + loadedSubscription.limits?.businesses ?? 1, + ); + const loadedBusinessCount = Number( + loadedSubscription.usage?.businesses ?? loadedBusinesses.length, + ); + const loadedPlanName = + loadedSubscription.subscription.planName || 'Your plan'; setSummary(loadedSummary); setSubscriptionStatus(loadedSubscription); @@ -562,16 +759,21 @@ export default function SetupPage() { return; } - setError(`${loadedPlanName} includes ${loadedBusinessLimit} ${loadedBusinessLimit === 1 ? 'business profile' : 'business profiles'}. Edit your existing business profile or upgrade before adding another.`); + setError( + `${loadedPlanName} includes ${loadedBusinessLimit} ${loadedBusinessLimit === 1 ? 'business profile' : 'business profiles'}. Edit your existing business profile or upgrade before adding another.`, + ); } if (primaryBusiness) { const nextSettings = businessToSettings(primaryBusiness); - const nextSelectedDestinations = getSelectedDestinationsFromSettings(nextSettings); + const nextSelectedDestinations = + getSelectedDestinationsFromSettings(nextSettings); setSelectedBusinessId(primaryBusiness.id); setSettingsForm(nextSettings); setSelectedReviewDestinations(nextSelectedDestinations); - setReviewOutputFinished(getReviewOutputIsComplete(nextSettings, nextSelectedDestinations)); + setReviewOutputFinished( + getReviewOutputIsComplete(nextSettings, nextSelectedDestinations), + ); setBusinessInfoEditing(false); setIsCreatingBusinessProfile(false); } else { @@ -601,12 +803,21 @@ export default function SetupPage() { 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), + const nextReviewDestination = coerceDestination( + businessType, + current.reviewDestination, + ); + const nextSelectedDestinations = selectedReviewDestinations.filter( + (destinationKey) => + getDestinationsForBusinessType(businessType).some( + (destination) => destination.key === destinationKey, + ), ); - if (nextSelectedDestinations.length === 0 && isReviewDestinationKey(nextReviewDestination)) { + if ( + nextSelectedDestinations.length === 0 && + isReviewDestinationKey(nextReviewDestination) + ) { nextSelectedDestinations.push(nextReviewDestination); } @@ -621,7 +832,10 @@ export default function SetupPage() { } if (key === 'defaultReviewPlatform') { - const nextReviewDestination = coerceDestination(current.businessType, String(value)); + const nextReviewDestination = coerceDestination( + current.businessType, + String(value), + ); if (isReviewDestinationKey(nextReviewDestination)) { setSelectedReviewDestinations([nextReviewDestination]); @@ -650,18 +864,23 @@ export default function SetupPage() { if (!business) return; const nextSettings = businessToSettings(business); - const nextSelectedDestinations = getSelectedDestinationsFromSettings(nextSettings); + const nextSelectedDestinations = + getSelectedDestinationsFromSettings(nextSettings); setSelectedBusinessId(businessId); setSettingsForm(nextSettings); setSelectedReviewDestinations(nextSelectedDestinations); - setReviewOutputFinished(getReviewOutputIsComplete(nextSettings, nextSelectedDestinations)); + setReviewOutputFinished( + getReviewOutputIsComplete(nextSettings, nextSelectedDestinations), + ); setBusinessInfoEditing(false); setIsCreatingBusinessProfile(false); }; const startNewBusinessProfile = () => { if (!canStartNewBusiness && businesses.length > 0) { - setError(`${businessLimitText} Edit an existing business profile or upgrade before adding another.`); + setError( + `${businessLimitText} Edit an existing business profile or upgrade before adding another.`, + ); return; } @@ -676,7 +895,9 @@ export default function SetupPage() { setError(''); if (typeof document !== 'undefined') { - document.getElementById('business-info')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + document + .getElementById('business-info') + ?.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }; @@ -685,13 +906,14 @@ export default function SetupPage() { if (!destination) return; - const isCurrentlySelected = selectedReviewDestinations.includes(destinationKey); + const isCurrentlySelected = + selectedReviewDestinations.includes(destinationKey); - setSelectedReviewDestinations((current) => ( + setSelectedReviewDestinations((current) => current.includes(destinationKey) ? current.filter((key) => key !== destinationKey) - : [...current, destinationKey] - )); + : [...current, destinationKey], + ); if (isCurrentlySelected && destination.field) { const field = destination.field as ReviewDestinationField; @@ -712,19 +934,31 @@ export default function SetupPage() { return false; } - const missingDestination = selectedDestinations.find((destination) => - destination.field && !String(settingsForm[destination.field as ReviewDestinationField] || '').trim(), + 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}.`); + setError( + `Please paste the review page link for ${missingDestination.label}.`, + ); return false; } return true; }; - const saveSettings = async (options: { finishReviewOutput?: boolean; collapseBusinessInfo?: boolean } = {}) => { + const saveSettings = async ( + options: { + finishReviewOutput?: boolean; + collapseBusinessInfo?: boolean; + finishMessaging?: boolean; + } = {}, + ) => { if (options.finishReviewOutput) { setMessage(''); } @@ -743,18 +977,29 @@ export default function SetupPage() { setError(''); try { - const reviewDestination = getPrimaryReviewDestination(settingsForm, selectedReviewDestinations); + const reviewDestination = getPrimaryReviewDestination( + settingsForm, + selectedReviewDestinations, + ); const response = await axios.put('/reviewflow/growth-tools/business', { businessId: isCreatingBusinessProfile ? '' : selectedBusinessId, ...settingsForm, reviewDestination, - delayDays: settingsForm.delayDays === '' ? '' : Number(settingsForm.delayDays), - followupDelayDays: settingsForm.followupDelayDays === '' ? '' : Number(settingsForm.followupDelayDays), - maxFollowups: settingsForm.maxFollowups === '' ? '' : Number(settingsForm.maxFollowups), + delayDays: + settingsForm.delayDays === '' ? '' : Number(settingsForm.delayDays), + followupDelayDays: + settingsForm.followupDelayDays === '' + ? '' + : Number(settingsForm.followupDelayDays), + maxFollowups: + settingsForm.maxFollowups === '' + ? '' + : Number(settingsForm.maxFollowups), }); const business = response.data.business as ReviewBusiness; const nextSettings = businessToSettings(business); - const nextSelectedDestinations = getSelectedDestinationsFromSettings(nextSettings); + const nextSelectedDestinations = + getSelectedDestinationsFromSettings(nextSettings); setSelectedBusinessId(business.id); setSettingsForm(nextSettings); setSelectedReviewDestinations(nextSelectedDestinations); @@ -763,11 +1008,15 @@ export default function SetupPage() { if (options.collapseBusinessInfo || isCreatingBusinessProfile) { setBusinessInfoEditing(false); } - setMessage(options.finishReviewOutput - ? 'Review destinations saved to the company profile. Email and SMS messages will use these review links.' - : options.collapseBusinessInfo - ? 'Business info saved. The summary box is ready, and the next setup steps will use this business profile.' - : 'Setup saved. Your Growth Tools now use these company, payment, and review-output defaults.'); + setMessage( + options.finishMessaging + ? 'Email and SMS templates saved. New review requests will use this logo, email layout, and basic SMS text.' + : options.finishReviewOutput + ? 'Review destinations saved to the company profile. Email and SMS messages will use these review links.' + : options.collapseBusinessInfo + ? 'Business info saved. The summary box is ready, and the next setup steps will use this business profile.' + : 'Setup saved. Your Growth Tools now use these company, payment, review-output, and message-template defaults.', + ); await loadData(); } catch (requestError) { console.error('Failed to save Setup:', requestError); @@ -783,6 +1032,44 @@ export default function SetupPage() { const finishReviewOutput = () => saveSettings({ finishReviewOutput: true }); + const saveMessageTemplates = () => saveSettings({ finishMessaging: true }); + + const handleLogoUpload = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + + if (!file) return; + + setIsUploadingLogo(true); + setError(''); + + try { + const uploadedFile = await FileUploader.upload( + 'businesses/brand_logo_url', + file, + emailLogoUploadSchema, + ); + + if (!uploadedFile?.publicUrl) { + throw new Error('Logo upload finished without a public URL.'); + } + + updateSettings('brandLogoUrl', uploadedFile.publicUrl); + setMessage( + 'Logo uploaded. Save email & SMS templates to keep it on this business profile.', + ); + } catch (uploadError) { + console.error('Failed to upload email logo:', uploadError); + setError( + uploadError instanceof Error + ? uploadError.message + : 'Could not upload logo. Please try again.', + ); + } finally { + setIsUploadingLogo(false); + event.target.value = ''; + } + }; + const handleProviderConnected = async ( business: ReviewBusiness, connectorForm: ConnectorFormValues, @@ -793,12 +1080,18 @@ export default function SetupPage() { setBusinessInfoEditing(false); setSettingsForm((current) => ({ ...businessToSettings(business), - businessName: connectorForm.businessName || business.name || current.businessName, + businessName: + connectorForm.businessName || business.name || current.businessName, businessType, - reviewDestination: coerceDestination(businessType, connectorForm.reviewDestination), + reviewDestination: coerceDestination( + businessType, + connectorForm.reviewDestination, + ), delayDays: connectorForm.delayDays || current.delayDays, })); - setMessage(`${connectorForm.provider.toUpperCase()} setup complete. Test transaction received, and the payment trigger summary is ready.`); + setMessage( + `${connectorForm.provider.toUpperCase()} setup complete. Test transaction received, and the payment trigger summary is ready.`, + ); await loadData(); }; @@ -808,7 +1101,11 @@ export default function SetupPage() { {getPageTitle('Setup')} - + {''} @@ -822,19 +1119,28 @@ export default function SetupPage() { 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. + 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}

+

+ {step.description} +

@@ -852,31 +1158,59 @@ export default function SetupPage() {

{error}

{error.includes('Upgrade to Pro') && ( - + )}
)} -
+
{setupSteps.map((step) => ( -
-

{step.complete ? 'Complete' : 'Needs setup'}

+
+

+ {step.complete ? 'Complete' : 'Needs setup'} +

{step.title}

-

{step.description}

+

+ {step.description} +

))}
- +
-

Step 1

-

Business info

+

+ Step 1 +

+

+ Business info +

- Add the business profile the same way the Businesses > Add Business page collects it. Grow accounts can save one business profile; Pro accounts can add more based on the plan limit. + Add the business profile the same way the Businesses > Add + Business page collects it. Grow accounts can save one business + profile; Pro accounts can add more based on the plan limit.

- loadData()} disabled={isLoading} /> + loadData()} + disabled={isLoading} + />
@@ -890,26 +1224,50 @@ export default function SetupPage() {
-

Saved business summary

-

{settingsForm.businessName || selectedBusiness.name || 'Business'}

+

+ Saved business summary +

+

+ {settingsForm.businessName || + selectedBusiness.name || + 'Business'} +

- This is the business profile that payment connections, review links, and automated review requests will use. + This is the business profile that payment connections, + review links, and automated review requests will use.

- setBusinessInfoEditing(true)} /> + setBusinessInfoEditing(true)} + /> {isMultiBusinessPlan && ( - + )}
{businesses.length > 1 && (
- - selectBusiness(event.target.value)} + > {businesses.map((business) => ( - + ))} @@ -926,155 +1284,232 @@ export default function SetupPage() {

{getBusinessTypeLabel(currentBusinessType)}

-

Active

-

{settingsForm.isActive ? 'Yes' : 'No'}

+

Website

+

{settingsForm.websiteUrl || 'Not set'}

-

Review delay days

-

{settingsForm.delayDays || 'Not set'}

+

Business phone

+

{settingsForm.businessPhone || 'Not set'}

-

Default review platform

-

{getDefaultReviewPlatformLabel(settingsForm.defaultReviewPlatform)}

+

Business email

+

{settingsForm.businessEmail || 'Not set'}

-

Stripe connected

-

{settingsForm.stripeConnected ? 'Yes' : 'No'}

+

Timezone

+

{settingsForm.timezone || 'Not set'}

-
-
-

Review links

-

Google: {settingsForm.googleReviewLink || 'Not set'}

-

Yelp: {settingsForm.yelpReviewLink || 'Not set'}

-

Facebook: {settingsForm.facebookReviewLink || 'Not set'}

-

Custom: {settingsForm.customReviewLink || 'Not set'}

-
-
-

Stripe details

-

Account reference: {settingsForm.stripeAccountReference || 'Not set'}

-

Connected at: {settingsForm.stripeConnectedAt ? formatDate(settingsForm.stripeConnectedAt) : 'Not set'}

- {!canAddAnotherBusiness && isMultiBusinessPlan && ( -

Business limit reached for this plan.

- )} -
+
+

Address

+

+ {settingsForm.streetAddress || 'Street address not set'} + {settingsForm.addressLine2 ? `, ${settingsForm.addressLine2}` : ''} +

+

+ {[settingsForm.city, settingsForm.state, settingsForm.postalCode] + .filter(Boolean) + .join(', ') || 'City, state, and ZIP not set'} +

+

{settingsForm.country || 'Country not set'}

+ {!canAddAnotherBusiness && isMultiBusinessPlan && ( +

+ Business limit reached for this plan. +

+ )}
) : (
{isCreatingBusinessProfile ? (
- New business profile. Save this form to create the summary box before connecting payment systems. + New business profile. Save this form to create the summary box + before connecting payment systems.
- ) : businesses.length > 0 && ( - - - + ) : ( + businesses.length > 0 && ( + + + + ) )} - updateSettings('ownerId', event.target.value)} + > + - +
+ + + updateSettings('businessName', event.target.value) + } + placeholder='Business name' + /> + + + + + +
+ + updateSettings('businessName', event.target.value)} - placeholder='Business name' + value={settingsForm.streetAddress} + onChange={(event) => + updateSettings('streetAddress', event.target.value) + } + placeholder='Street address' + /> + + updateSettings('addressLine2', event.target.value) + } + placeholder='Suite, unit, building, dock, etc. (optional)' /> - - - +
+ + updateSettings('city', event.target.value)} + placeholder='City' + /> + - - updateSettings('googleReviewLink', event.target.value)} placeholder='Google review link' /> - + + updateSettings('state', event.target.value)} + placeholder='State' + /> + - - updateSettings('yelpReviewLink', event.target.value)} placeholder='Yelp review link' /> - + + + updateSettings('postalCode', event.target.value) + } + placeholder='ZIP code' + /> + +
- - updateSettings('facebookReviewLink', event.target.value)} placeholder='Facebook review link' /> - +
+ + + updateSettings('country', event.target.value) + } + placeholder='Country' + /> + - - updateSettings('delayDays', event.target.value)} - placeholder='Review delay days' - /> - + + + updateSettings('timezone', event.target.value) + } + placeholder='Example: America/New_York' + /> + +
- - updateSettings('emailSubjectTemplate', event.target.value)} placeholder='Email subject template' /> - +
+ + + updateSettings('websiteUrl', event.target.value) + } + placeholder='https://example.com' + /> + - -