Autosave: 20260630-005058

This commit is contained in:
Flatlogic Bot 2026-06-30 00:50:52 +00:00
parent 30e91e2b17
commit 4df4505096
13 changed files with 1713 additions and 1359 deletions

View File

@ -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;
}
},
};

View File

@ -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,

View File

@ -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,
};

View File

@ -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);
}

View File

@ -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('<br />');
}
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 <app@flatlogic.app>';
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 () => `
<div style="font-family:Arial,sans-serif;color:#0f172a;line-height:1.6;max-width:640px;margin:0 auto;padding:24px;">
<div style="font-family:Arial,sans-serif;color:#0f172a;line-height:1.6;max-width:640px;margin:0 auto;padding:24px;background:#f8fafc;">
<div style="border:1px solid #e2e8f0;border-radius:18px;padding:24px;background:#ffffff;">
${logoUrl ? `<div style="margin-bottom:20px;"><img src="${escapeHtml(logoUrl)}" alt="${escapeHtml(businessName)}" style="max-width:180px;max-height:72px;object-fit:contain;" /></div>` : ''}
<div style="font-size:15px;">${textToHtml(body)}</div>
${reviewLink ? `<p style="margin-top:24px;"><a href="${escapeHtml(reviewLink)}" style="display:inline-block;background:#4f46e5;color:#ffffff;text-decoration:none;padding:12px 18px;border-radius:999px;font-weight:700;">Leave a review</a></p>` : ''}
${reviewLink ? `<p style="margin-top:24px;"><a href="${escapeHtml(reviewLink)}" style="display:inline-block;background:${escapeHtml(brandColor)};color:#ffffff;text-decoration:none;padding:12px 18px;border-radius:999px;font-weight:700;">Leave a review</a></p>` : ''}
</div>
<p style="font-size:12px;color:#64748b;margin-top:16px;">Sent by Review Flow for ${escapeHtml(businessName)}.</p>
<p style="font-size:12px;color:#64748b;margin-top:16px;">${escapeHtml(footerText)}</p>
</div>
`,
};
@ -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 <app@flatlogic.app>',
subject: request.email_subject,
from_email: emailPayload.from || config.email?.from || 'ReviewFlow <app@flatlogic.app>',
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,
};

View File

@ -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',
],
},
];

View File

