Autosave: 20260630-005058
This commit is contained in:
parent
30e91e2b17
commit
4df4505096
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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,
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@ -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'>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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' },
|
||||
],
|
||||
|
||||
@ -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 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
1056
frontend/src/pages/setup.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user