diff --git a/backend/src/db/migrations/20260629231000-add-branded-messaging-to-businesses.js b/backend/src/db/migrations/20260629231000-add-branded-messaging-to-businesses.js new file mode 100644 index 0000000..91dcec5 --- /dev/null +++ b/backend/src/db/migrations/20260629231000-add-branded-messaging-to-businesses.js @@ -0,0 +1,71 @@ +'use strict'; + +const businessColumns = { + brand_logo_url: { type: 'TEXT' }, + brand_primary_color: { type: 'TEXT', defaultValue: '#4f46e5', allowNull: false }, + email_sender_name: { type: 'TEXT' }, + email_reply_to: { type: 'TEXT' }, + email_footer_text: { type: 'TEXT' }, + sms_template: { 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 2ca0e56..aa0abaf 100644 --- a/backend/src/db/models/businesses.js +++ b/backend/src/db/models/businesses.js @@ -57,6 +57,33 @@ email_body_template: { }, + +brand_logo_url: { + type: DataTypes.TEXT, + }, + +brand_primary_color: { + type: DataTypes.TEXT, + allowNull: false, + defaultValue: '#4f46e5', + }, + +email_sender_name: { + type: DataTypes.TEXT, + }, + +email_reply_to: { + type: DataTypes.TEXT, + }, + +email_footer_text: { + type: DataTypes.TEXT, + }, + +sms_template: { + type: DataTypes.TEXT, + }, + is_active: { type: DataTypes.BOOLEAN, diff --git a/backend/src/routes/reviewflow.js b/backend/src/routes/reviewflow.js index f0c5515..363526f 100644 --- a/backend/src/routes/reviewflow.js +++ b/backend/src/routes/reviewflow.js @@ -82,6 +82,91 @@ async function assertProFeatureForEnabledFlag(currentUser, enabled, featureKey) } } +function normalizeHexColor(value, fallback = ReviewFlowService.DEFAULT_BRAND_PRIMARY_COLOR) { + const color = normalizeString(value) || fallback; + + if (/^#[0-9a-fA-F]{6}$/.test(color) || /^#[0-9a-fA-F]{3}$/.test(color)) { + return color; + } + + const error = new Error('Brand color must be a valid hex color, such as #4f46e5.'); + error.code = 400; + throw error; +} + +function normalizeOptionalUrl(value, message) { + const normalized = normalizeString(value); + + if (!normalized) { + return ''; + } + + validateUrl(normalized, message); + return normalized; +} + +function normalizeOptionalEmail(value) { + const normalized = normalizeString(value).toLowerCase(); + + if (normalized && !EMAIL_PATTERN.test(normalized)) { + const error = new Error('Reply-to email must be a valid email address.'); + error.code = 400; + throw error; + } + + return normalized; +} + +function normalizeTemplate(value, fallback) { + return normalizeString(value) || fallback; +} + +function sameTemplateValue(value, candidates) { + const normalized = normalizeString(value).toLowerCase(); + return candidates.some((candidate) => normalizeString(candidate).toLowerCase() === normalized); +} + +function isDefaultBrandedMessagingValue(key, value, businessName) { + const defaultSubject = ReviewFlowService.getDefaultEmailSubjectTemplate(); + const defaultBody = ReviewFlowService.getDefaultEmailBodyTemplate(); + const defaultSms = ReviewFlowService.getDefaultSmsTemplate(); + const defaultFooter = ReviewFlowService.getDefaultEmailFooterTemplate(); + + if (key === 'brand_primary_color') { + return normalizeString(value).toLowerCase() === ReviewFlowService.DEFAULT_BRAND_PRIMARY_COLOR; + } + + if (key === 'email_subject_template') { + return sameTemplateValue(value, [defaultSubject, `How was your experience with ${businessName}?`]); + } + + if (key === 'email_body_template') { + return sameTemplateValue(value, [ + defaultBody, + buildEmailBody('{customerName}', businessName, '{reviewLink}'), + ]); + } + + if (key === 'sms_template') { + return sameTemplateValue(value, [ + defaultSms, + `Thanks for choosing ${businessName}. Please leave a review: {reviewLink}`, + ]); + } + + if (key === 'email_footer_text') { + return sameTemplateValue(value, [defaultFooter, `Sent by Review Flow for ${businessName}.`]); + } + + return !normalizeString(value); +} + +function hasCustomBrandedMessaging(brandedMessagingPayload, businessName) { + return Object.entries(brandedMessagingPayload).some(([key, value]) => ( + !isDefaultBrandedMessagingValue(key, value, businessName) + )); +} + function getReviewLinkField(reviewDestination) { return REVIEW_LINK_FIELDS[reviewDestination] || null; } @@ -257,8 +342,11 @@ router.post('/request', wrapAsync(async (req, res) => { review_destination: reviewDestination, shopify_hosted_reviews_enabled: isHostedReviewDestination, delay_days: delayDays, - email_subject_template: `How was your experience with ${businessName}?`, - email_body_template: buildEmailBody('{customerName}', businessName, '{reviewLink}'), + brand_primary_color: ReviewFlowService.DEFAULT_BRAND_PRIMARY_COLOR, + email_footer_text: ReviewFlowService.getDefaultEmailFooterTemplate(), + email_subject_template: ReviewFlowService.getDefaultEmailSubjectTemplate(), + email_body_template: ReviewFlowService.getDefaultEmailBodyTemplate(), + sms_template: ReviewFlowService.getDefaultSmsTemplate(), is_active: true, createdById: currentUser.id, updatedById: currentUser.id, @@ -312,12 +400,16 @@ router.post('/request', wrapAsync(async (req, res) => { updatedById: currentUser.id, }, { transaction }); - const emailSubject = `How was your experience with ${businessName}?`; + const { emailSubject, emailBody } = ReviewFlowService.buildReviewRequestMessages( + business, + customerName, + effectiveReviewLink, + ); const reviewRequest = await db.review_requests.create({ status: 'pending', scheduled_for: scheduledFor, email_subject: emailSubject, - email_body: buildEmailBody(customerName, businessName, effectiveReviewLink), + email_body: emailBody, review_link: effectiveReviewLink, tracking_token: trackingToken, review_platform: reviewDestination, @@ -414,11 +506,33 @@ router.put('/growth-tools/business', wrapAsync(async (req, res) => { await assertProFeatureForEnabledFlag(currentUser, rebookingEnabled, 'rebooking_campaigns'); await assertProFeatureForEnabledFlag(currentUser, competitorInsightsEnabled, 'competitor_insights'); + const brandedMessagingPayload = { + brand_logo_url: normalizeOptionalUrl(body.brandLogoUrl ?? body.brand_logo_url ?? business.brand_logo_url, 'Brand logo URL must be a valid URL.'), + brand_primary_color: normalizeHexColor(body.brandPrimaryColor ?? body.brand_primary_color ?? business.brand_primary_color), + email_sender_name: normalizeString(body.emailSenderName ?? body.email_sender_name ?? business.email_sender_name), + email_reply_to: normalizeOptionalEmail(body.emailReplyTo ?? body.email_reply_to ?? business.email_reply_to), + email_footer_text: normalizeTemplate(body.emailFooterText ?? body.email_footer_text ?? business.email_footer_text, ReviewFlowService.getDefaultEmailFooterTemplate()), + email_subject_template: normalizeTemplate(body.emailSubjectTemplate ?? body.email_subject_template ?? business.email_subject_template, ReviewFlowService.getDefaultEmailSubjectTemplate()), + email_body_template: normalizeTemplate(body.emailBodyTemplate ?? body.email_body_template ?? business.email_body_template, ReviewFlowService.getDefaultEmailBodyTemplate()), + sms_template: normalizeTemplate(body.smsTemplate ?? body.sms_template ?? business.sms_template, ReviewFlowService.getDefaultSmsTemplate()), + }; + + if (hasCustomBrandedMessaging(brandedMessagingPayload, businessName || business.name || 'Review Flow Business')) { + await SubscriptionService.assertFeatureAccess(currentUser, 'branded_messaging'); + } + const updatePayload = { name: businessName || business.name, business_type: businessType, automation_mode: normalizeString(body.automationMode || body.automation_mode) || 'set_and_forget', review_destination: reviewDestination, + google_review_link: normalizeOptionalUrl(body.googleReviewLink ?? body.google_review_link ?? business.google_review_link, 'Google review link must be a valid URL.'), + yelp_review_link: normalizeOptionalUrl(body.yelpReviewLink ?? body.yelp_review_link ?? business.yelp_review_link, 'Yelp review link must be a valid URL.'), + facebook_review_link: normalizeOptionalUrl(body.facebookReviewLink ?? body.facebook_review_link ?? business.facebook_review_link, 'Facebook review link must be a valid URL.'), + trustpilot_review_link: normalizeOptionalUrl(body.trustpilotReviewLink ?? body.trustpilot_review_link ?? business.trustpilot_review_link, 'Trustpilot review link must be a valid URL.'), + angi_review_link: normalizeOptionalUrl(body.angiReviewLink ?? body.angi_review_link ?? business.angi_review_link, 'Angi review link must be a valid URL.'), + opentable_review_link: normalizeOptionalUrl(body.opentableReviewLink ?? body.opentable_review_link ?? business.opentable_review_link, 'OpenTable review link must be a valid URL.'), + custom_review_link: normalizeOptionalUrl(body.customReviewLink ?? body.custom_review_link ?? business.custom_review_link, 'Custom review page link must be a valid URL.'), delay_days: parseInteger(body.delayDays ?? body.delay_days, business.delay_days || 7, 0, 30), followup_enabled: parseBoolean(body.followupEnabled ?? body.followup_enabled, business.followup_enabled !== false), followup_delay_days: parseInteger(body.followupDelayDays ?? body.followup_delay_days, business.followup_delay_days || 3, 1, 30), @@ -434,6 +548,7 @@ router.put('/growth-tools/business', wrapAsync(async (req, res) => { competitor_insights_enabled: competitorInsightsEnabled, competitor_urls: normalizeString(body.competitorUrls ?? body.competitor_urls) || business.competitor_urls || '', review_widget_theme: normalizeString(body.reviewWidgetTheme ?? body.review_widget_theme) || business.review_widget_theme || 'light', + ...brandedMessagingPayload, is_active: true, updatedById: currentUser.id, }; diff --git a/backend/src/services/email/index.js b/backend/src/services/email/index.js index bc97a3d..c989d4c 100644 --- a/backend/src/services/email/index.js +++ b/backend/src/services/email/index.js @@ -18,7 +18,7 @@ module.exports = class EmailSender { const transporter = nodemailer.createTransport(this.transportConfig); const mailOptions = { - from: this.from, + from: this.email.from || this.from, to: this.email.to, subject: this.email.subject, html: htmlContent, @@ -27,6 +27,10 @@ module.exports = class EmailSender { }, }; + if (this.email.replyTo) { + mailOptions.replyTo = this.email.replyTo; + } + return transporter.sendMail(mailOptions); } diff --git a/backend/src/services/reviewflow.js b/backend/src/services/reviewflow.js index b982b5e..d2b2b93 100644 --- a/backend/src/services/reviewflow.js +++ b/backend/src/services/reviewflow.js @@ -165,6 +165,24 @@ const ONLINE_REVIEW_DESTINATIONS = new Set(['trustpilot', 'shopify_hosted', 'cus const LOCAL_TRIGGER_PROVIDERS = new Set(['stripe', 'square', 'paypal']); const ONLINE_TRIGGER_PROVIDERS = new Set(['stripe', 'paypal', 'shopify', 'woocommerce']); +const DEFAULT_BRAND_PRIMARY_COLOR = '#4f46e5'; + +function getDefaultEmailSubjectTemplate() { + return 'How was your experience with {businessName}?'; +} + +function getDefaultEmailBodyTemplate() { + return buildEmailBody('{customerName}', '{businessName}', '{reviewLink}'); +} + +function getDefaultSmsTemplate() { + return 'Thanks for choosing {businessName}. Please leave a review: {reviewLink}'; +} + +function getDefaultEmailFooterTemplate() { + return 'Sent by Review Flow for {businessName}.'; +} + function normalizeString(value) { return typeof value === 'string' ? value.trim() : ''; } @@ -417,22 +435,85 @@ function textToHtml(value) { .join('
'); } +function getSafeBrandColor(value) { + const color = normalizeString(value) || DEFAULT_BRAND_PRIMARY_COLOR; + + if (/^#[0-9a-fA-F]{6}$/.test(color) || /^#[0-9a-fA-F]{3}$/.test(color)) { + return color; + } + + return DEFAULT_BRAND_PRIMARY_COLOR; +} + +function getConfiguredFromEmailAddress() { + const configuredFrom = normalizeString(config.email?.from) || 'ReviewFlow '; + const matchedAddress = configuredFrom.match(/<([^>]+)>/); + + return matchedAddress ? matchedAddress[1].trim() : configuredFrom; +} + +function buildEmailFromAddress(business) { + const senderName = normalizeString(business?.email_sender_name) || business?.name || 'Review Flow'; + const safeSenderName = senderName.replace(/["<>]/g, '').trim() || 'Review Flow'; + const fromAddress = getConfiguredFromEmailAddress(); + + return `${safeSenderName} <${fromAddress}>`; +} + +function getTemplateReplacements(business, customerName, reviewLink) { + return { + businessName: business?.name || 'our business', + customerName: customerName || 'there', + reviewLink: reviewLink || '', + }; +} + +function buildReviewRequestMessages(business, customerName, reviewLink) { + const replacements = getTemplateReplacements(business, customerName, reviewLink); + const emailSubject = renderTemplate( + business?.email_subject_template || getDefaultEmailSubjectTemplate(), + replacements, + ) || `How was your experience with ${replacements.businessName}?`; + const emailBody = renderTemplate( + business?.email_body_template || getDefaultEmailBodyTemplate(), + replacements, + ) || buildEmailBody(replacements.customerName, replacements.businessName, replacements.reviewLink); + const smsMessage = renderTemplate( + business?.sms_template || getDefaultSmsTemplate(), + replacements, + ) || getDefaultSmsTemplate(); + const emailFooter = renderTemplate( + business?.email_footer_text || getDefaultEmailFooterTemplate(), + replacements, + ) || `Sent by Review Flow for ${replacements.businessName}.`; + + return { emailSubject, emailBody, smsMessage, emailFooter }; +} + function buildReviewRequestEmail(request, toEmail) { - const businessName = request.business?.name || 'Review Flow'; + const business = request.business || {}; + const businessName = business.name || 'Review Flow'; const customerName = request.customer?.name || 'there'; const reviewLink = request.review_link || ''; - const body = request.email_body || buildEmailBody(customerName, businessName, reviewLink); + const messages = buildReviewRequestMessages(business, customerName, reviewLink); + const body = request.email_body || messages.emailBody; + const brandColor = getSafeBrandColor(business.brand_primary_color); + const logoUrl = normalizeString(business.brand_logo_url); + const footerText = messages.emailFooter; return { to: toEmail, - subject: request.email_subject || `How was your experience with ${businessName}?`, + from: buildEmailFromAddress(business), + replyTo: normalizeEmail(business.email_reply_to), + subject: request.email_subject || messages.emailSubject || `How was your experience with ${businessName}?`, html: async () => ` -
+
+ ${logoUrl ? `
${escapeHtml(businessName)}
` : ''}
${textToHtml(body)}
- ${reviewLink ? `

Leave a review

` : ''} + ${reviewLink ? `

Leave a review

` : ''}
-

Sent by Review Flow for ${escapeHtml(businessName)}.

+

${escapeHtml(footerText)}

`, }; @@ -468,9 +549,10 @@ async function sendReviewRequestSms(request) { } const smsConfig = getSmsConfig(); - const businessName = request.business?.name || 'our business'; + const business = request.business || {}; + const customerName = request.customer?.name || 'there'; const reviewLink = request.review_link || ''; - const message = `Thanks for choosing ${businessName}. Please leave a review: ${reviewLink}`.slice(0, 1500); + const message = buildReviewRequestMessages(business, customerName, reviewLink).smsMessage.slice(0, 1500); const payload = new URLSearchParams({ To: toPhone, From: smsConfig.fromNumber, @@ -525,14 +607,15 @@ async function sendReviewRequestNotifications(request, currentUser, options = {} throw error; } + const emailPayload = buildReviewRequestEmail(request, toEmail); const emailLog = await db.email_delivery_logs.create({ provider: EmailSender.isConfigured ? 'smtp' : 'unknown', provider_message_reference: `reviewflow-${request.id}`, delivery_status: 'queued', queued_at: now, to_email: toEmail, - from_email: config.email?.from || 'ReviewFlow ', - subject: request.email_subject, + from_email: emailPayload.from || config.email?.from || 'ReviewFlow ', + subject: emailPayload.subject, review_requestId: request.id, createdById: currentUser.id, updatedById: currentUser.id, @@ -551,7 +634,7 @@ async function sendReviewRequestNotifications(request, currentUser, options = {} } try { - const emailResult = await new EmailSender(buildReviewRequestEmail(request, toEmail)).send(); + const emailResult = await new EmailSender(emailPayload).send(); await emailLog.update({ provider: 'smtp', provider_message_reference: emailResult?.messageId || emailLog.provider_message_reference, @@ -585,13 +668,23 @@ async function sendReviewRequestNotifications(request, currentUser, options = {} return deliveries; } +function escapeRegExp(value) { + return String(value) + .split('') + .map((character) => ('^$*+?.()|{}[]\\'.includes(character) ? '\\' + character : character)) + .join(''); +} + function renderTemplate(template, replacements) { if (!template) { return ''; } return Object.entries(replacements).reduce((output, [key, value]) => { - return output.replace(new RegExp(`{${key}}`, 'g'), value || ''); + const safeKey = escapeRegExp(key); + return output + .replace(new RegExp(`{{\\s*${safeKey}\\s*}}`, 'g'), value || '') + .replace(new RegExp(`{${safeKey}}`, 'g'), value || ''); }, template); } @@ -936,6 +1029,14 @@ function serializeBusiness(req, business) { competitor_insights_enabled: Boolean(business.competitor_insights_enabled), competitor_urls: business.competitor_urls || '', review_widget_theme: business.review_widget_theme || 'light', + brand_logo_url: business.brand_logo_url || '', + brand_primary_color: business.brand_primary_color || DEFAULT_BRAND_PRIMARY_COLOR, + email_sender_name: business.email_sender_name || '', + email_reply_to: business.email_reply_to || '', + email_footer_text: business.email_footer_text || getDefaultEmailFooterTemplate(), + email_subject_template: business.email_subject_template || getDefaultEmailSubjectTemplate(), + email_body_template: business.email_body_template || getDefaultEmailBodyTemplate(), + sms_template: business.sms_template || getDefaultSmsTemplate(), google_review_link: business.google_review_link, yelp_review_link: business.yelp_review_link, facebook_review_link: business.facebook_review_link, @@ -1023,8 +1124,8 @@ async function connectProvider(currentUser, body, req) { review_destination: reviewDestination, shopify_hosted_reviews_enabled: Boolean(config.hostedReviewProvider || reviewDestination === 'shopify_hosted'), delay_days: delayDays, - email_subject_template: `How was your experience with ${businessName}?`, - email_body_template: buildEmailBody('{customerName}', businessName, '{reviewLink}'), + email_subject_template: getDefaultEmailSubjectTemplate(), + email_body_template: getDefaultEmailBodyTemplate(), is_active: true, createdById: currentUser.id, updatedById: currentUser.id, @@ -1428,14 +1529,8 @@ async function createReviewRequestFromPayment(payment, business, customer, trans const delayDays = Math.max(0, Number(business.delay_days) || 0); const scheduledFor = new Date(Date.now() + delayDays * 24 * 60 * 60 * 1000); - const businessName = business.name || 'our business'; const customerName = customer.name || payment.customerName || 'there'; - const replacements = { businessName, customerName, reviewLink }; - const emailSubject = renderTemplate( - business.email_subject_template || `How was your experience with {businessName}?`, - replacements, - ) || `How was your experience with ${businessName}?`; - const emailBody = renderTemplate(business.email_body_template, replacements) || buildEmailBody(customerName, businessName, reviewLink); + const { emailSubject, emailBody } = buildReviewRequestMessages(business, customerName, reviewLink); return db.review_requests.create({ status: 'pending', @@ -1718,5 +1813,11 @@ module.exports = { rotateWebhookToken, serializeBusiness, serializeReviewChannels, + buildReviewRequestMessages, + getDefaultEmailSubjectTemplate, + getDefaultEmailBodyTemplate, + getDefaultSmsTemplate, + getDefaultEmailFooterTemplate, + DEFAULT_BRAND_PRIMARY_COLOR, submitHostedReview, }; diff --git a/backend/src/services/subscriptionPlans.js b/backend/src/services/subscriptionPlans.js index 1199fc5..3c8ee86 100644 --- a/backend/src/services/subscriptionPlans.js +++ b/backend/src/services/subscriptionPlans.js @@ -64,6 +64,7 @@ const subscriptionPlans = [ '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', @@ -96,6 +97,7 @@ const subscriptionPlans = [ 'marketing_broadcasts', 'rebooking_campaigns', 'competitor_insights', + 'branded_messaging', ], }, ]; diff --git a/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx b/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx index f0b8872..f0f2bae 100644 --- a/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx +++ b/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx @@ -352,7 +352,7 @@ const providerSetupDetails: Record< 'In Stripe, create a new webhook endpoint and paste the copied URL into the endpoint URL field.', 'Choose your account events unless you are intentionally configuring a Stripe Connect platform flow.', 'Add the required successful payment event types listed below, then save the endpoint.', - 'Send a Stripe test webhook or make a test payment, then return here and click Refresh connectors.', + 'Send a Stripe test webhook or make a test payment, then return here and click Check webhook status.', ], testTip: 'A successful Stripe delivery should show a 2xx response and create a payment event in Review Flow.', @@ -367,7 +367,7 @@ const providerSetupDetails: Record< 'In Square, open your application, create a webhook subscription, and paste the copied URL as the notification URL.', 'Select the payment events listed below so completed payments can be converted into review requests.', 'Save the subscription and keep any Square signature details private for future verification hardening.', - 'Use a Square test payment or webhook test delivery, then return here and click Refresh connectors.', + 'Use a Square test payment or webhook test delivery, then return here and click Check webhook status.', ], testTip: 'Square payments queue reviews only when the payment status is completed and a customer email is available.', @@ -386,7 +386,7 @@ const providerSetupDetails: Record< 'In PayPal, open the REST app that receives your payments and add a new webhook.', 'Paste the copied URL into the webhook URL field and subscribe to the completed payment events listed below.', 'Save the webhook, then confirm it is attached to the same PayPal app used by your checkout flow.', - 'Run a sandbox checkout or webhook simulator event, then return here and click Refresh connectors.', + 'Run a sandbox checkout or webhook simulator event, then return here and click Check webhook status.', ], testTip: 'A completed PayPal capture, sale, or checkout order should appear as a payment event before a review request is queued.', @@ -401,7 +401,7 @@ const providerSetupDetails: Record< 'In Shopify admin, open Settings, then Notifications, then create a webhook in the Webhooks section.', 'Choose the Order payment / orders/paid event, select JSON format, paste the copied URL, and save the webhook.', 'Review Flow will create a customer, transaction, and hosted product-review form link for each paid order with an email.', - 'Use Shopify test notifications or place a test paid order, then return here and click Refresh connectors.', + 'Use Shopify test notifications or place a test paid order, then return here and click Check webhook status.', ], testTip: 'A paid Shopify order queues a hosted Review Flow product review when the payload includes a customer email and financial_status is paid.', @@ -416,7 +416,7 @@ const providerSetupDetails: Record< 'In WordPress admin, open WooCommerce, then Settings, then Advanced, then Webhooks, and click Add webhook.', 'Name the webhook Review Flow, set Status to Active, choose the Order created topic, and paste the copied URL into Delivery URL.', 'Save the webhook, then add a second Active webhook for Order updated so status changes to processing or completed are captured.', - 'Create or update a test order to processing/completed, then return here and click Refresh connectors.', + 'Create or update a test order to processing/completed, then return here and click Check webhook status.', ], testTip: 'A WooCommerce order queues a review when status is processing or completed and the billing email is present.', @@ -429,6 +429,29 @@ type ProviderApiBackup = { successTip: string; }; +type PaymentSetupMethod = 'Webhook' | 'API backup'; + +type PaymentWebhookEvent = { + provider?: string; + provider_event_type?: string; + event_type?: string; + processed?: boolean; + processing_error?: string; + createdAt?: string; + business?: { name?: string }; +}; + +type VerifiedPaymentSetup = { + provider: string; + providerLabel: string; + businessName: string; + method: PaymentSetupMethod; + reviewDestinationLabel: string; + verifiedAt: string; + statusNote: string; + eventType?: string; +}; + const commonApiBackupUseCases = [ 'Use this only after trying the provider dashboard webhook first.', 'Good for a custom backend, Zapier, Make, n8n, or middleware that already knows the payment/order succeeded.', @@ -659,6 +682,11 @@ export default function PaymentProviderConnectors({ const [isClientReady, setIsClientReady] = useState(false); const [subscriptionStatus, setSubscriptionStatus] = useState(null); + const [isApiBackupVisible, setIsApiBackupVisible] = useState(false); + const [isCheckingWebhook, setIsCheckingWebhook] = useState(false); + const [paymentSetupFinished, setPaymentSetupFinished] = useState(false); + const [verifiedPaymentSetup, setVerifiedPaymentSetup] = + useState(null); const currentBusinessType = normalizeBusinessType(connectorForm.businessType); const filteredProviderOptions = getProvidersForBusinessType(currentBusinessType); @@ -729,6 +757,11 @@ export default function PaymentProviderConnectors({ key: keyof ConnectorFormValues, value: string, ) => { + if (key === 'businessType') { + setIsApiBackupVisible(false); + setPaymentSetupFinished(false); + } + setConnectorForm((current) => { if (key === 'businessType') { const businessType = normalizeBusinessType(value); @@ -755,6 +788,9 @@ export default function PaymentProviderConnectors({ }; const updateSelectedProvider = (providerKey: string) => { + setIsApiBackupVisible(false); + setPaymentSetupFinished(false); + const provider = filteredProviderOptions.find( (providerOption) => providerOption.key === providerKey, @@ -840,6 +876,9 @@ export default function PaymentProviderConnectors({ setConnectorMessage( `${selectedProvider.label} is connected for ${business.name}. ${selectedReviewDestination.label} is the review destination. Copy the secure webhook URL below into your ${selectedProvider.label} dashboard.`, ); + setIsApiBackupVisible(false); + setPaymentSetupFinished(false); + setVerifiedPaymentSetup(null); await Promise.all([loadConnectors(), loadSubscriptionStatus()]); @@ -909,6 +948,91 @@ export default function PaymentProviderConnectors({ } }; + const finishPaymentSetup = ( + method: PaymentSetupMethod = 'Webhook', + statusNote = 'Manual verification saved after a successful provider test.', + eventType?: string, + ) => { + const target = selectedWebhookTargets[0]; + + if (!target) { + setError( + `Connect ${selectedProvider.label} first so Review Flow can generate the secure webhook URL before verification.`, + ); + return; + } + + setVerifiedPaymentSetup({ + provider: selectedProvider.key, + providerLabel: selectedProvider.label, + businessName: target.businessName || connectorForm.businessName || 'Connected business', + method, + reviewDestinationLabel: selectedReviewDestination.label, + verifiedAt: new Date().toISOString(), + statusNote, + eventType, + }); + setPaymentSetupFinished(true); + setError(''); + setConnectorMessage( + `${selectedProvider.label} ${method.toLowerCase()} setup marked verified. You can edit this setup any time.`, + ); + }; + + const checkWebhookStatus = async () => { + const target = selectedWebhookTargets[0]; + + if (!target) { + setError( + `Connect ${selectedProvider.label} first, then paste its webhook URL into ${selectedProvider.label} and send a test event.`, + ); + return; + } + + setIsCheckingWebhook(true); + setError(''); + setConnectorMessage(''); + + try { + const response = await axios.get('/reviewflow/summary?limit=12'); + const events: PaymentWebhookEvent[] = Array.isArray(response.data?.recentEvents) + ? response.data.recentEvents + : []; + const matchingEvent = events.find((event) => { + const providerMatches = + (event.provider || '').toLowerCase() === selectedProvider.key; + const businessMatches = + !event.business?.name || event.business.name === target.businessName; + + return providerMatches && businessMatches; + }); + + await loadConnectors(); + + if (matchingEvent) { + finishPaymentSetup( + 'Webhook', + matchingEvent.processing_error + ? `A ${selectedProvider.label} webhook event arrived, but Review Flow reported: ${matchingEvent.processing_error}` + : `A ${selectedProvider.label} webhook test event arrived in Review Flow.`, + matchingEvent.provider_event_type || matchingEvent.event_type, + ); + return; + } + + setConnectorMessage( + `No recent ${selectedProvider.label} webhook test event was found yet. Send a provider test event, then click Check webhook status again. If the provider dashboard already shows a successful delivery, you can use the manual verification button.`, + ); + } catch (requestError) { + console.error('Failed to check payment webhook status:', requestError); + setError( + 'Could not check webhook status right now. If your provider dashboard confirms a successful test delivery, you can still mark it verified manually.', + ); + } finally { + setIsCheckingWebhook(false); + } + }; + const connectorUsage = subscriptionStatus?.usage.paymentConnectors ?? 0; const connectorLimit = subscriptionStatus?.limits.paymentConnectors ?? 0; const businessUsage = subscriptionStatus?.usage.businesses ?? 0; @@ -1039,6 +1163,59 @@ export default function PaymentProviderConnectors({ ))}
+ {paymentSetupFinished && verifiedPaymentSetup ? ( +
+
+
+

+ Payment setup finished +

+

+ {verifiedPaymentSetup.providerLabel} is ready to trigger review requests +

+

+ Review Flow will use this payment setup for {verifiedPaymentSetup.businessName}. +

+
+ { + setPaymentSetupFinished(false); + setConnectorMessage(''); + }} + /> +
+ +
+
+

Provider

+

{verifiedPaymentSetup.providerLabel}

+
+
+

Method

+

{verifiedPaymentSetup.method}

+
+
+

Review destination

+

{verifiedPaymentSetup.reviewDestinationLabel}

+
+
+

Verified

+

{formatDate(verifiedPaymentSetup.verifiedAt)}

+
+
+ +
+

Status: {verifiedPaymentSetup.statusNote}

+ {verifiedPaymentSetup.eventType && ( +

Last event: {verifiedPaymentSetup.eventType}

+ )} +
+
+ ) : ( + <>

Cleaner setup flow @@ -1047,9 +1224,10 @@ export default function PaymentProviderConnectors({ Choose one provider, then follow one guide

- The provider dropdown below now controls the instructions. Review Flow + The provider dropdown below controls the instructions. Review Flow shows the detailed webhook setup for only the selected payment or - ecommerce company, then shows an API backup underneath. + ecommerce company. API backup instructions stay hidden unless you click + Use API instead.

@@ -1071,11 +1249,11 @@ export default function PaymentProviderConnectors({

- 3. Use API backup if needed + 3. Test and finish

- If dashboard webhooks are unavailable, POST provider-style JSON to - the same URL. + Send a provider test event, check status here, then finish to see + the summary box.

@@ -1219,9 +1397,9 @@ export default function PaymentProviderConnectors({ {selectedProvider.label} setup

- Follow the webhook instructions first. API backup is available - below for custom systems or automation tools, but it should be - your fallback path. + Follow the webhook instructions first. Click Use API instead + only when a custom system or automation tool needs the fallback + POST option.

@@ -1230,7 +1408,7 @@ export default function PaymentProviderConnectors({ -
+

@@ -1273,9 +1451,41 @@ export default function PaymentProviderConnectors({

Test after saving: {selectedSetup.testTip}
+ +
+ + finishPaymentSetup('Webhook')} + /> + setIsApiBackupVisible((current) => !current)} + /> +
+

+ Recommended path: use the webhook. Use API only for custom systems or automation tools when the provider dashboard webhook is not practical. +

-
+

API backup @@ -1368,10 +1578,28 @@ export default function PaymentProviderConnectors({ Backup success check:{' '} {selectedApiBackup.successTip}

+ + + finishPaymentSetup( + 'API backup', + 'Manual verification saved after a successful API backup test POST.', + ) + } + />
+ + )} +

@@ -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 && ( - + updateSettings('businessName', event.target.value)} - placeholder='Business name' - /> - - - +
+

{selectedBusiness?.name || settingsForm.businessName || 'No business selected'}

+

Review workflow: {settingsForm.businessType} · destination: {settingsForm.reviewDestination}

+

If this looks wrong, update it in Setup instead of editing it here.

+
- - 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' - /> - - -
- {[ - ['followupEnabled', 'Follow-ups', 'Automatically prepare follow-up handoffs for customers who have not clicked.'], - ['socialWidgetEnabled', 'Social proof widget', 'Show verified hosted reviews on websites and landing pages.'], - ['aiReplyEnabled', 'AI replies (Pro)', 'Generate review replies using the existing AI proxy.'], - ['referralEnabled', 'Referrals (Pro)', 'Queue referral campaign messages for customers.'], - ['npsEnabled', 'NPS surveys (Pro)', 'Queue NPS survey outreach.'], - ['broadcastEnabled', 'Broadcasts (Pro)', 'Queue marketing broadcasts.'], - ['rebookingEnabled', 'Rebooking (Pro)', 'Queue repeat-business campaigns.'], - ['competitorInsightsEnabled', 'Competitor insights (Pro)', 'Save competitors and build an action checklist.'], - ].map(([key, label, help]) => ( - - ))} -
- - - updateSettings('referralOffer', event.target.value)} placeholder='Referral offer' /> - updateSettings('npsQuestion', event.target.value)} placeholder='NPS question' /> - - -