@ -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<ConnectorSubscriptionStatus | null>(null);
const [isApiBackupVisible, setIsApiBackupVisible] = useState(false);
const [isCheckingWebhook, setIsCheckingWebhook] = useState(false);
const [paymentSetupFinished, setPaymentSetupFinished] = useState(false);
const [verifiedPaymentSetup, setVerifiedPaymentSetup] =
useState<VerifiedPaymentSetup | null>(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({
))}
</div>
{paymentSetupFinished && verifiedPaymentSetup ? (
<div className='mb-6 rounded-3xl border border-emerald-200 bg-emerald-50 p-5 text-emerald-950 shadow-sm dark:border-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-50'>
<div className='flex flex-wrap items-start justify-between gap-4'>
<div>
<p className='text-sm font-black uppercase tracking-[0.25em] text-emerald-700 dark:text-emerald-300'>
Payment setup finished
</p>
<h4 className='mt-1 text-2xl font-black'>
{verifiedPaymentSetup.providerLabel} is ready to trigger review requests
</h4>
<p className='mt-2 text-sm leading-6 opacity-80'>
Review Flow will use this payment setup for {verifiedPaymentSetup.businessName}.
</p>
</div>
<BaseButton
type='button'
label='Edit setup'
color='whiteDark'
onClick={() => {
setPaymentSetupFinished(false);
setConnectorMessage('');
}}
/>
</div>
<div className='mt-4 grid gap-3 md:grid-cols-4'>
<div className='rounded-2xl bg-white/70 p-4 ring-1 ring-emerald-200 dark:bg-dark-900/60 dark:ring-emerald-900'>
<p className='text-xs font-black uppercase tracking-widest opacity-60'>Provider</p>
<p className='mt-1 font-black'>{verifiedPaymentSetup.providerLabel}</p>
</div>
<div className='rounded-2xl bg-white/70 p-4 ring-1 ring-emerald-200 dark:bg-dark-900/60 dark:ring-emerald-900'>
<p className='text-xs font-black uppercase tracking-widest opacity-60'>Method</p>
<p className='mt-1 font-black'>{verifiedPaymentSetup.method}</p>
</div>
<div className='rounded-2xl bg-white/70 p-4 ring-1 ring-emerald-200 dark:bg-dark-900/60 dark:ring-emerald-900'>
<p className='text-xs font-black uppercase tracking-widest opacity-60'>Review destination</p>
<p className='mt-1 font-black'>{verifiedPaymentSetup.reviewDestinationLabel}</p>
</div>
<div className='rounded-2xl bg-white/70 p-4 ring-1 ring-emerald-200 dark:bg-dark-900/60 dark:ring-emerald-900'>
<p className='text-xs font-black uppercase tracking-widest opacity-60'>Verified</p>
<p className='mt-1 font-black'>{formatDate(verifiedPaymentSetup.verifiedAt)}</p>
</div>
</div>
<div className='mt-4 rounded-2xl bg-white/70 p-4 text-sm leading-6 ring-1 ring-emerald-200 dark:bg-dark-900/60 dark:ring-emerald-900'>
<p><strong>Status:</strong> {verifiedPaymentSetup.statusNote}</p>
{verifiedPaymentSetup.eventType && (
<p className='mt-1'><strong>Last event:</strong> {verifiedPaymentSetup.eventType}</p>
)}
</div>
</div>
) : (
<>
<div className='mb-6 rounded-2xl border border-indigo-100 bg-indigo-50/70 p-4 dark:border-indigo-900 dark:bg-indigo-950/20'>
<p className='text-sm font-bold uppercase tracking-[0.25em] text-indigo-500'>
Cleaner setup flow
@ -1047,9 +1224,10 @@ export default function PaymentProviderConnectors({
Choose one provider, then follow one guide
</h4>
<p className='mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300'>
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.
</p>
<div className='mt-4 grid gap-3 md:grid-cols-3'>
<div className='rounded-xl bg-white p-3 text-sm ring-1 ring-indigo-100 dark:bg-dark-900 dark:ring-indigo-900'>
@ -1071,11 +1249,11 @@ export default function PaymentProviderConnectors({
</div>
<div className='rounded-xl bg-white p-3 text-sm ring-1 ring-indigo-100 dark:bg-dark-900 dark:ring-indigo-900'>
<p className='font-black text-slate-900 dark:text-white'>
3. Use API backup if needed
3. Test and finish
</p>
<p className='mt-1 text-slate-500 dark:text-slate-400'>
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.
</p>
</div>
</div>
@ -1219,9 +1397,9 @@ export default function PaymentProviderConnectors({
{selectedProvider.label} setup
</h4>
<p className='mt-2 max-w-3xl text-sm leading-6 text-white/85'>
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.
</p>
</div>
<span className='rounded-full bg-white/15 px-4 py-2 text-xs font-black uppercase tracking-widest text-white ring-1 ring-white/20'>
@ -1230,7 +1408,7 @@ export default function PaymentProviderConnectors({
</div>
</div>
<div className='grid gap-4 p-4 lg:grid-cols-[1fr_0.95fr]'>
<div className={`grid gap-4 p-4 ${isApiBackupVisible ? 'lg:grid-cols-[1fr_0.95fr]' : 'lg:grid-cols-1'}`}>
<div className='space-y-4 rounded-2xl bg-slate-50 p-4 dark:bg-dark-800'>
<div>
<p className='mb-1 text-xs font-bold uppercase tracking-widest text-slate-400'>
@ -1273,9 +1451,41 @@ export default function PaymentProviderConnectors({
<div className='rounded-xl bg-white p-3 text-xs leading-5 text-slate-600 ring-1 ring-slate-200 dark:bg-dark-900 dark:text-slate-300 dark:ring-dark-700'>
<strong>Test after saving:</strong> {selectedSetup.testTip}
</div>
<div className='flex flex-wrap gap-2'>
<BaseButton
type='button'
icon={mdiRefresh}
label={isCheckingWebhook ? 'Checking...' : 'Check webhook status'}
color='info'
small
disabled={isCheckingWebhook || !selectedWebhookTargets.length}
onClick={checkWebhookStatus}
/>
<BaseButton
type='button'
icon={mdiCheckCircleOutline}
label='I sent a successful test'
color='success'
small
disabled={!selectedWebhookTargets.length}
onClick={() => finishPaymentSetup('Webhook')}
/>
<BaseButton
type='button'
icon={mdiCreditCardOutline}
label={isApiBackupVisible ? 'Hide API instructions' : 'Use API instead'}
color='whiteDark'
small
onClick={() => setIsApiBackupVisible((current) => !current)}
/>
</div>
<p className='text-xs leading-5 text-slate-500 dark:text-slate-400'>
Recommended path: use the webhook. Use API only for custom systems or automation tools when the provider dashboard webhook is not practical.
</p>
</div>
<div className='space-y-4 rounded-2xl border border-dashed border-slate-300 bg-white p-4 dark:border-dark-700 dark:bg-dark-900'>
<div className={`${isApiBackupVisible ? 'space-y-4' : 'hidden'} rounded-2xl border border-dashed border-slate-300 bg-white p-4 dark:border-dark-700 dark:bg-dark-900`}>
<div>
<p className='text-xs font-bold uppercase tracking-[0.25em] text-amber-500'>
API backup
@ -1368,10 +1578,28 @@ export default function PaymentProviderConnectors({
<strong>Backup success check:</strong>{' '}
{selectedApiBackup.successTip}
</div>
<BaseButton
type='button'
icon={mdiCheckCircleOutline}
label='I tested API backup'
color='success'
small
disabled={!selectedWebhookTargets.length}
onClick={() =>
finishPaymentSetup(
'API backup',
'Manual verification saved after a successful API backup test POST.',
)
}
/>
</div>
</div>
</div>
</>
)}
<div className='mb-4 flex flex-wrap items-center justify-between gap-3'>
<div>
<p className='text-sm font-bold uppercase tracking-[0.25em] text-slate-400'>
@ -1503,9 +1731,8 @@ export default function PaymentProviderConnectors({
)}
</ol>
<p className='mt-3 text-slate-500 dark:text-slate-400'>
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.
</p>
{provider.webhook_token_last4 && (
<p className='mt-3 text-slate-400'>

View File

@ -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',

View File

@ -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' },
],

View File

@ -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<HTMLFormElement>) => {
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() {
<div className='grid gap-6 lg:grid-cols-[1.2fr_0.8fr] lg:items-center'>
<div>
<p className='mb-3 inline-flex rounded-full bg-white/10 px-4 py-1 text-sm font-semibold text-emerald-200 ring-1 ring-white/20'>
Automated review management · set it and forget it
Growth actions · campaigns · widgets · AI replies
</p>
<h2 className='max-w-3xl text-4xl font-black tracking-tight md:text-5xl'>
Keep review growth simple after business setup.
Use the tools that grow reviews after Setup is done.
</h2>
<p className='mt-4 max-w-2xl text-base text-slate-200 md:text-lg'>
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.
</p>
</div>
<div className='grid grid-cols-2 gap-3'>
@ -519,21 +517,21 @@ export default function GrowthToolsPage() {
</div>
)}
<div className='mb-6 grid gap-6 xl:grid-cols-[0.9fr_1.1fr]'>
<div className='mb-6 grid gap-6 xl:grid-cols-[0.75fr_1.25fr]'>
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
<div className='mb-5 flex items-start justify-between gap-4'>
<div>
<p className='text-sm font-bold uppercase tracking-[0.25em] text-emerald-500'>Setup</p>
<h3 className='text-2xl font-black text-slate-900 dark:text-white'>Business type and automation</h3>
<p className='text-sm font-bold uppercase tracking-[0.25em] text-emerald-500'>Growth workspace</p>
<h3 className='text-2xl font-black text-slate-900 dark:text-white'>Use tools after Setup is complete</h3>
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400'>
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.
</p>
</div>
<BaseButton icon={mdiRefresh} label='Refresh' color='whiteDark' onClick={loadData} disabled={isLoading} />
</div>
{businesses.length > 0 && (
<FormField label='Business profile' help='Choose which business profile these growth settings apply to.'>
<FormField label='Business profile' help='Choose which business profile these tools should use.'>
<select value={selectedBusinessId} onChange={(event) => selectBusiness(event.target.value)}>
{businesses.map((business) => (
<option key={business.id} value={business.id}>{business.name || 'Business'}</option>
@ -542,91 +540,16 @@ export default function GrowthToolsPage() {
</FormField>
)}
<form onSubmit={saveSettings}>
<FormField label='Business and type' help={businessTypeOptions.find((option) => option.key === currentBusinessType)?.help}>
<input
required
value={settingsForm.businessName}
onChange={(event) => updateSettings('businessName', event.target.value)}
placeholder='Business name'
/>
<select value={settingsForm.businessType} onChange={(event) => updateSettings('businessType', event.target.value)}>
{businessTypeOptions.map((option) => (
<option key={option.key} value={option.key}>{option.label}</option>
))}
</select>
<select value={settingsForm.reviewDestination} onChange={(event) => updateSettings('reviewDestination', event.target.value)}>
{destinationOptions.map((destination) => (
<option key={destination.key} value={destination.key}>{destination.label}</option>
))}
</select>
</FormField>
<div className='rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm leading-6 text-slate-600 dark:border-dark-700 dark:bg-dark-800 dark:text-slate-300'>
<p className='font-black text-slate-900 dark:text-white'>{selectedBusiness?.name || settingsForm.businessName || 'No business selected'}</p>
<p className='mt-1'>Review workflow: {settingsForm.businessType} · destination: {settingsForm.reviewDestination}</p>
<p className='mt-1'>If this looks wrong, update it in Setup instead of editing it here.</p>
</div>
<FormField label='Set-it-and-forget-it timing' help='Due requests can be handed off from the queue without manually opening each one.'>
<input
min='0'
max='30'
type='number'
value={settingsForm.delayDays}
onChange={(event) => updateSettings('delayDays', event.target.value)}
placeholder='Initial delay days'
/>
<input
min='1'
max='30'
type='number'
value={settingsForm.followupDelayDays}
onChange={(event) => updateSettings('followupDelayDays', event.target.value)}
placeholder='Follow-up delay days'
/>
<input
min='0'
max='5'
type='number'
value={settingsForm.maxFollowups}
onChange={(event) => updateSettings('maxFollowups', event.target.value)}
placeholder='Max follow-ups'
/>
</FormField>
<div className='mb-5 grid gap-3 md:grid-cols-2'>
{[
['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]) => (
<label key={key} className='flex gap-3 rounded-2xl border border-slate-200 p-4 text-sm dark:border-dark-700'>
<input
type='checkbox'
checked={Boolean(settingsForm[key as keyof typeof defaultSettings])}
onChange={(event) => updateSettings(key as keyof typeof defaultSettings, event.target.checked)}
/>
<span>
<span className='block font-black text-slate-900 dark:text-white'>{label}</span>
<span className='mt-1 block leading-5 text-slate-500 dark:text-slate-400'>{help}</span>
</span>
</label>
))}
</div>
<FormField label='Referral, NPS, and competitor defaults' help='Pro tools use these defaults when queueing campaigns or building insights.'>
<input value={settingsForm.referralOffer} onChange={(event) => updateSettings('referralOffer', event.target.value)} placeholder='Referral offer' />
<input value={settingsForm.npsQuestion} onChange={(event) => updateSettings('npsQuestion', event.target.value)} placeholder='NPS question' />
</FormField>
<FormField label='Competitors' help='One competitor name or URL per line. Keep this focused on your top direct alternatives.'>
<textarea value={settingsForm.competitorUrls} onChange={(event) => updateSettings('competitorUrls', event.target.value)} placeholder='nicejob.com&#10;Another competitor' />
</FormField>
<div className='flex flex-wrap gap-3'>
<BaseButton type='submit' icon={mdiCheckCircleOutline} label={isSaving ? 'Saving...' : 'Save settings'} color='info' disabled={isSaving} />
<BaseButton type='button' icon={mdiSend} label='Run due automation' color='success' onClick={runDueAutomation} disabled={isWorking} />
</div>
</form>
<div className='mt-5 flex flex-wrap gap-3'>
<BaseButton href='/setup' icon={mdiOpenInNew} label='Open Setup' color='info' />
<BaseButton icon={mdiSend} label='Run due automation' color='success' onClick={runDueAutomation} disabled={isWorking} />
</div>
</CardBox>
<div className='grid gap-6'>
@ -679,7 +602,6 @@ export default function GrowthToolsPage() {
</CardBox>
</div>
</div>
<div className='grid gap-6 xl:grid-cols-2'>
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
<div className='flex items-start justify-between gap-3'>

File diff suppressed because it is too large Load Diff

1056
frontend/src/pages/setup.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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',