Autosave: 20260629-224348

This commit is contained in:
Flatlogic Bot 2026-06-29 22:43:42 +00:00
parent f741ab0364
commit 30e91e2b17
33 changed files with 3158 additions and 335 deletions

View File

@ -1 +1,4 @@
PORT=8080
TWILIO_ACCOUNT_SID=ACf0b6dd3d34b2aefffd9914c317bf04e0
TWILIO_AUTH_TOKEN=5b4dc2c0246b699596997a212a46548a
TWILIO_FROM_NUMBER=+17372324091

View File

@ -1,7 +1,5 @@
const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils');
@ -26,6 +24,82 @@ module.exports = class BusinessesDBApi {
null
,
business_type: data.business_type
||
'hybrid'
,
automation_mode: data.automation_mode
||
'set_and_forget'
,
followup_enabled: data.followup_enabled !== undefined ? data.followup_enabled : true
,
followup_delay_days: data.followup_delay_days
||
3
,
max_followups: data.max_followups
||
1
,
ai_reply_enabled: data.ai_reply_enabled
||
false
,
referral_enabled: data.referral_enabled
||
false
,
referral_offer: data.referral_offer
||
null
,
nps_enabled: data.nps_enabled
||
false
,
nps_question: data.nps_question
||
null
,
social_widget_enabled: data.social_widget_enabled !== undefined ? data.social_widget_enabled : true
,
broadcast_enabled: data.broadcast_enabled
||
false
,
rebooking_enabled: data.rebooking_enabled
||
false
,
competitor_insights_enabled: data.competitor_insights_enabled
||
false
,
competitor_urls: data.competitor_urls
||
null
,
review_widget_theme: data.review_widget_theme
||
'light'
,
google_review_link: data.google_review_link
||
null
@ -120,6 +194,82 @@ module.exports = class BusinessesDBApi {
name: item.name
||
null
,
business_type: item.business_type
||
'hybrid'
,
automation_mode: item.automation_mode
||
'set_and_forget'
,
followup_enabled: item.followup_enabled !== undefined ? item.followup_enabled : true
,
followup_delay_days: item.followup_delay_days
||
3
,
max_followups: item.max_followups
||
1
,
ai_reply_enabled: item.ai_reply_enabled
||
false
,
referral_enabled: item.referral_enabled
||
false
,
referral_offer: item.referral_offer
||
null
,
nps_enabled: item.nps_enabled
||
false
,
nps_question: item.nps_question
||
null
,
social_widget_enabled: item.social_widget_enabled !== undefined ? item.social_widget_enabled : true
,
broadcast_enabled: item.broadcast_enabled
||
false
,
rebooking_enabled: item.rebooking_enabled
||
false
,
competitor_insights_enabled: item.competitor_insights_enabled
||
false
,
competitor_urls: item.competitor_urls
||
null
,
review_widget_theme: item.review_widget_theme
||
'light'
,
google_review_link: item.google_review_link
@ -214,6 +364,39 @@ module.exports = class BusinessesDBApi {
if (data.name !== undefined) updatePayload.name = data.name;
if (data.business_type !== undefined) updatePayload.business_type = data.business_type;
if (data.automation_mode !== undefined) updatePayload.automation_mode = data.automation_mode;
if (data.followup_enabled !== undefined) updatePayload.followup_enabled = data.followup_enabled;
if (data.followup_delay_days !== undefined) updatePayload.followup_delay_days = data.followup_delay_days;
if (data.max_followups !== undefined) updatePayload.max_followups = data.max_followups;
if (data.ai_reply_enabled !== undefined) updatePayload.ai_reply_enabled = data.ai_reply_enabled;
if (data.referral_enabled !== undefined) updatePayload.referral_enabled = data.referral_enabled;
if (data.referral_offer !== undefined) updatePayload.referral_offer = data.referral_offer;
if (data.nps_enabled !== undefined) updatePayload.nps_enabled = data.nps_enabled;
if (data.nps_question !== undefined) updatePayload.nps_question = data.nps_question;
if (data.social_widget_enabled !== undefined) updatePayload.social_widget_enabled = data.social_widget_enabled;
if (data.broadcast_enabled !== undefined) updatePayload.broadcast_enabled = data.broadcast_enabled;
if (data.rebooking_enabled !== undefined) updatePayload.rebooking_enabled = data.rebooking_enabled;
if (data.competitor_insights_enabled !== undefined) updatePayload.competitor_insights_enabled = data.competitor_insights_enabled;
if (data.competitor_urls !== undefined) updatePayload.competitor_urls = data.competitor_urls;
if (data.review_widget_theme !== undefined) updatePayload.review_widget_theme = data.review_widget_theme;
if (data.google_review_link !== undefined) updatePayload.google_review_link = data.google_review_link;
@ -384,9 +567,6 @@ module.exports = class BusinessesDBApi {
offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
let include = [

View File

@ -12,6 +12,47 @@ const config = require('../../config');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
const INTERNAL_ADMIN_ROLE_NAMES = ['Administrator'];
function getRoleName(currentUser) {
return currentUser?.app_role?.name || currentUser?.app_role?.dataValues?.name || '';
}
function isInternalAdminUser(currentUser) {
return currentUser?.email === 'admin@flatlogic.com' || INTERNAL_ADMIN_ROLE_NAMES.includes(getRoleName(currentUser));
}
function getTeamOwnerId(currentUser) {
return currentUser?.createdById || currentUser?.id || null;
}
function applyCurrentUserScope(where, currentUser) {
if (!currentUser || isInternalAdminUser(currentUser)) {
return where;
}
const teamOwnerId = getTeamOwnerId(currentUser);
if (!teamOwnerId) {
return where;
}
const teamScope = {
[Op.or]: [
{ id: teamOwnerId },
{ createdById: teamOwnerId },
],
};
if (!where || Reflect.ownKeys(where).length === 0) {
return teamScope;
}
return {
[Op.and]: [where, teamScope],
};
}
module.exports = class UsersDBApi {
static async create(data, options) {
@ -95,9 +136,16 @@ module.exports = class UsersDBApi {
if (!data.data.app_role) {
const role = await db.roles.findOne({
where: { name: 'User' },
const defaultRoleNames = isInternalAdminUser(currentUser)
? [config.roles?.user || 'User']
: ['Operations Manager', 'Account Owner'];
const roles = await db.roles.findAll({
where: { name: { [Op.in]: defaultRoleNames } },
transaction,
});
const role = defaultRoleNames
.map((roleName) => roles.find((candidate) => candidate.name === roleName))
.find(Boolean);
if (role) {
await users.setApp_role(role, {
transaction,
@ -237,7 +285,10 @@ module.exports = class UsersDBApi {
const transaction = (options && options.transaction) || undefined;
const users = await db.users.findByPk(id, {}, {transaction});
const users = await db.users.findOne({
where: applyCurrentUserScope({ id }, currentUser),
transaction,
});
@ -342,11 +393,11 @@ module.exports = class UsersDBApi {
const transaction = (options && options.transaction) || undefined;
const users = await db.users.findAll({
where: {
where: applyCurrentUserScope({
id: {
[Op.in]: ids,
},
},
}, currentUser),
transaction,
});
@ -370,7 +421,10 @@ module.exports = class UsersDBApi {
const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined;
const users = await db.users.findByPk(id, options);
const users = await db.users.findOne({
where: applyCurrentUserScope({ id }, currentUser),
transaction,
});
await users.update({
deletedBy: currentUser.id
@ -388,10 +442,10 @@ module.exports = class UsersDBApi {
static async findBy(where, options) {
const transaction = (options && options.transaction) || undefined;
const users = await db.users.findOne(
{ where },
{ transaction },
);
const users = await db.users.findOne({
where: applyCurrentUserScope(where, options?.currentUser),
transaction,
});
if (!users) {
return users;
@ -455,9 +509,6 @@ module.exports = class UsersDBApi {
offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
let include = [
@ -723,6 +774,8 @@ module.exports = class UsersDBApi {
where = applyCurrentUserScope(where, options?.currentUser);
const queryOptions = {
where,
include,
@ -752,7 +805,7 @@ module.exports = class UsersDBApi {
}
}
static async findAllAutocomplete(query, limit, offset, ) {
static async findAllAutocomplete(query, limit, offset, options = {}) {
let where = {};
@ -770,6 +823,8 @@ module.exports = class UsersDBApi {
};
}
where = applyCurrentUserScope(where, options?.currentUser);
const records = await db.users.findAll({
attributes: [ 'id', 'firstName' ],
where,

View File

@ -0,0 +1,89 @@
'use strict';
const businessColumns = {
business_type: { type: 'TEXT', defaultValue: 'hybrid', allowNull: false },
automation_mode: { type: 'TEXT', defaultValue: 'set_and_forget', allowNull: false },
followup_enabled: { type: 'BOOLEAN', defaultValue: true, allowNull: false },
followup_delay_days: { type: 'INTEGER', defaultValue: 3, allowNull: false },
max_followups: { type: 'INTEGER', defaultValue: 1, allowNull: false },
ai_reply_enabled: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
referral_enabled: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
referral_offer: { type: 'TEXT' },
nps_enabled: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
nps_question: { type: 'TEXT' },
social_widget_enabled: { type: 'BOOLEAN', defaultValue: true, allowNull: false },
broadcast_enabled: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
rebooking_enabled: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
competitor_insights_enabled: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
competitor_urls: { type: 'TEXT' },
review_widget_theme: { type: 'TEXT', defaultValue: 'light', allowNull: false },
};
function normalizeColumnDefinition(Sequelize, definition) {
const normalized = { ...definition };
if (definition.type === 'TEXT') {
normalized.type = Sequelize.DataTypes.TEXT;
}
if (definition.type === 'BOOLEAN') {
normalized.type = Sequelize.DataTypes.BOOLEAN;
}
if (definition.type === 'INTEGER') {
normalized.type = Sequelize.DataTypes.INTEGER;
}
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

@ -288,6 +288,97 @@ custom_review_link: {
},
business_type: {
type: DataTypes.TEXT,
allowNull: false,
defaultValue: 'hybrid',
},
automation_mode: {
type: DataTypes.TEXT,
allowNull: false,
defaultValue: 'set_and_forget',
},
followup_enabled: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
followup_delay_days: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 3,
},
max_followups: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 1,
},
ai_reply_enabled: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
referral_enabled: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
referral_offer: {
type: DataTypes.TEXT,
},
nps_enabled: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
nps_question: {
type: DataTypes.TEXT,
},
social_widget_enabled: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
broadcast_enabled: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
rebooking_enabled: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
competitor_insights_enabled: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
competitor_urls: {
type: DataTypes.TEXT,
},
review_widget_theme: {
type: DataTypes.TEXT,
allowNull: false,
defaultValue: 'light',
},
importHash: {

View File

@ -0,0 +1,387 @@
'use strict';
const bcrypt = require('bcrypt');
const config = require('../../config');
const DEMO_PASSWORD = 'ProDemo2026!';
const ids = {
user: '78e31e71-a4cd-4e73-aa11-2b0b681f0a33',
business: '1f862b7d-1b83-4a37-95ff-6b23a0f91d01',
customerA: 'b780ce14-f076-42a6-9d14-70c0559ef4e7',
customerB: '089a6496-37a5-47b6-b282-814d8d4bbb49',
customerC: 'cc7c37d2-f93a-4817-8a36-d2c26ee98b26',
transactionA: '0e57256e-868b-4d69-a896-45f9becc926b',
transactionB: '129b1899-4dc4-4cc8-8b30-6af4c8e64b8e',
requestA: '9fcad09d-4d77-49e6-a08c-e7e65f8938ac',
requestB: 'cb93d8a8-c6a7-44fc-af98-e0b18990561f',
requestC: '4d8347f1-694b-40ec-90a7-680c7fb8501f',
eventA: 'c9965d28-3895-4263-8fdf-ee05a5a709d3',
eventB: 'c251b361-d407-49f3-9694-6d0e72d0dd8a',
emailLogA: 'eb0c8667-9913-4851-b443-e18cbb22737e',
};
module.exports = {
up: async (queryInterface, Sequelize) => {
const now = new Date();
const trialStartedAt = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000);
const trialEndsAt = new Date(now.getTime() + 12 * 24 * 60 * 60 * 1000);
const paidAtA = new Date(now.getTime() - 36 * 60 * 60 * 1000);
const paidAtB = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const scheduledSoon = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000);
const sentYesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const reviewedLastWeek = new Date(now.getTime() - 6 * 24 * 60 * 60 * 1000);
const passwordHash = bcrypt.hashSync(DEMO_PASSWORD, config.bcrypt.saltRounds);
const [accountOwnerRole] = await queryInterface.sequelize.query(
'SELECT id FROM "roles" WHERE name = :roleName LIMIT 1',
{
replacements: { roleName: 'Account Owner' },
type: Sequelize.QueryTypes.SELECT,
},
);
if (!accountOwnerRole?.id) {
throw new Error('Cannot seed Pro demo customer: Account Owner role was not found.');
}
const [existingDemoUser] = await queryInterface.sequelize.query(
'SELECT id FROM "users" WHERE email = :email LIMIT 1',
{
replacements: { email: 'pro@reviewflow.demo' },
type: Sequelize.QueryTypes.SELECT,
},
);
const demoUserId = existingDemoUser?.id || ids.user;
if (existingDemoUser?.id) {
await queryInterface.sequelize.query(
`UPDATE "users"
SET "firstName" = :firstName,
"lastName" = :lastName,
"emailVerified" = true,
"provider" = :provider,
"password" = :password,
"disabled" = false,
"subscriptionPlanId" = :subscriptionPlanId,
"subscriptionStatus" = :subscriptionStatus,
"trialStartedAt" = :trialStartedAt,
"trialEndsAt" = :trialEndsAt,
"app_roleId" = :appRoleId,
"updatedAt" = :updatedAt,
"deletedAt" = NULL
WHERE id = :id`,
{
replacements: {
id: demoUserId,
firstName: 'Pro',
lastName: 'Demo Customer',
provider: config.providers.LOCAL,
password: passwordHash,
subscriptionPlanId: 'pro',
subscriptionStatus: 'trialing',
trialStartedAt,
trialEndsAt,
appRoleId: accountOwnerRole.id,
updatedAt: now,
},
},
);
} else {
await queryInterface.bulkInsert('users', [
{
id: demoUserId,
firstName: 'Pro',
lastName: 'Demo Customer',
email: 'pro@reviewflow.demo',
disabled: false,
password: passwordHash,
emailVerified: true,
provider: config.providers.LOCAL,
subscriptionPlanId: 'pro',
subscriptionStatus: 'trialing',
trialStartedAt,
trialEndsAt,
app_roleId: accountOwnerRole.id,
createdById: demoUserId,
updatedById: demoUserId,
createdAt: now,
updatedAt: now,
},
]);
}
await queryInterface.sequelize.query(
`UPDATE "users"
SET "createdById" = COALESCE("createdById", :id),
"updatedById" = :id
WHERE id = :id`,
{ replacements: { id: demoUserId } },
);
await queryInterface.bulkDelete('email_delivery_logs', { id: ids.emailLogA });
await queryInterface.bulkDelete('stripe_events', { id: { [Sequelize.Op.in]: [ids.eventA, ids.eventB] } });
await queryInterface.bulkDelete('review_requests', { id: { [Sequelize.Op.in]: [ids.requestA, ids.requestB, ids.requestC] } });
await queryInterface.bulkDelete('transactions', { id: { [Sequelize.Op.in]: [ids.transactionA, ids.transactionB] } });
await queryInterface.bulkDelete('customers', { id: { [Sequelize.Op.in]: [ids.customerA, ids.customerB, ids.customerC] } });
await queryInterface.bulkDelete('businesses', { id: ids.business });
await queryInterface.bulkInsert('businesses', [
{
id: ids.business,
ownerId: demoUserId,
name: 'Harbor Freight Logistics',
business_type: 'hybrid',
review_destination: 'google',
google_review_link: 'https://g.page/r/harbor-freight-logistics/review',
yelp_review_link: 'https://www.yelp.com/biz/harbor-freight-logistics',
facebook_review_link: 'https://www.facebook.com/harborfreightlogistics/reviews',
trustpilot_review_link: 'https://www.trustpilot.com/review/harborfreightlogistics.example',
custom_review_link: 'https://reviews.example.com/harbor-freight-logistics',
delay_days: 3,
email_subject_template: 'How was your delivery with Harbor Freight Logistics?',
email_body_template: 'Hi {customerName}, thanks for choosing Harbor Freight Logistics. Please leave a review here: {reviewLink}',
is_active: true,
stripe_connected: true,
stripe_account_reference: 'acct_demo_harbor_logistics',
stripe_connected_at: now,
paypal_connected: true,
paypal_merchant_reference: 'merchant_demo_harbor_logistics',
paypal_connected_at: now,
shopify_connected: true,
shopify_store_reference: 'harbor-demo.myshopify.com',
shopify_connected_at: now,
shopify_hosted_reviews_enabled: true,
automation_mode: 'set_and_forget',
followup_enabled: true,
followup_delay_days: 3,
max_followups: 1,
ai_reply_enabled: true,
referral_enabled: true,
referral_offer: '$25 credit for every referred shipper who books a delivery.',
nps_enabled: true,
nps_question: 'How likely are you to recommend our delivery team?',
social_widget_enabled: true,
broadcast_enabled: true,
rebooking_enabled: true,
competitor_insights_enabled: true,
competitor_urls: 'https://www.trustpilot.com/review/example-competitor-logistics',
review_widget_theme: 'light',
createdById: demoUserId,
updatedById: demoUserId,
createdAt: now,
updatedAt: now,
},
]);
await queryInterface.bulkInsert('customers', [
{
id: ids.customerA,
businessId: ids.business,
email: 'mia@northstarfoods.example',
name: 'Mia Torres',
phone: '+1 555 0101',
stripe_customer_reference: 'cus_demo_mia_torres',
contact_status: 'active',
last_transaction_at: paidAtA,
createdById: demoUserId,
updatedById: demoUserId,
createdAt: paidAtA,
updatedAt: now,
},
{
id: ids.customerB,
businessId: ids.business,
email: 'dispatch@evergreenretail.example',
name: 'Evergreen Retail Dispatch',
phone: '+1 555 0102',
paypal_customer_reference: 'payer_demo_evergreen',
contact_status: 'active',
last_transaction_at: paidAtB,
createdById: demoUserId,
updatedById: demoUserId,
createdAt: paidAtB,
updatedAt: now,
},
{
id: ids.customerC,
businessId: ids.business,
email: 'ops@ridgewayparts.example',
name: 'Ridgeway Parts Ops',
phone: '+1 555 0103',
shopify_customer_reference: 'shopify_customer_demo_ridgeway',
contact_status: 'active',
last_transaction_at: reviewedLastWeek,
createdById: demoUserId,
updatedById: demoUserId,
createdAt: reviewedLastWeek,
updatedAt: now,
},
]);
await queryInterface.bulkInsert('transactions', [
{
id: ids.transactionA,
businessId: ids.business,
customerId: ids.customerA,
stripe_payment_reference: 'pi_demo_roadway_1001',
payment_provider: 'stripe',
provider_event_reference: 'evt_demo_roadway_1001',
amount: 1280.5,
currency: 'USD',
payment_status: 'succeeded',
paid_at: paidAtA,
description: 'Temperature-controlled regional delivery',
receipt_email: 'mia@northstarfoods.example',
createdById: demoUserId,
updatedById: demoUserId,
createdAt: paidAtA,
updatedAt: now,
},
{
id: ids.transactionB,
businessId: ids.business,
customerId: ids.customerB,
paypal_payment_reference: 'paypal_demo_evergreen_2044',
payment_provider: 'paypal',
provider_event_reference: 'WH-DEMO-EVERGREEN-2044',
amount: 845,
currency: 'USD',
payment_status: 'succeeded',
paid_at: paidAtB,
description: 'Last-mile pallet delivery',
receipt_email: 'dispatch@evergreenretail.example',
createdById: demoUserId,
updatedById: demoUserId,
createdAt: paidAtB,
updatedAt: now,
},
]);
await queryInterface.bulkInsert('review_requests', [
{
id: ids.requestA,
businessId: ids.business,
customerId: ids.customerA,
transactionId: ids.transactionA,
status: 'pending',
scheduled_for: scheduledSoon,
email_subject: 'How was your delivery with Harbor Freight Logistics?',
email_body: 'Hi Mia,\n\nThank you for choosing Harbor Freight Logistics. Would you share a quick review of your delivery experience?\n\nLeave a review: https://g.page/r/harbor-freight-logistics/review\n\nThank you,\nHarbor Freight Logistics',
review_link: 'https://g.page/r/harbor-freight-logistics/review',
tracking_token: 'demo-pro-pending-token',
review_platform: 'google',
createdById: demoUserId,
updatedById: demoUserId,
createdAt: paidAtA,
updatedAt: now,
},
{
id: ids.requestB,
businessId: ids.business,
customerId: ids.customerB,
transactionId: ids.transactionB,
status: 'clicked',
scheduled_for: sentYesterday,
sent_at: sentYesterday,
clicked_at: new Date(sentYesterday.getTime() + 3 * 60 * 60 * 1000),
email_subject: 'Thanks from Harbor Freight Logistics',
email_body: 'Hi Evergreen Retail Dispatch,\n\nThanks for trusting our team with your pallet delivery. Please share your feedback when you have a moment.\n\nLeave a review: https://reviews.example.com/harbor-freight-logistics\n\nThank you,\nHarbor Freight Logistics',
review_link: 'https://reviews.example.com/harbor-freight-logistics',
tracking_token: 'demo-pro-clicked-token',
review_platform: 'custom',
createdById: demoUserId,
updatedById: demoUserId,
createdAt: sentYesterday,
updatedAt: now,
},
{
id: ids.requestC,
businessId: ids.business,
customerId: ids.customerC,
status: 'reviewed',
scheduled_for: reviewedLastWeek,
sent_at: reviewedLastWeek,
clicked_at: new Date(reviewedLastWeek.getTime() + 2 * 60 * 60 * 1000),
reviewed_at: new Date(reviewedLastWeek.getTime() + 4 * 60 * 60 * 1000),
submitted_at: new Date(reviewedLastWeek.getTime() + 4 * 60 * 60 * 1000),
email_subject: 'How did our delivery team do?',
email_body: 'Hi Ridgeway Parts Ops,\n\nThanks for your order. Please rate your delivery experience using the hosted review form.\n\nLeave a review: /review/demo-pro-reviewed-token\n\nThank you,\nHarbor Freight Logistics',
review_link: '/review/demo-pro-reviewed-token',
tracking_token: 'demo-pro-reviewed-token',
review_platform: 'shopify_hosted',
review_rating: 5,
review_title: 'Dependable delivery team',
review_content: 'The driver arrived on schedule and the shipment tracking updates were clear.',
reviewer_display_name: 'Ridgeway Parts Ops',
createdById: demoUserId,
updatedById: demoUserId,
createdAt: reviewedLastWeek,
updatedAt: now,
},
]);
await queryInterface.bulkInsert('stripe_events', [
{
id: ids.eventA,
businessId: ids.business,
stripe_event_reference: 'evt_demo_roadway_1001',
provider: 'stripe',
provider_event_type: 'payment_intent.succeeded',
event_type: 'payment_intent_succeeded',
received_at: paidAtA,
processed: true,
processed_at: paidAtA,
payload_json: JSON.stringify({ demo: true, provider: 'stripe', amount: 1280.5 }),
createdById: demoUserId,
updatedById: demoUserId,
createdAt: paidAtA,
updatedAt: now,
},
{
id: ids.eventB,
businessId: ids.business,
stripe_event_reference: 'WH-DEMO-EVERGREEN-2044',
provider: 'paypal',
provider_event_type: 'PAYMENT.CAPTURE.COMPLETED',
event_type: 'charge_succeeded',
received_at: paidAtB,
processed: true,
processed_at: paidAtB,
payload_json: JSON.stringify({ demo: true, provider: 'paypal', amount: 845 }),
createdById: demoUserId,
updatedById: demoUserId,
createdAt: paidAtB,
updatedAt: now,
},
]);
await queryInterface.bulkInsert('email_delivery_logs', [
{
id: ids.emailLogA,
review_requestId: ids.requestB,
provider: 'smtp',
provider_message_reference: 'smtp-demo-clicked-request',
delivery_status: 'delivered',
queued_at: sentYesterday,
sent_at: sentYesterday,
delivered_at: new Date(sentYesterday.getTime() + 8 * 60 * 1000),
to_email: 'dispatch@evergreenretail.example',
from_email: 'ReviewFlow <app@flatlogic.app>',
subject: 'Thanks from Harbor Freight Logistics',
createdById: demoUserId,
updatedById: demoUserId,
createdAt: sentYesterday,
updatedAt: now,
},
]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.bulkDelete('email_delivery_logs', { id: ids.emailLogA });
await queryInterface.bulkDelete('stripe_events', { id: { [Sequelize.Op.in]: [ids.eventA, ids.eventB] } });
await queryInterface.bulkDelete('review_requests', { id: { [Sequelize.Op.in]: [ids.requestA, ids.requestB, ids.requestC] } });
await queryInterface.bulkDelete('transactions', { id: { [Sequelize.Op.in]: [ids.transactionA, ids.transactionB] } });
await queryInterface.bulkDelete('customers', { id: { [Sequelize.Op.in]: [ids.customerA, ids.customerB, ids.customerC] } });
await queryInterface.bulkDelete('businesses', { id: ids.business });
await queryInterface.bulkDelete('users', { email: 'pro@reviewflow.demo' });
},
};

View File

@ -19,6 +19,72 @@ router.post('/reviews/:trackingToken', wrapAsync(async (req, res) => {
res.status(200).send({ review });
}));
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
router.get('/widgets/:businessId', wrapAsync(async (req, res) => {
const widget = await ReviewFlowService.getSocialWidgetReviews(req.params.businessId, req.query || {});
if (req.query.format === 'json') {
res.status(200).send(widget);
return;
}
const reviewsHtml = widget.reviews.length
? widget.reviews.map((review) => `
<article class="review-card">
<div class="stars" aria-label="${escapeHtml(review.rating)} out of 5 stars">${'★'.repeat(Number(review.rating) || 5)}</div>
<h3>${escapeHtml(review.title || 'Great experience')}</h3>
<p>${escapeHtml(review.content)}</p>
<footer>${escapeHtml(review.reviewer)} · ${escapeHtml(review.source)}</footer>
</article>
`).join('')
: '<div class="empty">Fresh reviews will appear here after customers submit them.</div>';
res.status(200).type('html').send(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
body { margin: 0; background: transparent; color: #0f172a; }
.widget { border: 1px solid #e2e8f0; border-radius: 18px; padding: 18px; background: #fff; box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08); }
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.eyebrow { color: #059669; font-size: 11px; font-weight: 900; letter-spacing: .22em; text-transform: uppercase; }
h2 { margin: 4px 0 0; font-size: 20px; line-height: 1.2; }
.badge { border-radius: 999px; background: #ecfdf5; color: #047857; font-size: 12px; font-weight: 800; padding: 6px 10px; white-space: nowrap; }
.reviews { display: grid; gap: 12px; }
.review-card { border: 1px solid #e2e8f0; border-radius: 14px; padding: 14px; background: #f8fafc; }
.stars { color: #f59e0b; letter-spacing: .08em; font-size: 14px; }
h3 { margin: 8px 0 6px; font-size: 16px; }
p { margin: 0; color: #475569; line-height: 1.55; }
footer { margin-top: 10px; color: #64748b; font-size: 12px; font-weight: 700; }
.empty { border: 1px dashed #cbd5e1; border-radius: 14px; padding: 18px; color: #64748b; text-align: center; }
</style>
</head>
<body>
<section class="widget" aria-label="Customer reviews for ${escapeHtml(widget.business.name)}">
<div class="header">
<div>
<div class="eyebrow">Verified reviews</div>
<h2>${escapeHtml(widget.business.name)}</h2>
</div>
<div class="badge">Powered by Review Flow</div>
</div>
<div class="reviews">${reviewsHtml}</div>
</section>
</body>
</html>`);
}));
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;

View File

@ -54,6 +54,34 @@ function normalizeReviewDestination(value) {
return 'google';
}
function normalizeBusinessType(value, fallback = 'hybrid') {
return ReviewFlowService.normalizeBusinessType(value, fallback);
}
function parseBoolean(value, fallback = false) {
if (value === undefined || value === null || value === '') {
return fallback;
}
return value === true || value === 'true' || value === 'on' || value === 1 || value === '1';
}
function parseInteger(value, fallback, min, max) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return fallback;
}
return Math.max(min, Math.min(Math.round(parsed), max));
}
async function assertProFeatureForEnabledFlag(currentUser, enabled, featureKey) {
if (enabled) {
await SubscriptionService.assertFeatureAccess(currentUser, featureKey);
}
}
function getReviewLinkField(reviewDestination) {
return REVIEW_LINK_FIELDS[reviewDestination] || null;
}
@ -126,6 +154,7 @@ router.get('/summary', wrapAsync(async (req, res) => {
paymentEvents,
recentTransactions,
recentEvents,
businesses,
] = await Promise.all([
db.review_requests.count({ where: { createdById: currentUser.id, status: 'pending' } }),
db.review_requests.count({ where: { createdById: currentUser.id, status: 'sent' } }),
@ -151,6 +180,11 @@ router.get('/summary', wrapAsync(async (req, res) => {
order: [['createdAt', 'DESC']],
limit: 6,
}),
db.businesses.findAll({
where: { createdById: currentUser.id },
order: [['updatedAt', 'DESC']],
limit: 25,
}),
]);
res.status(200).send({
@ -158,6 +192,8 @@ router.get('/summary', wrapAsync(async (req, res) => {
requests,
recentTransactions,
recentEvents,
businesses: businesses.map((business) => ReviewFlowService.serializeBusiness(req, business)),
primaryBusiness: businesses[0] ? ReviewFlowService.serializeBusiness(req, businesses[0]) : null,
});
}));
@ -167,6 +203,12 @@ router.post('/request', wrapAsync(async (req, res) => {
const businessName = normalizeString(body.businessName);
const reviewLink = normalizeString(body.reviewLink);
const reviewDestination = normalizeReviewDestination(body.reviewDestination || body.reviewPlatform || 'google');
const businessType = normalizeBusinessType(body.businessType || body.business_type, 'hybrid');
if (!ReviewFlowService.isReviewDestinationAllowedForBusinessType(businessType, reviewDestination)) {
const error = new Error('This review destination does not match the selected business type. Choose Hybrid if this business needs both local and online options.');
error.code = 400;
throw error;
}
const isHostedReviewDestination = reviewDestination === 'shopify_hosted';
const reviewLinkField = getReviewLinkField(reviewDestination);
const customerEmail = normalizeString(body.customerEmail).toLowerCase();
@ -206,10 +248,12 @@ router.post('/request', wrapAsync(async (req, res) => {
? ReviewFlowService.getHostedReviewUrl(req, trackingToken)
: reviewLink;
const transaction = await db.sequelize.transaction();
let transactionCommitted = false;
try {
const businessDefaults = {
name: businessName,
business_type: businessType,
review_destination: reviewDestination,
shopify_hosted_reviews_enabled: isHostedReviewDestination,
delay_days: delayDays,
@ -218,6 +262,7 @@ router.post('/request', wrapAsync(async (req, res) => {
is_active: true,
createdById: currentUser.id,
updatedById: currentUser.id,
ownerId: currentUser.id,
};
if (reviewLink && reviewLinkField) {
@ -231,6 +276,7 @@ router.post('/request', wrapAsync(async (req, res) => {
});
const businessUpdates = {
business_type: businessType,
review_destination: reviewDestination,
shopify_hosted_reviews_enabled: business.shopify_hosted_reviews_enabled || isHostedReviewDestination,
delay_days: delayDays,
@ -282,6 +328,16 @@ router.post('/request', wrapAsync(async (req, res) => {
}, { transaction });
await transaction.commit();
transactionCommitted = true;
let delivery = null;
if (scheduledFor.getTime() <= Date.now()) {
delivery = await ReviewFlowService.processDueReviewRequests(currentUser, {
limit: 1,
requestId: reviewRequest.id,
});
}
const createdRequest = await db.review_requests.findByPk(reviewRequest.id, {
include: [
@ -290,13 +346,137 @@ router.post('/request', wrapAsync(async (req, res) => {
],
});
res.status(201).send({ request: createdRequest });
res.status(201).send({ request: createdRequest, delivery });
} catch (error) {
await transaction.rollback();
if (!transactionCommitted) {
await transaction.rollback();
}
throw error;
}
}));
router.post('/automation/run-due', wrapAsync(async (req, res) => {
await SubscriptionService.assertFeatureAccess(req.currentUser, 'set_and_forget_automation');
const result = await ReviewFlowService.processDueReviewRequests(req.currentUser, req.body || {});
res.status(200).send(result);
}));
router.put('/growth-tools/business', wrapAsync(async (req, res) => {
const currentUser = req.currentUser;
const body = req.body || {};
const businessId = normalizeString(body.businessId || body.id);
const businessName = normalizeString(body.businessName || body.name || 'Review Flow Business');
let business = businessId
? await db.businesses.findOne({ where: { id: businessId, createdById: currentUser.id } })
: await db.businesses.findOne({ where: { name: businessName, createdById: currentUser.id } });
if (businessId && !business) {
const error = new Error('Business not found for this account.');
error.code = 404;
throw error;
}
if (!business) {
await SubscriptionService.assertCanCreateBusinesses(currentUser, 1);
business = await db.businesses.create({
name: businessName,
business_type: normalizeBusinessType(body.businessType || body.business_type, 'hybrid'),
automation_mode: 'set_and_forget',
is_active: true,
createdById: currentUser.id,
updatedById: currentUser.id,
ownerId: currentUser.id,
});
}
const businessType = normalizeBusinessType(body.businessType || body.business_type, business.business_type || 'hybrid');
const reviewDestination = normalizeReviewDestination(body.reviewDestination || body.review_destination || business.review_destination || 'google');
if (!ReviewFlowService.isReviewDestinationAllowedForBusinessType(businessType, reviewDestination)) {
const error = new Error('This review destination does not match the selected business type. Choose Hybrid if this business needs both local and online options.');
error.code = 400;
throw error;
}
const aiReplyEnabled = parseBoolean(body.aiReplyEnabled ?? body.ai_reply_enabled, business.ai_reply_enabled);
const referralEnabled = parseBoolean(body.referralEnabled ?? body.referral_enabled, business.referral_enabled);
const npsEnabled = parseBoolean(body.npsEnabled ?? body.nps_enabled, business.nps_enabled);
const broadcastEnabled = parseBoolean(body.broadcastEnabled ?? body.broadcast_enabled, business.broadcast_enabled);
const rebookingEnabled = parseBoolean(body.rebookingEnabled ?? body.rebooking_enabled, business.rebooking_enabled);
const competitorInsightsEnabled = parseBoolean(body.competitorInsightsEnabled ?? body.competitor_insights_enabled, business.competitor_insights_enabled);
await assertProFeatureForEnabledFlag(currentUser, aiReplyEnabled, 'ai_review_replies');
await assertProFeatureForEnabledFlag(currentUser, referralEnabled, 'referral_campaigns');
await assertProFeatureForEnabledFlag(currentUser, npsEnabled, 'nps_surveys');
await assertProFeatureForEnabledFlag(currentUser, broadcastEnabled, 'marketing_broadcasts');
await assertProFeatureForEnabledFlag(currentUser, rebookingEnabled, 'rebooking_campaigns');
await assertProFeatureForEnabledFlag(currentUser, competitorInsightsEnabled, 'competitor_insights');
const updatePayload = {
name: businessName || business.name,
business_type: businessType,
automation_mode: normalizeString(body.automationMode || body.automation_mode) || 'set_and_forget',
review_destination: reviewDestination,
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),
max_followups: parseInteger(body.maxFollowups ?? body.max_followups, business.max_followups || 1, 0, 5),
ai_reply_enabled: aiReplyEnabled,
referral_enabled: referralEnabled,
referral_offer: normalizeString(body.referralOffer ?? body.referral_offer) || business.referral_offer || '',
nps_enabled: npsEnabled,
nps_question: normalizeString(body.npsQuestion ?? body.nps_question) || business.nps_question || 'How likely are you to recommend us to a friend?',
social_widget_enabled: parseBoolean(body.socialWidgetEnabled ?? body.social_widget_enabled, business.social_widget_enabled !== false),
broadcast_enabled: broadcastEnabled,
rebooking_enabled: rebookingEnabled,
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',
is_active: true,
updatedById: currentUser.id,
};
await business.update(updatePayload);
const refreshedBusiness = await db.businesses.findByPk(business.id);
res.status(200).send({ business: ReviewFlowService.serializeBusiness(req, refreshedBusiness) });
}));
router.post('/growth-tools/broadcast', wrapAsync(async (req, res) => {
const campaignType = normalizeString(req.body?.campaignType || 'broadcast');
const featureByCampaign = {
broadcast: 'marketing_broadcasts',
referral: 'referral_campaigns',
nps: 'nps_surveys',
rebooking: 'rebooking_campaigns',
};
await SubscriptionService.assertFeatureAccess(req.currentUser, featureByCampaign[campaignType] || 'marketing_broadcasts');
const result = await ReviewFlowService.queueCustomerCampaign(req.currentUser, req.body || {});
res.status(200).send(result);
}));
router.post('/growth-tools/competitor-insights', wrapAsync(async (req, res) => {
await SubscriptionService.assertFeatureAccess(req.currentUser, 'competitor_insights');
const result = await ReviewFlowService.buildCompetitorInsights(req.currentUser, req.body || {});
res.status(200).send(result);
}));
router.get('/social-widget/:businessId', wrapAsync(async (req, res) => {
await SubscriptionService.assertFeatureAccess(req.currentUser, 'social_proof_widgets');
const result = await ReviewFlowService.getSocialWidgetReviews(req.params.businessId, req.query || {});
const origin = `${req.protocol}://${req.get('host')}`;
res.status(200).send({
...result,
embedCode: `<iframe src="${origin}/api/reviewflow-public/widgets/${req.params.businessId}" style="width:100%;border:0;min-height:320px;border-radius:16px;" loading="lazy"></iframe>`,
});
}));
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;

View File

@ -347,7 +347,6 @@ router.get('/count', wrapAsync(async (req, res) => {
const currentUser = req.currentUser;
const payload = await UsersDBApi.findAll(
req.query,
null,
{ countOnly: true, currentUser }
);
@ -385,7 +384,7 @@ router.get('/autocomplete', async (req, res) => {
req.query.query,
req.query.limit,
req.query.offset,
{ currentUser: req.currentUser },
);
res.status(200).send(payload);
@ -426,11 +425,16 @@ router.get('/autocomplete', async (req, res) => {
router.get('/:id', wrapAsync(async (req, res) => {
const payload = await UsersDBApi.findBy(
{ id: req.params.id },
{ currentUser: req.currentUser },
);
delete payload.password;
if (!payload) {
const error = new Error('User not found.');
error.code = 404;
throw error;
}
delete payload.password;
res.status(200).send(payload);
}));

View File

@ -1,5 +1,8 @@
const crypto = require('crypto');
const axios = require('axios');
const config = require('../config');
const db = require('../db/models');
const EmailSender = require('./email');
const SubscriptionService = require('./subscription');
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@ -156,6 +159,12 @@ const REVIEW_CHANNELS = {
},
};
const BUSINESS_TYPES = new Set(['local', 'online', 'hybrid']);
const LOCAL_REVIEW_DESTINATIONS = new Set(['google', 'facebook', 'yelp', 'angi', 'opentable', 'custom']);
const ONLINE_REVIEW_DESTINATIONS = new Set(['trustpilot', 'shopify_hosted', 'custom']);
const LOCAL_TRIGGER_PROVIDERS = new Set(['stripe', 'square', 'paypal']);
const ONLINE_TRIGGER_PROVIDERS = new Set(['stripe', 'paypal', 'shopify', 'woocommerce']);
function normalizeString(value) {
return typeof value === 'string' ? value.trim() : '';
}
@ -182,6 +191,60 @@ function getNormalizedReviewDestination(value) {
return 'google';
}
function normalizeBusinessType(value, fallback = 'hybrid') {
const normalizedType = normalizeString(value || fallback).toLowerCase();
if (BUSINESS_TYPES.has(normalizedType)) {
return normalizedType;
}
return BUSINESS_TYPES.has(fallback) ? fallback : 'hybrid';
}
function isReviewDestinationAllowedForBusinessType(businessType, destination) {
const normalizedType = normalizeBusinessType(businessType);
const normalizedDestination = getNormalizedReviewDestination(destination);
if (normalizedType === 'hybrid') {
return true;
}
if (normalizedType === 'local') {
return LOCAL_REVIEW_DESTINATIONS.has(normalizedDestination);
}
return ONLINE_REVIEW_DESTINATIONS.has(normalizedDestination);
}
function assertReviewDestinationAllowedForBusinessType(businessType, destination) {
if (!isReviewDestinationAllowedForBusinessType(businessType, destination)) {
const label = normalizeBusinessType(businessType) === 'local' ? 'local' : 'online/ecommerce';
throw httpError(`This review destination does not match a ${label} business setup. Choose Hybrid if you need both local and online options.`, 400);
}
}
function isProviderAllowedForBusinessType(businessType, provider) {
const normalizedType = normalizeBusinessType(businessType);
const normalizedProvider = normalizeString(provider).toLowerCase();
if (normalizedType === 'hybrid') {
return Boolean(PROVIDERS[normalizedProvider]);
}
if (normalizedType === 'local') {
return LOCAL_TRIGGER_PROVIDERS.has(normalizedProvider);
}
return ONLINE_TRIGGER_PROVIDERS.has(normalizedProvider);
}
function assertProviderAllowedForBusinessType(businessType, provider) {
if (!isProviderAllowedForBusinessType(businessType, provider)) {
const label = normalizeBusinessType(businessType) === 'local' ? 'local' : 'online/ecommerce';
throw httpError(`This provider does not match a ${label} business setup. Choose Hybrid if this business needs both local and online triggers.`, 400);
}
}
function getReviewChannel(destination) {
return REVIEW_CHANNELS[getNormalizedReviewDestination(destination)];
}
@ -337,6 +400,191 @@ function buildEmailBody(customerName, businessName, reviewLink) {
].join('\n');
}
function escapeHtml(value) {
return normalizeString(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function textToHtml(value) {
return escapeHtml(value)
.split('\n')
.map((line) => line || '&nbsp;')
.join('<br />');
}
function buildReviewRequestEmail(request, toEmail) {
const businessName = request.business?.name || 'Review Flow';
const customerName = request.customer?.name || 'there';
const reviewLink = request.review_link || '';
const body = request.email_body || buildEmailBody(customerName, businessName, reviewLink);
return {
to: toEmail,
subject: request.email_subject || `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="border:1px solid #e2e8f0;border-radius:18px;padding:24px;background:#ffffff;">
<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>` : ''}
</div>
<p style="font-size:12px;color:#64748b;margin-top:16px;">Sent by Review Flow for ${escapeHtml(businessName)}.</p>
</div>
`,
};
}
function getSmsConfig() {
return {
accountSid: process.env.TWILIO_ACCOUNT_SID || '',
authToken: process.env.TWILIO_AUTH_TOKEN || '',
fromNumber: process.env.TWILIO_FROM_NUMBER || process.env.SMS_FROM_NUMBER || '',
};
}
function isSmsConfigured() {
const smsConfig = getSmsConfig();
return Boolean(smsConfig.accountSid && smsConfig.authToken && smsConfig.fromNumber);
}
async function sendReviewRequestSms(request) {
const toPhone = normalizeString(request.customer?.phone);
if (!toPhone) {
return { channel: 'sms', status: 'skipped', reason: 'No customer phone number was provided.' };
}
if (!isSmsConfigured()) {
return {
channel: 'sms',
status: 'skipped',
to: toPhone,
reason: 'SMS provider is not configured. Set TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_FROM_NUMBER to enable SMS delivery.',
};
}
const smsConfig = getSmsConfig();
const businessName = request.business?.name || 'our business';
const reviewLink = request.review_link || '';
const message = `Thanks for choosing ${businessName}. Please leave a review: ${reviewLink}`.slice(0, 1500);
const payload = new URLSearchParams({
To: toPhone,
From: smsConfig.fromNumber,
Body: message,
});
try {
const response = await axios.post(
`https://api.twilio.com/2010-04-01/Accounts/${encodeURIComponent(smsConfig.accountSid)}/Messages.json`,
payload,
{
auth: {
username: smsConfig.accountSid,
password: smsConfig.authToken,
},
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
timeout: 15000,
},
);
return {
channel: 'sms',
status: 'sent',
to: toPhone,
providerMessageReference: response.data?.sid || null,
};
} catch (error) {
console.error('Review Flow SMS delivery failed:', {
requestId: request.id,
to: toPhone,
message: error.message,
response: error.response?.data,
});
return {
channel: 'sms',
status: 'failed',
to: toPhone,
reason: error.response?.data?.message || error.message,
};
}
}
async function sendReviewRequestNotifications(request, currentUser, options = {}) {
const now = options.now || new Date();
const toEmail = normalizeEmail(request.customer?.email);
const deliveries = [];
if (!EMAIL_PATTERN.test(toEmail)) {
const error = new Error(`Missing customer email for request ${request.id}`);
error.code = 'missing_customer_email';
throw error;
}
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,
review_requestId: request.id,
createdById: currentUser.id,
updatedById: currentUser.id,
});
if (!EmailSender.isConfigured) {
const reason = 'Email provider is not configured. Set EMAIL_USER and EMAIL_PASS to enable SMTP delivery.';
await emailLog.update({
delivery_status: 'failed',
error_details: reason,
updatedById: currentUser.id,
});
const error = new Error(reason);
error.code = 'email_provider_not_configured';
throw error;
}
try {
const emailResult = await new EmailSender(buildReviewRequestEmail(request, toEmail)).send();
await emailLog.update({
provider: 'smtp',
provider_message_reference: emailResult?.messageId || emailLog.provider_message_reference,
delivery_status: 'sent',
sent_at: new Date(),
updatedById: currentUser.id,
});
deliveries.push({
channel: 'email',
status: 'sent',
to: toEmail,
providerMessageReference: emailResult?.messageId || null,
});
} catch (error) {
console.error('Review Flow email delivery failed:', {
requestId: request.id,
to: toEmail,
message: error.message,
});
await emailLog.update({
delivery_status: 'failed',
error_details: error.message,
updatedById: currentUser.id,
});
throw error;
}
const smsDelivery = await sendReviewRequestSms(request);
deliveries.push(smsDelivery);
return deliveries;
}
function renderTemplate(template, replacements) {
if (!template) {
return '';
@ -672,6 +920,22 @@ function serializeBusiness(req, business) {
return {
id: business.id,
name: business.name,
business_type: normalizeBusinessType(business.business_type),
automation_mode: business.automation_mode || 'set_and_forget',
followup_enabled: business.followup_enabled !== false,
followup_delay_days: business.followup_delay_days ?? 3,
max_followups: business.max_followups ?? 1,
ai_reply_enabled: Boolean(business.ai_reply_enabled),
referral_enabled: Boolean(business.referral_enabled),
referral_offer: business.referral_offer || '',
nps_enabled: Boolean(business.nps_enabled),
nps_question: business.nps_question || '',
social_widget_enabled: business.social_widget_enabled !== false,
broadcast_enabled: Boolean(business.broadcast_enabled),
rebooking_enabled: Boolean(business.rebooking_enabled),
competitor_insights_enabled: Boolean(business.competitor_insights_enabled),
competitor_urls: business.competitor_urls || '',
review_widget_theme: business.review_widget_theme || 'light',
google_review_link: business.google_review_link,
yelp_review_link: business.yelp_review_link,
facebook_review_link: business.facebook_review_link,
@ -738,6 +1002,10 @@ async function connectProvider(currentUser, body, req) {
business = await db.businesses.findOne({ where: { name: businessName, createdById: currentUser.id } });
}
const businessType = normalizeBusinessType(body.businessType || body.business_type, business?.business_type || 'hybrid');
assertProviderAllowedForBusinessType(businessType, config.provider);
assertReviewDestinationAllowedForBusinessType(businessType, reviewDestination);
if (reviewLink) {
validateUrl(reviewLink, 'Enter a valid review page URL before connecting a webhook.');
}
@ -751,6 +1019,7 @@ async function connectProvider(currentUser, body, req) {
const createPayload = {
name: businessName,
business_type: businessType,
review_destination: reviewDestination,
shopify_hosted_reviews_enabled: Boolean(config.hostedReviewProvider || reviewDestination === 'shopify_hosted'),
delay_days: delayDays,
@ -771,6 +1040,7 @@ async function connectProvider(currentUser, body, req) {
const updates = {
is_active: true,
business_type: businessType,
delay_days: delayDays,
review_destination: reviewDestination,
shopify_hosted_reviews_enabled: Boolean(
@ -797,6 +1067,238 @@ async function connectProvider(currentUser, body, req) {
return serializeBusiness(req, refreshedBusiness);
}
async function getSocialWidgetReviews(businessId, options = {}) {
const business = await db.businesses.findByPk(businessId);
if (!business || business.social_widget_enabled === false) {
throw httpError('Review widget is not enabled for this business.', 404);
}
const reviews = await db.review_requests.findAll({
where: {
businessId: business.id,
status: 'reviewed',
review_rating: { [db.Sequelize.Op.gte]: Number(options.minimumRating) || 4 },
},
include: [
{ model: db.customers, as: 'customer' },
{ model: db.transactions, as: 'transaction' },
],
order: [['reviewed_at', 'DESC'], ['updatedAt', 'DESC']],
limit: Math.min(Number(options.limit) || 8, 25),
});
return {
business: {
id: business.id,
name: business.name,
business_type: normalizeBusinessType(business.business_type),
review_widget_theme: business.review_widget_theme || 'light',
},
reviews: reviews.map((review) => ({
id: review.id,
rating: review.review_rating,
title: review.review_title || '',
content: review.review_content || '',
reviewer: review.reviewer_display_name || review.customer?.name || 'Verified customer',
reviewed_at: review.reviewed_at || review.submitted_at || review.updatedAt,
source: review.review_platform || 'Review Flow',
transaction: review.transaction ? {
payment_provider: review.transaction.payment_provider,
description: review.transaction.description,
} : null,
})),
};
}
async function processDueReviewRequests(currentUser, options = {}) {
const now = new Date();
const limit = Math.min(Number(options.limit) || 50, 200);
const where = {
createdById: currentUser.id,
status: 'pending',
scheduled_for: { [db.Sequelize.Op.lte]: now },
};
if (options.requestId) {
where.id = options.requestId;
}
const dueRequests = await db.review_requests.findAll({
where,
include: [
{ model: db.businesses, as: 'business' },
{ model: db.customers, as: 'customer' },
],
order: [['scheduled_for', 'ASC']],
limit,
});
const cronRun = await db.cron_runs.create({
job_name: 'reviewflow_set_and_forget_due_requests',
started_at: now,
run_status: 'skipped',
processed_count: 0,
sent_count: 0,
error_count: 0,
createdById: currentUser.id,
updatedById: currentUser.id,
});
let sentCount = 0;
let errorCount = 0;
const errors = [];
const deliveries = [];
for (const request of dueRequests) {
try {
const requestDeliveries = await sendReviewRequestNotifications(request, currentUser, { now });
deliveries.push({ requestId: request.id, deliveries: requestDeliveries });
await request.update({
status: 'sent',
sent_at: new Date(),
failure_reason: null,
updatedById: currentUser.id,
});
sentCount += 1;
} catch (error) {
console.error('Review Flow request delivery failed:', {
requestId: request.id,
message: error.message,
});
errorCount += 1;
errors.push(error.message);
deliveries.push({
requestId: request.id,
deliveries: [{ channel: 'email', status: 'failed', reason: error.message }],
});
await request.update({
status: 'failed',
failure_reason: error.message,
updatedById: currentUser.id,
});
}
}
await cronRun.update({
finished_at: new Date(),
run_status: errorCount && sentCount ? 'partial' : errorCount ? 'failed' : dueRequests.length ? 'success' : 'skipped',
processed_count: dueRequests.length,
sent_count: sentCount,
error_count: errorCount,
error_summary: errors.slice(0, 10).join('\n') || null,
updatedById: currentUser.id,
});
return {
processed: dueRequests.length,
sent: sentCount,
failed: errorCount,
errors,
deliveries,
cronRunId: cronRun.id,
};
}
async function queueCustomerCampaign(currentUser, body = {}) {
const businessId = normalizeString(body.businessId);
const subject = normalizeString(body.subject).slice(0, 200);
const message = normalizeString(body.message).slice(0, 5000);
const campaignType = normalizeString(body.campaignType || 'broadcast') || 'broadcast';
requireField(subject, 'Campaign subject is required.');
requireField(message, 'Campaign message is required.');
const where = { createdById: currentUser.id };
if (businessId) {
where.businessId = businessId;
}
const customers = await db.customers.findAll({
where,
order: [['updatedAt', 'DESC']],
limit: Math.min(Number(body.limit) || 250, 500),
});
const deliverableCustomers = customers.filter((customer) => EMAIL_PATTERN.test(normalizeEmail(customer.email)));
const now = new Date();
if (!deliverableCustomers.length) {
return {
queued: 0,
skipped: customers.length,
campaignType,
message: 'No customers with valid email addresses were found for this campaign.',
};
}
await db.email_delivery_logs.bulkCreate(deliverableCustomers.map((customer) => ({
provider: 'unknown',
provider_message_reference: `${campaignType}-${customer.id}-${now.getTime()}`,
delivery_status: 'queued',
queued_at: now,
to_email: normalizeEmail(customer.email),
from_email: 'reviews@reviewflow.local',
subject,
error_details: `Queued ${campaignType} campaign from Growth Tools. Message: ${message}`,
createdById: currentUser.id,
updatedById: currentUser.id,
})));
return {
queued: deliverableCustomers.length,
skipped: customers.length - deliverableCustomers.length,
campaignType,
message: `${deliverableCustomers.length} ${campaignType} messages were queued for provider handoff.`,
};
}
async function buildCompetitorInsights(currentUser, body = {}) {
const businessId = normalizeString(body.businessId);
const competitorUrls = normalizeString(body.competitorUrls || body.competitor_urls);
requireField(competitorUrls, 'Add at least one competitor URL or name.');
const business = businessId
? await db.businesses.findOne({ where: { id: businessId, createdById: currentUser.id } })
: await db.businesses.findOne({ where: { createdById: currentUser.id }, order: [['updatedAt', 'DESC']] });
if (!business) {
throw httpError('Create a business profile before saving competitor insights.', 400);
}
await business.update({
competitor_urls: competitorUrls,
competitor_insights_enabled: true,
updatedById: currentUser.id,
});
const [reviewed, pending, sent, customers] = await Promise.all([
db.review_requests.count({ where: { createdById: currentUser.id, status: 'reviewed' } }),
db.review_requests.count({ where: { createdById: currentUser.id, status: 'pending' } }),
db.review_requests.count({ where: { createdById: currentUser.id, status: 'sent' } }),
db.customers.count({ where: { createdById: currentUser.id } }),
]);
const competitors = competitorUrls.split('\n').map((item) => item.trim()).filter(Boolean).slice(0, 8);
return {
business: serializeBusiness({ get: () => '', protocol: 'https', headers: {} }, business),
competitors,
metrics: { reviewed, pending, sent, customers },
recommendations: [
reviewed < 10
? 'Prioritize review volume first: keep automation on until you have at least 10 fresh testimonials for widgets and sales pages.'
: 'You have enough reviewed feedback to rotate testimonials into your social proof widget.',
pending > sent
? 'There are more pending than sent requests. Run the set-it-and-forget-it processor or check email provider setup.'
: 'Your queue is moving. Keep request timing consistent so competitors cannot outpace your freshness.',
competitors.length > 3
? 'Track the top 3 competitors most often mentioned by prospects so the dashboard stays uncluttered.'
: 'Add 23 direct competitors and review their public messaging monthly.',
],
};
}
async function rotateWebhookToken(currentUser, businessId, provider, req) {
const config = getProviderConfig(provider);
const business = await db.businesses.findOne({ where: { id: businessId, createdById: currentUser.id } });
@ -1198,12 +1700,19 @@ module.exports = {
buildEmailBody,
connectProvider,
generateWebhookToken,
buildCompetitorInsights,
getHostedReviewRequest,
getHostedReviewUrl,
getProviderConfig,
getReviewDestination,
getReviewLink,
getWebhookUrl,
getSocialWidgetReviews,
isProviderAllowedForBusinessType,
isReviewDestinationAllowedForBusinessType,
normalizeBusinessType,
processDueReviewRequests,
queueCustomerCampaign,
listConnectorBusinesses,
processPaymentWebhook,
rotateWebhookToken,

View File

@ -3,11 +3,11 @@ const TRIAL_DAYS = 14;
const subscriptionPlans = [
{
id: 'starter',
name: 'Starter',
name: 'Grow',
priceMonthly: 49,
currency: 'USD',
trialDays: TRIAL_DAYS,
tagline: 'For small teams that want automated review collection without extra marketing automation.',
tagline: 'For review automation that runs after setup: requests, reminders, widgets, and clean local/online routing.',
limits: {
monthlyReviewRequests: 250,
businesses: 1,
@ -15,30 +15,33 @@ const subscriptionPlans = [
paymentConnectors: 5,
},
features: [
'Review Flow dashboard',
'Manual review request creation',
'Hosted public review form',
'Customer management',
'Business profile management',
'Transaction tracking',
'Set-it-and-forget-it review request automation',
'Local, online, or Hybrid business setup',
'Automatic review requests from payments and orders',
'Manual review request queue',
'Hosted public product-review form',
'Review monitoring dashboard and queue',
'Embeddable social proof review widget',
'Customer, business, and transaction management',
'Stripe, Square, PayPal, Shopify, and WooCommerce webhook intake',
'Review request status tracking',
'Email delivery logs',
'Basic reporting',
'Standard support',
'Basic usage reporting',
],
includedFeatureKeys: [
'reviewflow_dashboard',
'business_type_setup',
'set_and_forget_automation',
'manual_review_requests',
'hosted_review_form',
'customer_management',
'business_management',
'transaction_tracking',
'team_member_invitations',
'payment_webhooks',
'review_status_tracking',
'message_preview',
'email_delivery_logs',
'basic_reporting',
'standard_support',
'social_proof_widgets',
],
},
{
@ -47,7 +50,7 @@ const subscriptionPlans = [
priceMonthly: 99,
currency: 'USD',
trialDays: TRIAL_DAYS,
tagline: 'For growing businesses that want automation, AI assistance, and reputation marketing tools.',
tagline: 'For teams that want AI replies, referrals, NPS, broadcasts, rebooking campaigns, and competitor insight tools.',
limits: {
monthlyReviewRequests: 2500,
businesses: 10,
@ -55,44 +58,44 @@ const subscriptionPlans = [
paymentConnectors: 5,
},
features: [
'Everything in Starter',
'Advanced automation rules',
'Everything in Grow',
'AI review reply assistant',
'Social proof widgets',
'Review monitoring workspace',
'Referral campaigns',
'Repeat booking reminders',
'NPS surveys',
'Competitor/reputation insights',
'Broadcast campaigns',
'Advanced reporting',
'Branding customization',
'Priority support',
'Referral campaign queueing',
'NPS survey campaign queueing',
'Marketing broadcasts and repeat-business campaigns',
'Competitor insight workspace',
'2,500 review requests per month',
'Up to 10 business profiles',
'Up to 10 team members',
'Subscription usage dashboard and upgrade controls',
],
includedFeatureKeys: [
'reviewflow_dashboard',
'business_type_setup',
'set_and_forget_automation',
'manual_review_requests',
'hosted_review_form',
'customer_management',
'business_management',
'transaction_tracking',
'team_member_invitations',
'payment_webhooks',
'review_status_tracking',
'message_preview',
'email_delivery_logs',
'basic_reporting',
'standard_support',
'advanced_automation',
'ai_review_replies',
'social_proof_widgets',
'review_monitoring',
'higher_review_request_limit',
'higher_business_limit',
'higher_team_member_limit',
'subscription_usage_dashboard',
'separate_admin_view',
'ai_review_replies',
'referral_campaigns',
'repeat_booking_reminders',
'nps_surveys',
'marketing_broadcasts',
'rebooking_campaigns',
'competitor_insights',
'broadcast_campaigns',
'advanced_reporting',
'branding_customization',
'priority_support',
],
},
];

View File

@ -109,7 +109,7 @@ module.exports = class UsersService {
try {
let users = await UsersDBApi.findBy(
{id},
{transaction},
{transaction, currentUser},
);
if (!users) {

View File

@ -13,6 +13,8 @@ import CardBox from '../CardBox';
import FormField from '../FormField';
import { getBusinessProfileUsageLabel } from '../../helpers/businessPlanLabels';
type BusinessType = 'local' | 'online' | 'hybrid';
export interface ProviderConnector {
key: 'stripe' | 'square' | 'paypal' | 'shopify' | 'woocommerce' | string;
label: string;
@ -30,6 +32,7 @@ export interface ProviderConnector {
export interface ConnectorBusiness {
id: string;
name?: string;
business_type?: BusinessType;
google_review_link?: string;
yelp_review_link?: string;
facebook_review_link?: string;
@ -45,6 +48,7 @@ export interface ConnectorBusiness {
export interface ConnectorFormValues {
provider: string;
businessType: BusinessType;
businessName: string;
reviewDestination: string;
reviewLink: string;
@ -57,6 +61,7 @@ interface PaymentProviderConnectorsProps {
eyebrow?: string;
title?: string;
description?: string;
initialBusinessType?: BusinessType;
onConnected?: (
business: ConnectorBusiness,
connectorForm: ConnectorFormValues,
@ -82,6 +87,7 @@ type ConnectorSubscriptionStatus = {
const connectorDefaults: ConnectorFormValues = {
provider: 'stripe',
businessType: 'hybrid',
businessName: 'Review Flow Studio',
reviewDestination: 'google',
reviewLink: 'https://g.page/r/example/review',
@ -95,6 +101,7 @@ const providerOptions = [
label: 'Stripe',
categoryLabel: 'Payment trigger',
defaultReviewDestination: 'google',
businessTypes: ['local', 'online', 'hybrid'],
description: 'Connect card and checkout payments from Stripe.',
},
{
@ -102,6 +109,7 @@ const providerOptions = [
label: 'PayPal',
categoryLabel: 'Payment trigger',
defaultReviewDestination: 'google',
businessTypes: ['local', 'online', 'hybrid'],
description: 'Connect completed PayPal captures and sales.',
},
{
@ -109,6 +117,7 @@ const providerOptions = [
label: 'Square',
categoryLabel: 'Payment trigger',
defaultReviewDestination: 'google',
businessTypes: ['local', 'hybrid'],
description: 'Connect Square payment notifications.',
},
{
@ -116,6 +125,7 @@ const providerOptions = [
label: 'Shopify',
categoryLabel: 'Ecommerce order trigger + hosted reviews',
defaultReviewDestination: 'shopify_hosted',
businessTypes: ['online', 'hybrid'],
description:
'Connect paid Shopify orders; customers review products on a hosted Review Flow form.',
},
@ -124,6 +134,7 @@ const providerOptions = [
label: 'WooCommerce',
categoryLabel: 'Ecommerce order trigger',
defaultReviewDestination: 'trustpilot',
businessTypes: ['online', 'hybrid'],
description: 'Connect WooCommerce orders from your WordPress store.',
},
];
@ -133,6 +144,7 @@ const reviewDestinationOptions = [
key: 'google',
label: 'Google',
group: 'Local review destinations',
scope: 'local',
mode: 'external_link',
description: 'For local businesses collecting Google profile reviews.',
},
@ -140,6 +152,7 @@ const reviewDestinationOptions = [
key: 'facebook',
label: 'Facebook',
group: 'Local review destinations',
scope: 'local',
mode: 'external_link',
description: 'For local Facebook recommendations and reviews.',
},
@ -147,6 +160,7 @@ const reviewDestinationOptions = [
key: 'yelp',
label: 'Yelp',
group: 'Local review destinations',
scope: 'local',
mode: 'external_link',
description: 'For local-service Yelp review requests.',
},
@ -154,6 +168,7 @@ const reviewDestinationOptions = [
key: 'angi',
label: 'Angi',
group: 'Local review destinations',
scope: 'local',
mode: 'external_link',
description: 'For home-service Angi profile review requests.',
},
@ -161,6 +176,7 @@ const reviewDestinationOptions = [
key: 'opentable',
label: 'OpenTable',
group: 'Local review destinations',
scope: 'local',
mode: 'external_link',
description: 'For restaurant guests leaving OpenTable reviews.',
},
@ -168,6 +184,7 @@ const reviewDestinationOptions = [
key: 'shopify_hosted',
label: 'Shopify hosted product review',
group: 'Ecommerce review destinations',
scope: 'online',
mode: 'hosted_form',
description:
'Review Flow hosts the product review form after a Shopify paid order.',
@ -176,6 +193,7 @@ const reviewDestinationOptions = [
key: 'trustpilot',
label: 'Trustpilot',
group: 'Ecommerce review destinations',
scope: 'online',
mode: 'external_link',
description: 'For ecommerce brand/store review invitations.',
},
@ -183,6 +201,7 @@ const reviewDestinationOptions = [
key: 'custom',
label: 'Custom review page',
group: 'Custom destination',
scope: 'hybrid',
mode: 'external_link',
description: 'Use any review page you control.',
},
@ -203,6 +222,85 @@ const reviewDestinationGroups = [
},
];
const businessTypeOptions: Array<{
key: BusinessType;
label: string;
help: string;
}> = [
{
key: 'local',
label: 'Local / service',
help: 'Keeps payment triggers focused on local review destinations.',
},
{
key: 'online',
label: 'Online / ecommerce',
help: 'Keeps order triggers focused on ecommerce review destinations.',
},
{
key: 'hybrid',
label: 'Hybrid',
help: 'Shows both local and online triggers for mixed businesses.',
},
];
function normalizeBusinessType(value?: string): BusinessType {
if (value === 'local' || value === 'online' || value === 'hybrid') {
return value;
}
return 'hybrid';
}
function destinationAllowedForBusinessType(
businessType: BusinessType,
destination: (typeof reviewDestinationOptions)[number],
) {
if (businessType === 'hybrid' || destination.scope === 'hybrid') {
return true;
}
return destination.scope === businessType;
}
function getReviewDestinationsForBusinessType(businessType: BusinessType) {
return reviewDestinationOptions.filter((destination) =>
destinationAllowedForBusinessType(businessType, destination),
);
}
function getDefaultReviewDestinationForBusinessType(businessType: BusinessType) {
if (businessType === 'online') return 'shopify_hosted';
return 'google';
}
function getAllowedReviewDestination(
businessType: BusinessType,
reviewDestination?: string,
) {
const allowedDestinations = getReviewDestinationsForBusinessType(businessType);
const isAllowed = allowedDestinations.some(
(destination) => destination.key === reviewDestination,
);
return isAllowed
? reviewDestination || allowedDestinations[0].key
: getDefaultReviewDestinationForBusinessType(businessType);
}
function providerAllowedForBusinessType(
businessType: BusinessType,
provider: (typeof providerOptions)[number],
) {
return provider.businessTypes.includes(businessType);
}
function getProvidersForBusinessType(businessType: BusinessType) {
return providerOptions.filter((provider) =>
providerAllowedForBusinessType(businessType, provider),
);
}
const providerInstructions: Record<string, string[]> = {
stripe: [
'Stripe Dashboard → Developers → Webhooks → Add endpoint.',
@ -547,6 +645,7 @@ export default function PaymentProviderConnectors({
eyebrow = 'Order triggers and review destinations',
title = 'Connect payment/ecommerce triggers without mixing local review channels',
description = 'Payment and ecommerce providers trigger review requests. Review destinations decide where customers leave feedback: local profiles, ecommerce review links, or the hosted Shopify product-review form.',
initialBusinessType = 'hybrid',
onConnected,
}: PaymentProviderConnectorsProps) {
const [connectorForm, setConnectorForm] =
@ -561,10 +660,13 @@ export default function PaymentProviderConnectors({
const [subscriptionStatus, setSubscriptionStatus] =
useState<ConnectorSubscriptionStatus | null>(null);
const currentBusinessType = normalizeBusinessType(connectorForm.businessType);
const filteredProviderOptions = getProvidersForBusinessType(currentBusinessType);
const filteredReviewDestinationOptions = getReviewDestinationsForBusinessType(currentBusinessType);
const selectedProvider =
providerOptions.find(
filteredProviderOptions.find(
(provider) => provider.key === connectorForm.provider,
) || providerOptions[0];
) || filteredProviderOptions[0];
const selectedSetup =
providerSetupDetails[selectedProvider.key] || providerSetupDetails.stripe;
const selectedApiBackup =
@ -576,9 +678,9 @@ export default function PaymentProviderConnectors({
? 'shopify_hosted'
: connectorForm.reviewDestination;
const selectedReviewDestination =
reviewDestinationOptions.find(
filteredReviewDestinationOptions.find(
(destination) => destination.key === effectiveReviewDestination,
) || reviewDestinationOptions[0];
) || filteredReviewDestinationOptions[0];
const isHostedReviewDestination =
selectedReviewDestination.mode === 'hosted_form';
@ -601,9 +703,9 @@ export default function PaymentProviderConnectors({
return {
connectedCount,
totalCount: providers.length || providerOptions.length,
totalCount: providers.length || filteredProviderOptions.length,
};
}, [connectors]);
}, [connectors, filteredProviderOptions.length]);
const selectedWebhookTargets = useMemo(
() =>
@ -627,22 +729,46 @@ export default function PaymentProviderConnectors({
key: keyof ConnectorFormValues,
value: string,
) => {
setConnectorForm((current) => ({ ...current, [key]: value }));
setConnectorForm((current) => {
if (key === 'businessType') {
const businessType = normalizeBusinessType(value);
const providers = getProvidersForBusinessType(businessType);
const provider = providers.find(
(providerOption) => providerOption.key === current.provider,
) || providers[0];
return {
...current,
businessType,
provider: provider.key,
reviewDestination: getAllowedReviewDestination(
businessType,
provider.defaultReviewDestination === 'shopify_hosted'
? 'shopify_hosted'
: current.reviewDestination,
),
};
}
return { ...current, [key]: value };
});
};
const updateSelectedProvider = (providerKey: string) => {
const provider =
providerOptions.find(
filteredProviderOptions.find(
(providerOption) => providerOption.key === providerKey,
) || providerOptions[0];
) || filteredProviderOptions[0];
setConnectorForm((current) => ({
...current,
provider: provider.key,
reviewDestination:
reviewDestination: getAllowedReviewDestination(
currentBusinessType,
provider.defaultReviewDestination === 'shopify_hosted'
? 'shopify_hosted'
: current.reviewDestination,
),
}));
};
@ -690,6 +816,10 @@ export default function PaymentProviderConnectors({
loadSubscriptionStatus();
}, []);
useEffect(() => {
updateConnectorForm('businessType', normalizeBusinessType(initialBusinessType));
}, [initialBusinessType]);
const handleConnectorSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsConnectorSubmitting(true);
@ -927,8 +1057,7 @@ export default function PaymentProviderConnectors({
1. Select provider
</p>
<p className='mt-1 text-slate-500 dark:text-slate-400'>
Pick Stripe, PayPal, Square, Shopify, or WooCommerce from the
dropdown.
Pick the relevant provider from the filtered dropdown; Local, Online, and Hybrid setups show different choices.
</p>
</div>
<div className='rounded-xl bg-white p-3 text-sm ring-1 ring-indigo-100 dark:bg-dark-900 dark:ring-indigo-900'>
@ -957,14 +1086,24 @@ export default function PaymentProviderConnectors({
className='mb-6 rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-dark-700 dark:bg-dark-800'
>
<FormField
label='Provider and business'
help='Choose the provider, then use the same business name to reuse its review settings.'
label='Business type, provider, and business'
help={businessTypeOptions.find((option) => option.key === currentBusinessType)?.help}
>
<select
value={connectorForm.provider}
value={connectorForm.businessType}
onChange={(event) => updateConnectorForm('businessType', event.target.value)}
>
{businessTypeOptions.map((option) => (
<option key={`${option.key}-business-type`} value={option.key}>
{option.label}
</option>
))}
</select>
<select
value={selectedProvider.key}
onChange={(event) => updateSelectedProvider(event.target.value)}
>
{providerOptions.map((provider) => (
{filteredProviderOptions.map((provider) => (
<option key={`${provider.key}-option`} value={provider.key}>
{provider.label}
</option>
@ -999,7 +1138,7 @@ export default function PaymentProviderConnectors({
updateConnectorForm('reviewDestination', event.target.value)
}
>
{reviewDestinationOptions.map((destination) => (
{filteredReviewDestinationOptions.map((destination) => (
<option
key={`${destination.key}-destination`}
value={destination.key}

View File

@ -27,10 +27,18 @@ export const SelectField = ({ options, field, form, itemRef, showField, disabled
async function callApi(inputValue: string, loadedOptions: any[]) {
const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}`;
const { data } = await axios(path);
return {
options: data.map(mapResponseToValuesAndLabels),
hasMore: data.length === PAGE_SIZE,
try {
const { data } = await axios(path);
return {
options: data.map(mapResponseToValuesAndLabels),
hasMore: data.length === PAGE_SIZE,
}
} catch (error) {
console.error(`Failed to load options for ${itemRef}:`, error);
return {
options: [],
hasMore: false,
}
}
}
return (

View File

@ -42,10 +42,18 @@ export const SelectFieldMany = ({ options, field, form, itemRef, showField }) =>
async function callApi(inputValue: string, loadedOptions: any[]) {
const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}`;
const { data } = await axios(path);
return {
options: data.map(mapResponseToValuesAndLabels),
hasMore: data.length === PAGE_SIZE,
try {
const { data } = await axios(path);
return {
options: data.map(mapResponseToValuesAndLabels),
hasMore: data.length === PAGE_SIZE,
}
} catch (error) {
console.error(`Failed to load options for ${itemRef}:`, error);
return {
options: [],
hasMore: false,
}
}
}
return (

View File

@ -43,6 +43,11 @@ export const customerMenuAside: MenuAsideItem[] = [
icon: icon.mdiStarOutline,
label: 'Review Flow',
},
{
href: '/growth-tools',
icon: icon.mdiStarCircleOutline,
label: 'Growth Tools',
},
{
href: '/businesses/businesses-list',
label: 'Businesses',
@ -73,6 +78,12 @@ export const customerMenuAside: MenuAsideItem[] = [
icon: emailCheckIcon,
permissions: 'READ_EMAIL_DELIVERY_LOGS',
},
{
href: '/users/users-list',
label: 'Team members',
icon: icon.mdiAccountGroup,
permissions: 'READ_USERS',
},
{
href: '/subscription',
icon: icon.mdiCreditCardOutline,

View File

@ -69,6 +69,8 @@ const EditBusinesses = () => {
'name': '',
'business_type': 'hybrid',
@ -586,7 +588,20 @@ const EditBusinesses = () => {
<FormField
<FormField
label="Business type"
labelFor="business_type"
help="Choose Local, Online, or Hybrid so Review Flow hides irrelevant setup options."
>
<Field name="business_type" id="business_type" component="select">
<option value="local">Local / service business</option>
<option value="online">Online / ecommerce business</option>
<option value="hybrid">Hybrid business</option>
</Field>
</FormField>
<FormField
label="Google review link"
>
<Field

View File

@ -69,6 +69,8 @@ const EditBusinessesPage = () => {
'name': '',
'business_type': 'hybrid',
@ -583,7 +585,20 @@ const EditBusinessesPage = () => {
<FormField
<FormField
label="Business type"
labelFor="business_type"
help="Choose Local, Online, or Hybrid so Review Flow hides irrelevant setup options."
>
<Field name="business_type" id="business_type" component="select">
<option value="local">Local / service business</option>
<option value="online">Online / ecommerce business</option>
<option value="hybrid">Hybrid business</option>
</Field>
</FormField>
<FormField
label="Google review link"
>
<Field

View File

@ -106,7 +106,7 @@ const BusinessesTablesPage = () => {
<p className='mt-2 text-sm leading-6'>
{isAdminPortal
? 'Manage customer business profiles used for review links, email templates, delay timing, and payment/webhook settings. Internal admin management is not limited by customer subscription plans.'
: 'Stores the business profile used for review links, email templates, delay timing, and payment/webhook settings. Starter accounts manage one business profile; Pro accounts can manage up to ten.'}
: 'Stores the business profile used for review links, email templates, delay timing, and payment/webhook settings. Grow accounts manage one business profile; Pro accounts can manage up to ten.'}
</p>
</CardBox>
<SubscriptionLimitGate

View File

@ -48,6 +48,8 @@ const initialValues = {
name: '',
business_type: 'hybrid',
@ -359,8 +361,21 @@ const BusinessesNew = () => {
<FormField
label="Business type"
labelFor="business_type"
help="Choose Local, Online, or Hybrid so Review Flow hides irrelevant setup options."
>
<Field name="business_type" id="business_type" component="select">
<option value="local">Local / service business</option>
<option value="online">Online / ecommerce business</option>
<option value="hybrid">Hybrid business</option>
</Field>
</FormField>
<FormField
label="Google review link"
>
<Field

View File

@ -106,7 +106,7 @@ const BusinessesTablesPage = () => {
<p className='mt-2 text-sm leading-6'>
{isAdminPortal
? 'Manage customer business profiles used for review links, email templates, delay timing, and payment/webhook settings. Internal admin management is not limited by customer subscription plans.'
: 'Stores the business profile used for review links, email templates, delay timing, and payment/webhook settings. Starter accounts manage one business profile; Pro accounts can manage up to ten.'}
: 'Stores the business profile used for review links, email templates, delay timing, and payment/webhook settings. Grow accounts manage one business profile; Pro accounts can manage up to ten.'}
</p>
</CardBox>
<SubscriptionLimitGate

View File

@ -0,0 +1,755 @@
import {
mdiCheckCircleOutline,
mdiCreditCardOutline,
mdiOpenInNew,
mdiRefresh,
mdiSend,
mdiStarCircleOutline,
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import React, { FormEvent, ReactElement, useEffect, useMemo, useState } from 'react';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import FormField from '../components/FormField';
import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import LayoutAuthenticated from '../layouts/Authenticated';
import { getPageTitle } from '../config';
import { aiResponse } from '../stores/openAiSlice';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
type BusinessType = 'local' | 'online' | 'hybrid';
type ReviewBusiness = {
id: string;
name?: string;
business_type?: BusinessType;
review_destination?: string;
delay_days?: number;
automation_mode?: string;
followup_enabled?: boolean;
followup_delay_days?: number;
max_followups?: number;
ai_reply_enabled?: boolean;
referral_enabled?: boolean;
referral_offer?: string;
nps_enabled?: boolean;
nps_question?: string;
social_widget_enabled?: boolean;
broadcast_enabled?: boolean;
rebooking_enabled?: boolean;
competitor_insights_enabled?: boolean;
competitor_urls?: string;
review_widget_theme?: string;
};
type SummaryResponse = {
stats: {
pending: number;
sent: number;
clicked: number;
reviewed: number;
customers: number;
transactions: number;
paymentEvents: number;
};
businesses?: ReviewBusiness[];
primaryBusiness?: ReviewBusiness | null;
};
type SubscriptionStatusResponse = {
subscription: {
planId: string;
planName: string;
effectiveStatus: string;
isActive: boolean;
};
};
type WidgetResponse = {
embedCode?: string;
business?: { name?: string };
reviews?: Array<{
id: string;
rating?: number;
title?: string;
content?: string;
reviewer?: string;
source?: string;
}>;
};
type CompetitorInsightsResponse = {
competitors: string[];
metrics: {
reviewed: number;
pending: number;
sent: number;
customers: number;
};
recommendations: string[];
};
const businessTypeOptions: Array<{ key: BusinessType; label: string; help: string }> = [
{
key: 'local',
label: 'Local / service business',
help: 'Use this for businesses that collect local profile reviews such as Google, Facebook, Yelp, Angi, or OpenTable.',
},
{
key: 'online',
label: 'Online / ecommerce business',
help: 'Use this for stores and online brands that collect product or ecommerce reviews.',
},
{
key: 'hybrid',
label: 'Hybrid business',
help: 'Use this when the same business needs both local and online review workflows.',
},
];
const reviewDestinationOptions = [
{ key: 'google', label: 'Google', scope: 'local' },
{ key: 'facebook', label: 'Facebook', scope: 'local' },
{ key: 'yelp', label: 'Yelp', scope: 'local' },
{ key: 'angi', label: 'Angi', scope: 'local' },
{ key: 'opentable', label: 'OpenTable', scope: 'local' },
{ key: 'shopify_hosted', label: 'Shopify hosted product review', scope: 'online' },
{ key: 'trustpilot', label: 'Trustpilot', scope: 'online' },
{ key: 'custom', label: 'Custom review page', scope: 'hybrid' },
];
const defaultSettings = {
businessName: 'Review Flow Business',
businessType: 'hybrid' as BusinessType,
reviewDestination: 'google',
delayDays: '7',
followupEnabled: true,
followupDelayDays: '3',
maxFollowups: '1',
aiReplyEnabled: false,
referralEnabled: false,
referralOffer: 'Give $25, get $25 when a referred customer completes their first purchase.',
npsEnabled: false,
npsQuestion: 'How likely are you to recommend us to a friend?',
socialWidgetEnabled: true,
broadcastEnabled: false,
rebookingEnabled: false,
competitorInsightsEnabled: false,
competitorUrls: '',
reviewWidgetTheme: 'light',
};
const defaultCampaign = {
campaignType: 'broadcast',
subject: 'Quick update from our team',
message: 'Thanks for being a customer. We appreciate your support and wanted to share a quick update.',
};
function normalizeBusinessType(value?: string): BusinessType {
if (value === 'local' || value === 'online' || value === 'hybrid') return value;
return 'hybrid';
}
function destinationAllowedForBusinessType(businessType: BusinessType, destination: typeof reviewDestinationOptions[number]) {
if (businessType === 'hybrid' || destination.scope === 'hybrid') return true;
return destination.scope === businessType;
}
function getDestinationsForBusinessType(businessType: BusinessType) {
return reviewDestinationOptions.filter((destination) =>
destinationAllowedForBusinessType(businessType, destination),
);
}
function getDefaultDestination(businessType: BusinessType) {
return businessType === 'online' ? 'shopify_hosted' : 'google';
}
function coerceDestination(businessType: BusinessType, destination?: string) {
const destinations = getDestinationsForBusinessType(businessType);
return destinations.some((option) => option.key === destination)
? destination || destinations[0].key
: getDefaultDestination(businessType);
}
function businessToSettings(business?: ReviewBusiness | null) {
if (!business) return defaultSettings;
const businessType = normalizeBusinessType(business.business_type);
return {
businessName: business.name || defaultSettings.businessName,
businessType,
reviewDestination: coerceDestination(businessType, business.review_destination),
delayDays: String(business.delay_days ?? 7),
followupEnabled: business.followup_enabled !== false,
followupDelayDays: String(business.followup_delay_days ?? 3),
maxFollowups: String(business.max_followups ?? 1),
aiReplyEnabled: Boolean(business.ai_reply_enabled),
referralEnabled: Boolean(business.referral_enabled),
referralOffer: business.referral_offer || defaultSettings.referralOffer,
npsEnabled: Boolean(business.nps_enabled),
npsQuestion: business.nps_question || defaultSettings.npsQuestion,
socialWidgetEnabled: business.social_widget_enabled !== false,
broadcastEnabled: Boolean(business.broadcast_enabled),
rebookingEnabled: Boolean(business.rebooking_enabled),
competitorInsightsEnabled: Boolean(business.competitor_insights_enabled),
competitorUrls: business.competitor_urls || '',
reviewWidgetTheme: business.review_widget_theme || 'light',
};
}
function extractAiResponseText(response: any) {
const output = response?.output || response?.data?.output || [];
for (const item of output) {
if (item?.type !== 'message') continue;
for (const content of item.content || []) {
if (content?.type === 'output_text' && content.text) {
return String(content.text);
}
}
}
return '';
}
function getCampaignLabel(campaignType: string) {
if (campaignType === 'referral') return 'Referral campaign';
if (campaignType === 'nps') return 'NPS survey';
if (campaignType === 'rebooking') return 'Repeat business / rebooking';
return 'Marketing broadcast';
}
export default function GrowthToolsPage() {
const dispatch = useAppDispatch();
const { isAskingResponse, errorMessage: aiErrorMessage } = useAppSelector((state) => state.openAi);
const [summary, setSummary] = useState<SummaryResponse | null>(null);
const [subscriptionStatus, setSubscriptionStatus] = useState<SubscriptionStatusResponse | null>(null);
const [selectedBusinessId, setSelectedBusinessId] = useState('');
const [settingsForm, setSettingsForm] = useState(defaultSettings);
const [campaignForm, setCampaignForm] = useState(defaultCampaign);
const [aiReviewText, setAiReviewText] = useState('Great service, fast communication, and the team made everything easy.');
const [aiTone, setAiTone] = useState('friendly, concise, and professional');
const [aiSuggestion, setAiSuggestion] = useState('');
const [widget, setWidget] = useState<WidgetResponse | null>(null);
const [competitorInsights, setCompetitorInsights] = useState<CompetitorInsightsResponse | null>(null);
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 || [];
const selectedBusiness = useMemo(
() => businesses.find((business) => business.id === selectedBusinessId) || summary?.primaryBusiness || null,
[businesses, selectedBusinessId, summary?.primaryBusiness],
);
const currentBusinessType = normalizeBusinessType(settingsForm.businessType);
const destinationOptions = getDestinationsForBusinessType(currentBusinessType);
const isGrowPlan = subscriptionStatus?.subscription.planId === 'starter';
const hasSelectedBusiness = Boolean(selectedBusinessId || selectedBusiness?.id);
const loadData = async () => {
setIsLoading(true);
setError('');
try {
const [summaryResponse, subscriptionResponse] = await Promise.all([
axios.get('/reviewflow/summary'),
axios.get('/subscription/me'),
]);
const loadedSummary = summaryResponse.data as SummaryResponse;
const primaryBusiness = loadedSummary.primaryBusiness || loadedSummary.businesses?.[0] || null;
setSummary(loadedSummary);
setSubscriptionStatus(subscriptionResponse.data);
if (primaryBusiness) {
setSelectedBusinessId(primaryBusiness.id);
setSettingsForm(businessToSettings(primaryBusiness));
}
} catch (requestError) {
console.error('Failed to load Growth Tools:', requestError);
setError('Could not load Growth Tools. Refresh the page or try again.');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const updateSettings = (key: keyof typeof defaultSettings, value: string | boolean) => {
setSettingsForm((current) => {
if (key === 'businessType') {
const businessType = normalizeBusinessType(String(value));
return {
...current,
businessType,
reviewDestination: coerceDestination(businessType, current.reviewDestination),
};
}
return { ...current, [key]: value };
});
};
const selectBusiness = (businessId: string) => {
const business = businesses.find((item) => item.id === businessId);
setSelectedBusinessId(businessId);
setSettingsForm(businessToSettings(business));
setWidget(null);
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('');
setError('');
try {
const response = await axios.post('/reviewflow/automation/run-due', { limit: 100 });
setMessage(
`Set-it-and-forget-it run complete: ${response.data.processed} processed, ${response.data.sent} handed off, ${response.data.failed} failed.`,
);
await loadData();
} catch (requestError) {
console.error('Failed to run due review automation:', requestError);
if (axios.isAxiosError(requestError) && requestError.response?.data) {
setError(String(requestError.response.data));
} else {
setError('Could not run due automation. Please try again.');
}
} finally {
setIsWorking(false);
}
};
const loadWidget = async () => {
if (!hasSelectedBusiness) return;
setIsWorking(true);
setMessage('');
setError('');
try {
const response = await axios.get(`/reviewflow/social-widget/${selectedBusinessId || selectedBusiness?.id}`);
setWidget(response.data);
setMessage('Social proof widget refreshed. Copy the embed code into a website page where you want reviews to appear.');
} catch (requestError) {
console.error('Failed to load social proof widget:', requestError);
if (axios.isAxiosError(requestError) && requestError.response?.data) {
setError(String(requestError.response.data));
} else {
setError('Could not load the social proof widget.');
}
} finally {
setIsWorking(false);
}
};
const launchCampaign = async () => {
setIsWorking(true);
setMessage('');
setError('');
try {
const response = await axios.post('/reviewflow/growth-tools/broadcast', {
businessId: selectedBusinessId,
...campaignForm,
});
setMessage(`${getCampaignLabel(campaignForm.campaignType)} queued: ${response.data.queued} customers, ${response.data.skipped} skipped.`);
} catch (requestError) {
console.error('Failed to queue Growth Tools campaign:', requestError);
if (axios.isAxiosError(requestError) && requestError.response?.data) {
setError(String(requestError.response.data));
} else {
setError('Could not queue this campaign. Please try again.');
}
} finally {
setIsWorking(false);
}
};
const generateAiReply = async () => {
setAiSuggestion('');
setError('');
if (isGrowPlan) {
setError('Grow does not include AI review replies. Upgrade to Pro to unlock it.');
return;
}
const payload = {
input: [
{
role: 'system',
content: 'You write short, warm, non-defensive review replies for a small business. Keep replies under 90 words and do not mention private customer data.',
},
{
role: 'user',
content: `Business: ${settingsForm.businessName}\nTone: ${aiTone}\nCustomer review: ${aiReviewText}\nWrite one public reply.`,
},
],
options: { poll_interval: 5, poll_timeout: 300 },
};
const resultAction = await dispatch(aiResponse(payload));
if (aiResponse.fulfilled.match(resultAction)) {
const text = extractAiResponseText(resultAction.payload);
setAiSuggestion(text || 'AI returned a response, but no text output was found.');
return;
}
console.error('AI reply assistant failed:', resultAction.payload || resultAction.error);
setError('AI reply assistant failed. Check the AI proxy configuration and try again.');
};
const runCompetitorInsights = async () => {
setIsWorking(true);
setCompetitorInsights(null);
setError('');
setMessage('');
try {
const response = await axios.post('/reviewflow/growth-tools/competitor-insights', {
businessId: selectedBusinessId,
competitorUrls: settingsForm.competitorUrls,
});
setCompetitorInsights(response.data);
setMessage('Competitor insight checklist updated.');
} catch (requestError) {
console.error('Failed to build competitor insights:', requestError);
if (axios.isAxiosError(requestError) && requestError.response?.data) {
setError(String(requestError.response.data));
} else {
setError('Could not build competitor insights. Please try again.');
}
} finally {
setIsWorking(false);
}
};
return (
<>
<Head>
<title>{getPageTitle('Growth Tools')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiStarCircleOutline} title='Growth Tools' main>
{''}
</SectionTitleLineWithButton>
<div className='mb-6 overflow-hidden rounded-3xl bg-gradient-to-br from-slate-950 via-indigo-950 to-emerald-950 p-6 text-white shadow-2xl'>
<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
</p>
<h2 className='max-w-3xl text-4xl font-black tracking-tight md:text-5xl'>
Keep review growth simple after business setup.
</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.
</p>
</div>
<div className='grid grid-cols-2 gap-3'>
{[
['Pending', summary?.stats.pending ?? 0],
['Sent', summary?.stats.sent ?? 0],
['Clicked', summary?.stats.clicked ?? 0],
['Reviewed', summary?.stats.reviewed ?? 0],
].map(([label, value]) => (
<div key={label} className='rounded-2xl bg-white/10 p-4 ring-1 ring-white/15 backdrop-blur'>
<div className='text-3xl font-black'>{value}</div>
<div className='text-sm text-slate-300'>{label}</div>
</div>
))}
</div>
</div>
</div>
{message && (
<div className='mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-emerald-900'>
<strong>Done.</strong> {message}
</div>
)}
{(error || aiErrorMessage) && (
<div className='mb-6 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-rose-900'>
<p>{error || aiErrorMessage}</p>
{(error || aiErrorMessage).includes('Upgrade to Pro') && (
<BaseButton href='/subscription' icon={mdiCreditCardOutline} label='Upgrade to Pro' color='danger' className='mt-3' />
)}
</div>
)}
<div className='mb-6 grid gap-6 xl:grid-cols-[0.9fr_1.1fr]'>
<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='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.
</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.'>
<select value={selectedBusinessId} onChange={(event) => selectBusiness(event.target.value)}>
{businesses.map((business) => (
<option key={business.id} value={business.id}>{business.name || 'Business'}</option>
))}
</select>
</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>
<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>
</CardBox>
<div className='grid gap-6'>
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
<p className='text-sm font-bold uppercase tracking-[0.25em] text-indigo-500'>Grow</p>
<h3 className='mt-1 text-2xl font-black text-slate-900 dark:text-white'>Social proof widget</h3>
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400'>
Grow includes an embeddable widget for hosted reviews. It displays verified reviews after customers submit them through Review Flow.
</p>
<BaseButton icon={mdiOpenInNew} label='Refresh widget code' color='info' className='mt-4' onClick={loadWidget} disabled={!hasSelectedBusiness || isWorking} />
{widget?.embedCode && (
<div className='mt-4 rounded-2xl bg-slate-950 p-4 text-sm text-emerald-100'>
<p className='mb-2 font-black text-white'>Embed code</p>
<code className='break-all'>{widget.embedCode}</code>
</div>
)}
{widget?.reviews && (
<div className='mt-4 grid gap-3'>
{widget.reviews.length === 0 ? (
<div className='rounded-2xl border border-dashed border-slate-200 p-5 text-center text-slate-500'>No hosted reviews yet.</div>
) : widget.reviews.map((review) => (
<div key={review.id} className='rounded-2xl bg-slate-50 p-4 dark:bg-dark-800'>
<p className='font-black text-amber-500'>{'★'.repeat(review.rating || 5)}</p>
<p className='mt-1 font-bold text-slate-900 dark:text-white'>{review.title || 'Customer review'}</p>
<p className='mt-1 text-sm text-slate-500'>{review.content}</p>
</div>
))}
</div>
)}
</CardBox>
<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'>
<div>
<p className='text-sm font-bold uppercase tracking-[0.25em] text-fuchsia-500'>Pro</p>
<h3 className='mt-1 text-2xl font-black text-slate-900 dark:text-white'>AI review reply assistant</h3>
</div>
{isGrowPlan && <span className='rounded-full bg-indigo-100 px-3 py-1 text-xs font-black text-indigo-700'>Pro</span>}
</div>
<FormField label='Review and tone' help='Uses the existing /api/ai/response proxy through Redux.'>
<textarea value={aiReviewText} onChange={(event) => setAiReviewText(event.target.value)} placeholder='Paste customer review text' />
<input value={aiTone} onChange={(event) => setAiTone(event.target.value)} placeholder='Tone' />
</FormField>
<BaseButton icon={mdiSend} label={isAskingResponse ? 'Generating...' : 'Generate reply'} color='info' onClick={generateAiReply} disabled={isAskingResponse || !aiReviewText.trim() || isGrowPlan} />
{aiSuggestion && (
<div className='mt-4 rounded-2xl border border-indigo-100 bg-indigo-50 p-4 text-sm leading-6 text-indigo-950'>
{aiSuggestion}
</div>
)}
</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'>
<div>
<p className='text-sm font-bold uppercase tracking-[0.25em] text-emerald-500'>Pro campaigns</p>
<h3 className='mt-1 text-2xl font-black text-slate-900 dark:text-white'>Referrals, NPS, broadcasts, and rebooking</h3>
</div>
{isGrowPlan && <BaseButton href='/subscription' icon={mdiCreditCardOutline} label='Upgrade' color='info' />}
</div>
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400'>
These campaign actions queue customer messages in Email Delivery so a provider handoff can process them. They do not hide failures; invalid or missing emails are skipped.
</p>
<FormField label='Campaign' help='Choose the type, then write the subject and message.'>
<select value={campaignForm.campaignType} onChange={(event) => setCampaignForm((current) => ({ ...current, campaignType: event.target.value }))}>
<option value='broadcast'>Marketing broadcast</option>
<option value='referral'>Referral campaign</option>
<option value='nps'>NPS survey</option>
<option value='rebooking'>Repeat business / rebooking</option>
</select>
<input value={campaignForm.subject} onChange={(event) => setCampaignForm((current) => ({ ...current, subject: event.target.value }))} placeholder='Subject' />
</FormField>
<FormField label='Message' help='This is stored in the delivery log details for provider handoff.'>
<textarea value={campaignForm.message} onChange={(event) => setCampaignForm((current) => ({ ...current, message: event.target.value }))} placeholder='Message' />
</FormField>
<BaseButton icon={mdiSend} label='Queue campaign' color='info' onClick={launchCampaign} disabled={isWorking} />
</CardBox>
<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'>
<div>
<p className='text-sm font-bold uppercase tracking-[0.25em] text-amber-500'>Pro insights</p>
<h3 className='mt-1 text-2xl font-black text-slate-900 dark:text-white'>Competitor insight checklist</h3>
</div>
{isGrowPlan && <span className='rounded-full bg-indigo-100 px-3 py-1 text-xs font-black text-indigo-700'>Pro</span>}
</div>
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400'>
Save focused competitors and generate an internal action checklist from your own review stats. This does not scrape competitor sites; it keeps the workspace lightweight and safe.
</p>
<BaseButton icon={mdiRefresh} label='Build insights' color='warning' className='mt-4' onClick={runCompetitorInsights} disabled={isWorking || !settingsForm.competitorUrls.trim()} />
{competitorInsights && (
<div className='mt-4 space-y-4'>
<div className='grid grid-cols-2 gap-3'>
{Object.entries(competitorInsights.metrics).map(([label, value]) => (
<div key={label} className='rounded-2xl bg-slate-50 p-4 dark:bg-dark-800'>
<p className='text-2xl font-black'>{value}</p>
<p className='text-sm capitalize text-slate-500'>{label}</p>
</div>
))}
</div>
<div className='rounded-2xl border border-slate-200 p-4 dark:border-dark-700'>
<p className='font-black text-slate-900 dark:text-white'>Tracked competitors</p>
<ul className='mt-2 list-disc space-y-1 pl-5 text-sm text-slate-500'>
{competitorInsights.competitors.map((competitor) => <li key={competitor}>{competitor}</li>)}
</ul>
</div>
<div className='rounded-2xl bg-amber-50 p-4 text-amber-950'>
<p className='font-black'>Recommended next actions</p>
<ul className='mt-2 list-disc space-y-2 pl-5 text-sm leading-6'>
{competitorInsights.recommendations.map((recommendation) => <li key={recommendation}>{recommendation}</li>)}
</ul>
</div>
</div>
)}
</CardBox>
</div>
</SectionMain>
</>
);
}
GrowthToolsPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated portal='customer'>{page}</LayoutAuthenticated>;
};

View File

@ -62,13 +62,13 @@ export default function Starter() {
<div>
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm font-bold text-emerald-700">
<span className="h-2 w-2 rounded-full bg-emerald-500" />
Review automation for modern local businesses
Automated review management for local, online, and hybrid businesses
</div>
<h1 className="max-w-4xl text-5xl font-black leading-[0.95] tracking-tight text-slate-950 md:text-7xl">
Ask at the perfect moment. Earn more five-star reviews.
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600">
Review Flow turns Stripe, Square, PayPal, Shopify, and WooCommerce webhooks into scheduled review requests with a clean queue, message preview, and admin controls already wired into your app.
Review Flow turns payments, orders, and manual customer moments into scheduled review requests, social proof, and growth campaigns while hiding irrelevant options after setup.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<BaseButton href="/reviewflow" icon={mdiStarCircleOutline} label="Open Review Flow" color="info" className="shadow-xl shadow-indigo-600/20" />
@ -141,9 +141,9 @@ export default function Starter() {
<div className="mx-auto max-w-7xl">
<div className="mx-auto max-w-3xl text-center">
<p className="text-sm font-black uppercase tracking-[0.3em] text-emerald-600">Simple pricing</p>
<h2 className="mt-4 text-4xl font-black tracking-tight text-slate-950 md:text-5xl">Choose Starter or Pro.</h2>
<h2 className="mt-4 text-4xl font-black tracking-tight text-slate-950 md:text-5xl">Choose Grow or Pro.</h2>
<p className="mt-5 text-lg leading-8 text-slate-600">
Every plan starts with a {trialDays}-day free trial. Starter covers the core review workflow. Pro adds the advanced automation and reputation marketing tools growing teams need.
Every plan starts with a {trialDays}-day free trial. Grow covers set-it-and-forget-it review automation. Pro adds AI replies, referrals, NPS, broadcasts, rebooking, competitor insights, and higher limits.
</p>
</div>

View File

@ -38,8 +38,8 @@ export default function Login() {
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
(state) => state.auth,
);
const [initialValues, setInitialValues] = React.useState({ email:'admin@flatlogic.com',
password: 'fc6e39e3',
const [initialValues, setInitialValues] = React.useState({ email:'pro@reviewflow.demo',
password: 'ProDemo2026!',
remember: true })
const title = 'Review Flow'
@ -62,15 +62,15 @@ export default function Login() {
'Transportation teams can manage businesses, customers, transactions, payment events, and review requests without jumping tools.',
},
{
title: 'Clear Starter and Pro tiers',
title: 'Clear Grow and Pro tiers',
description:
'Starter is $49/month for the core review workflow. Pro is $99/month for higher limits, automation, AI, and reputation marketing tools.',
'Grow is $49/month for automated review management. Pro is $99/month for AI replies, referrals, NPS, broadcasts, rebooking, competitor insights, and higher limits.',
},
];
const pricingPlans = [
{
name: 'Starter',
name: 'Grow',
price: '$49',
description:
'Best for small teams that need the core Review Flow workflow and simple monthly limits.',
@ -84,7 +84,7 @@ export default function Login() {
],
},
{
title: 'Starter limits',
title: 'Grow limits',
features: [
'250 review requests per month.',
'1 business profile.',
@ -98,23 +98,23 @@ export default function Login() {
name: 'Pro',
price: '$99',
description:
'Best for growing teams that want higher limits, automation, AI assistance, and reputation marketing tools.',
'Best for growing teams that need higher Review Flow limits on the same working workflow.',
sections: [
{
title: 'Everything in Starter',
title: 'Everything in Grow',
features: [
'2,500 review requests per month.',
'10 business profiles.',
'10 team members.',
'Priority support and advanced reporting.',
'Subscription usage dashboard and upgrade controls.',
],
},
{
title: 'Growth tools',
title: 'Working Pro upgrades',
features: [
'Advanced automation rules.',
'AI review reply assistant.',
'Social proof widgets, referral campaigns, repeat booking reminders, NPS surveys, and broadcasts.',
'Higher monthly review-request limit.',
'More business profiles for multiple locations or brands.',
'Larger team-member limit with the same invitation workflow.',
],
},
],
@ -219,16 +219,22 @@ export default function Login() {
<p className='mb-2'>Use{' '}
<code className={`cursor-pointer ${textColor} `}
data-password="fc6e39e3"
onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '}
<code className={`${textColor}`}>fc6e39e3</code>{' / '}
to login as Internal Admin</p>
<p>Use <code
data-password="ProDemo2026!"
onClick={(e) => setLogin(e.target)}>pro@reviewflow.demo</code>{' / '}
<code className={`${textColor}`}>ProDemo2026!</code>{' / '}
to login as Pro Demo Customer</p>
<p className='mb-2'>Use <code
className={`cursor-pointer ${textColor} `}
data-password="874c3b951385"
onClick={(e) => setLogin(e.target)}>john@doe.com</code>{' / '}
<code className={`${textColor}`}>874c3b951385</code>{' / '}
to login as Customer Owner</p>
to login as Grow Customer Owner</p>
<p>Use{' '}
<code className={`cursor-pointer ${textColor} `}
data-password="fc6e39e3"
onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '}
<code className={`${textColor}`}>fc6e39e3</code>{' / '}
to login as Internal Admin</p>
</div>
<div>
<BaseIcon

View File

@ -32,12 +32,22 @@ import { getBusinessProfileLimitLabel } from '../helpers/businessPlanLabels';
interface ReviewBusiness {
id?: string;
name?: string;
business_type?: BusinessType;
google_review_link?: string;
yelp_review_link?: string;
facebook_review_link?: string;
trustpilot_review_link?: string;
angi_review_link?: string;
opentable_review_link?: string;
custom_review_link?: string;
review_destination?: string;
delay_days?: number;
}
interface ReviewCustomer {
name?: string;
email?: string;
phone?: string;
}
interface ReviewTransaction {
@ -72,12 +82,33 @@ interface ReviewRequest {
review_link?: string;
review_platform?: string;
review_rating?: number;
failure_reason?: string;
createdAt?: string;
business?: ReviewBusiness;
customer?: ReviewCustomer;
transaction?: ReviewTransaction;
}
interface ReviewDeliveryAttempt {
channel?: string;
status?: string;
to?: string;
reason?: string;
}
interface ReviewDeliveryGroup {
requestId?: string;
deliveries?: ReviewDeliveryAttempt[];
}
interface ReviewDeliveryResponse {
processed?: number;
sent?: number;
failed?: number;
errors?: string[];
deliveries?: ReviewDeliveryGroup[];
}
interface SummaryResponse {
stats: {
pending: number;
@ -91,6 +122,8 @@ interface SummaryResponse {
requests: ReviewRequest[];
recentTransactions?: ReviewTransaction[];
recentEvents?: ReviewEvent[];
businesses?: ReviewBusiness[];
primaryBusiness?: ReviewBusiness | null;
}
interface SubscriptionStatusResponse {
@ -116,27 +149,113 @@ interface SubscriptionStatusResponse {
};
}
type BusinessType = 'local' | 'online' | 'hybrid';
const defaultForm = {
businessName: 'Review Flow Studio',
businessType: 'hybrid' as BusinessType,
reviewDestination: 'google',
reviewLink: 'https://g.page/r/example/review',
delayDays: '7',
delayDays: '0',
customerName: '',
customerEmail: '',
phone: '',
};
const reviewDestinationOptions = [
{ key: 'google', label: 'Google', requiresLink: true },
{ key: 'facebook', label: 'Facebook', requiresLink: true },
{ key: 'yelp', label: 'Yelp', requiresLink: true },
{ key: 'angi', label: 'Angi', requiresLink: true },
{ key: 'opentable', label: 'OpenTable', requiresLink: true },
{ key: 'trustpilot', label: 'Trustpilot', requiresLink: true },
{ key: 'shopify_hosted', label: 'Shopify hosted product review', requiresLink: false },
{ key: 'custom', label: 'Custom review page', requiresLink: true },
{ key: 'google', label: 'Google', requiresLink: true, scope: 'local' },
{ key: 'facebook', label: 'Facebook', requiresLink: true, scope: 'local' },
{ key: 'yelp', label: 'Yelp', requiresLink: true, scope: 'local' },
{ key: 'angi', label: 'Angi', requiresLink: true, scope: 'local' },
{ key: 'opentable', label: 'OpenTable', requiresLink: true, scope: 'local' },
{ key: 'trustpilot', label: 'Trustpilot', requiresLink: true, scope: 'online' },
{ key: 'shopify_hosted', label: 'Shopify hosted product review', requiresLink: false, scope: 'online' },
{ key: 'custom', label: 'Custom review page', requiresLink: true, scope: 'hybrid' },
];
const businessTypeOptions: Array<{
key: BusinessType;
label: string;
help: string;
}> = [
{
key: 'local',
label: 'Local / service business',
help: 'Shows local review destinations like Google, Facebook, Yelp, Angi, and OpenTable.',
},
{
key: 'online',
label: 'Online / ecommerce business',
help: 'Shows ecommerce destinations like Shopify hosted reviews and Trustpilot.',
},
{
key: 'hybrid',
label: 'Hybrid business',
help: 'Shows both local and online options for businesses that need both.',
},
];
function normalizeBusinessType(value?: string): BusinessType {
if (value === 'local' || value === 'online' || value === 'hybrid') {
return value;
}
return 'hybrid';
}
function destinationAllowedForBusinessType(
businessType: BusinessType,
destination: (typeof reviewDestinationOptions)[number],
) {
if (businessType === 'hybrid' || destination.scope === 'hybrid') {
return true;
}
return destination.scope === businessType;
}
function getReviewDestinationsForBusinessType(businessType: BusinessType) {
return reviewDestinationOptions.filter((destination) =>
destinationAllowedForBusinessType(businessType, destination),
);
}
function getDefaultReviewDestinationForBusinessType(businessType: BusinessType) {
if (businessType === 'online') return 'shopify_hosted';
return 'google';
}
function getAllowedReviewDestination(
businessType: BusinessType,
reviewDestination?: string,
) {
const allowedDestinations = getReviewDestinationsForBusinessType(businessType);
const isAllowed = allowedDestinations.some(
(destination) => destination.key === reviewDestination,
);
return isAllowed
? reviewDestination || allowedDestinations[0].key
: getDefaultReviewDestinationForBusinessType(businessType);
}
function getReviewLinkForDestination(
business: ReviewBusiness,
destination?: string,
) {
const destinationKey = destination || business.review_destination || 'google';
if (destinationKey === 'google') return business.google_review_link || '';
if (destinationKey === 'facebook') return business.facebook_review_link || '';
if (destinationKey === 'yelp') return business.yelp_review_link || '';
if (destinationKey === 'angi') return business.angi_review_link || '';
if (destinationKey === 'opentable') return business.opentable_review_link || '';
if (destinationKey === 'trustpilot') return business.trustpilot_review_link || '';
if (destinationKey === 'custom') return business.custom_review_link || '';
return '';
}
const statusStyles: Record<string, string> = {
pending: 'bg-amber-100 text-amber-800 ring-amber-200',
sent: 'bg-sky-100 text-sky-800 ring-sky-200',
@ -146,9 +265,9 @@ const statusStyles: Record<string, string> = {
};
const proFeaturePrompts = [
['Advanced automation', 'Create rules for timing, destinations, and follow-up behavior.'],
['AI reply assistant', 'Draft thoughtful review replies faster from one workspace.'],
['Reputation marketing', 'Unlock widgets, referral campaigns, NPS surveys, and broadcasts.'],
['Higher request volume', 'Queue up to 2,500 review requests per month.'],
['More business profiles', 'Manage up to 10 locations, brands, or service lines.'],
['Larger team access', 'Invite up to 10 team members with the same permission-controlled workflow.'],
];
function formatDate(value?: string | null) {
@ -186,11 +305,63 @@ function formatAmount(amount?: string | number, currency?: string) {
}).format(numericAmount);
}
type ReviewFlowDisclosureProps = {
eyebrow?: string;
title: string;
description?: string;
children: React.ReactNode;
defaultOpen?: boolean;
className?: string;
};
function ReviewFlowDisclosure({
eyebrow,
title,
description,
children,
defaultOpen = false,
className = '',
}: ReviewFlowDisclosureProps) {
const detailsProps = defaultOpen ? { open: true } : {};
return (
<details
{...detailsProps}
className={`group overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-xl dark:border-dark-700 dark:bg-dark-900 ${className}`}
>
<summary className='flex cursor-pointer list-none items-center justify-between gap-4 p-5 [&::-webkit-details-marker]:hidden'>
<div>
{eyebrow && (
<p className='text-xs font-black uppercase tracking-[0.25em] text-slate-400'>
{eyebrow}
</p>
)}
<h3 className='mt-1 text-xl font-black text-slate-900 dark:text-white'>
{title}
</h3>
{description && (
<p className='mt-1 text-sm leading-6 text-slate-500 dark:text-slate-400'>
{description}
</p>
)}
</div>
<span className='flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-100 text-lg font-black text-slate-600 transition group-open:rotate-180 dark:bg-dark-800 dark:text-slate-200'>
</span>
</summary>
<div className='border-t border-slate-100 p-5 dark:border-dark-700'>
{children}
</div>
</details>
);
}
export default function ReviewFlowWorkspace() {
const [form, setForm] = useState(defaultForm);
const [summary, setSummary] = useState<SummaryResponse | null>(null);
const [selected, setSelected] = useState<ReviewRequest | null>(null);
const [created, setCreated] = useState<ReviewRequest | null>(null);
const [deliveryNotice, setDeliveryNotice] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
@ -210,10 +381,12 @@ export default function ReviewFlowWorkspace() {
transactions: 0,
paymentEvents: 0,
};
const currentBusinessType = normalizeBusinessType(form.businessType);
const filteredReviewDestinationOptions = getReviewDestinationsForBusinessType(currentBusinessType);
const selectedReviewDestination =
reviewDestinationOptions.find(
filteredReviewDestinationOptions.find(
(destination) => destination.key === form.reviewDestination,
) || reviewDestinationOptions[0];
) || filteredReviewDestinationOptions[0];
const isHostedReviewDestination = !selectedReviewDestination.requiresLink;
const previewDate = useMemo(() => {
@ -230,6 +403,28 @@ export default function ReviewFlowWorkspace() {
try {
const response = await axios.get('/reviewflow/summary');
setSummary(response.data);
const primaryBusiness = response.data.primaryBusiness as ReviewBusiness | null;
if (primaryBusiness) {
setForm((current) => {
const businessType = normalizeBusinessType(primaryBusiness.business_type);
const reviewDestination = getAllowedReviewDestination(
businessType,
primaryBusiness.review_destination || current.reviewDestination,
);
return {
...current,
businessName:
current.businessName === defaultForm.businessName && primaryBusiness.name
? primaryBusiness.name
: current.businessName,
businessType,
reviewDestination,
reviewLink: getReviewLinkForDestination(primaryBusiness, reviewDestination) || current.reviewLink,
delayDays: current.delayDays,
};
});
}
if (!selected && response.data.requests?.length) {
setSelected(response.data.requests[0]);
}
@ -270,7 +465,21 @@ export default function ReviewFlowWorkspace() {
}, []);
const updateForm = (key: keyof typeof defaultForm, value: string) => {
setForm((current) => ({ ...current, [key]: value }));
setForm((current) => {
if (key === 'businessType') {
const businessType = normalizeBusinessType(value);
return {
...current,
businessType,
reviewDestination: getAllowedReviewDestination(
businessType,
current.reviewDestination,
),
};
}
return { ...current, [key]: value };
});
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
@ -278,6 +487,7 @@ export default function ReviewFlowWorkspace() {
setIsSubmitting(true);
setError('');
setCreated(null);
setDeliveryNotice('');
try {
const response = await axios.post('/reviewflow/request', {
@ -286,8 +496,37 @@ export default function ReviewFlowWorkspace() {
delayDays: Number(form.delayDays),
});
const newRequest = response.data.request;
const delivery = response.data.delivery as ReviewDeliveryResponse | null;
const deliveryAttempts = delivery?.deliveries?.flatMap(
(group) => group.deliveries || [],
) || [];
const smsAttempt = deliveryAttempts.find(
(attempt) => attempt.channel === 'sms',
);
const failedMessage = delivery?.errors?.[0];
const noticeParts: string[] = [];
if (delivery?.sent) {
noticeParts.push('Email sent through SMTP.');
}
if (smsAttempt?.status === 'sent') {
noticeParts.push('SMS sent.');
} else if (smsAttempt?.status === 'skipped' && smsAttempt.reason) {
noticeParts.push(`SMS skipped: ${smsAttempt.reason}`);
} else if (smsAttempt?.status === 'failed' && smsAttempt.reason) {
noticeParts.push(`SMS failed: ${smsAttempt.reason}`);
}
setDeliveryNotice(noticeParts.join(' '));
setCreated(newRequest);
setSelected(newRequest);
if (failedMessage || newRequest.status === 'failed') {
setError(
`Request was created, but delivery failed: ${failedMessage || newRequest.failure_reason || 'Unknown delivery error'}`,
);
}
setForm((current) => ({
...current,
customerName: '',
@ -316,6 +555,7 @@ export default function ReviewFlowWorkspace() {
setForm((current) => ({
...current,
businessName: connectorForm.businessName,
businessType: normalizeBusinessType(connectorForm.businessType),
reviewDestination: connectorForm.reviewDestination,
reviewLink: connectorForm.reviewLink,
delayDays: connectorForm.delayDays,
@ -349,6 +589,12 @@ export default function ReviewFlowWorkspace() {
const isReviewRequestBlocked = Boolean(
isSubscriptionInactive || isReviewRequestLimitReached,
);
const focusMetrics = [
['Pending', stats.pending, 'Needs attention'],
['Sent', stats.sent, 'Waiting for a customer'],
['Clicked', stats.clicked, 'Opened the review link'],
['Reviewed', stats.reviewed, 'Completed reviews'],
];
return (
<>
@ -365,33 +611,27 @@ export default function ReviewFlowWorkspace() {
</SectionTitleLineWithButton>
<div className='mb-6 overflow-hidden rounded-3xl bg-gradient-to-br from-slate-950 via-indigo-950 to-emerald-950 p-6 text-white shadow-2xl'>
<div className='grid gap-6 lg:grid-cols-[1.2fr_0.8fr] lg:items-center'>
<div className='grid gap-6 lg:grid-cols-[1fr_1fr] 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'>
Clean workflow · trigger customer right review destination
Clean workspace · quick request queue · message preview
</p>
<h2 className='max-w-3xl text-4xl font-black tracking-tight md:text-5xl'>
Keep ecommerce triggers and local review destinations cleanly separated.
<h2 className='max-w-3xl text-3xl font-black tracking-tight md:text-4xl'>
Focus on the review requests that need action now.
</h2>
<p className='mt-4 max-w-2xl text-base text-slate-200 md:text-lg'>
Stripe, Square, PayPal, Shopify, and WooCommerce create customers and transactions from webhooks. Google, Facebook, Yelp, Angi, OpenTable, Trustpilot, and Shopify hosted reviews are treated as review destinations.
<p className='mt-4 max-w-2xl text-base text-slate-200'>
The essentials stay visible. Setup, payment connectors, and webhook history are now tucked into dropdowns below.
</p>
</div>
<div className='grid grid-cols-2 gap-3'>
{[
['Events', stats.paymentEvents],
['Payments', stats.transactions],
['Pending', stats.pending],
['Customers', stats.customers],
['Clicked', stats.clicked],
['Reviewed', stats.reviewed],
].map(([label, value]) => (
{focusMetrics.map(([label, value, description]) => (
<div
key={label}
className='rounded-2xl bg-white/10 p-4 ring-1 ring-white/15 backdrop-blur'
>
<div className='text-3xl font-black'>{value}</div>
<div className='text-sm text-slate-300'>{label}</div>
<div className='text-sm font-bold text-white'>{label}</div>
<div className='text-xs text-slate-300'>{description}</div>
</div>
))}
</div>
@ -441,9 +681,16 @@ export default function ReviewFlowWorkspace() {
)}
{created && (
<div className='mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-emerald-900'>
<strong>Review request queued.</strong> {created.customer?.email} is
scheduled for {formatDate(created.scheduled_for)}.
<div className={`mb-6 rounded-2xl border p-4 ${created.status === 'failed' ? 'border-rose-200 bg-rose-50 text-rose-900' : 'border-emerald-200 bg-emerald-50 text-emerald-900'}`}>
<strong>
{created.status === 'sent'
? 'Review request sent.'
: created.status === 'failed'
? 'Review request delivery failed.'
: 'Review request scheduled.'}
</strong>{' '}
{created.customer?.email} {created.status === 'sent' ? 'was emailed' : 'is scheduled'} for {formatDate(created.scheduled_for)}.
{deliveryNotice && <p className='mt-2 text-sm'>{deliveryNotice}</p>}
</div>
)}
{error && (
@ -461,57 +708,19 @@ export default function ReviewFlowWorkspace() {
</div>
)}
<PaymentProviderConnectors
className='mb-6'
onConnected={handleProviderConnected}
/>
{isStarterPlan && (
<CardBox className='mb-6 border-0 bg-gradient-to-br from-indigo-950 to-slate-950 text-white shadow-2xl'>
<div className='grid gap-6 lg:grid-cols-[0.8fr_1.2fr] lg:items-center'>
<div>
<p className='text-sm font-black uppercase tracking-[0.25em] text-emerald-300'>
Pro upgrade prompts
</p>
<h3 className='mt-2 text-3xl font-black'>
Unlock advanced reputation growth tools.
</h3>
<p className='mt-3 text-slate-300'>
Starter keeps the core review workflow running. Pro raises limits to 10 business profiles and unlocks the next automation, AI, and marketing modules as they are enabled.
</p>
<BaseButton
href='/subscription'
icon={mdiOpenInNew}
label='Upgrade to Pro'
color='info'
className='mt-5'
/>
</div>
<div className='grid gap-3 md:grid-cols-3'>
{proFeaturePrompts.map(([title, copy]) => (
<div key={title} className='rounded-2xl bg-white/10 p-4 ring-1 ring-white/15'>
<p className='font-black'>{title}</p>
<p className='mt-2 text-sm leading-6 text-slate-300'>{copy}</p>
</div>
))}
</div>
</div>
</CardBox>
)}
<div className='grid gap-6 xl:grid-cols-[0.95fr_1.05fr]'>
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
<div className='mb-6 flex items-start justify-between gap-4'>
<div>
<p className='text-sm font-bold uppercase tracking-[0.25em] text-emerald-500'>
Manual fallback
Quick action
</p>
<h3 className='text-2xl font-black text-slate-900 dark:text-white'>
Queue a review request
Ask a customer for a review
</h3>
<p className='mt-2 text-sm text-slate-500 dark:text-slate-400'>
Use this when a payment did not come through a webhook, or
when you want to test the review queue manually.
Enter the customer first. Business setup, destination, timing,
and phone are available in Optional setup.
</p>
</div>
<div className='rounded-2xl bg-emerald-100 p-3 text-emerald-700'>
@ -546,57 +755,8 @@ export default function ReviewFlowWorkspace() {
<form onSubmit={handleSubmit}>
<FormField
label='Business and review destination'
help='Choose the destination first. Shopify hosted reviews generate a Review Flow form automatically.'
>
<input
required
value={form.businessName}
onChange={(event) =>
updateForm('businessName', event.target.value)
}
placeholder='Business name'
/>
<select
value={form.reviewDestination}
onChange={(event) =>
updateForm('reviewDestination', event.target.value)
}
>
{reviewDestinationOptions.map((destination) => (
<option key={destination.key} value={destination.key}>
{destination.label}
</option>
))}
</select>
</FormField>
<FormField
label={isHostedReviewDestination ? 'Hosted review form' : 'External review link'}
help={
isHostedReviewDestination
? 'No external URL needed; the outgoing email points to a hosted /review page.'
: 'Use the exact review page where this customer should land.'
}
>
{isHostedReviewDestination ? (
<div className='rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm font-semibold text-emerald-900'>
Review Flow will create a secure hosted product-review link for this request.
</div>
) : (
<input
required
type='url'
value={form.reviewLink}
onChange={(event) =>
updateForm('reviewLink', event.target.value)
}
placeholder='https://your-review-destination.example/review'
/>
)}
</FormField>
<FormField
label='Customer'
help='Webhook payments fill this automatically when the provider sends a customer email.'
label='Customer to ask for a review'
help='Most manual requests only need a customer name and email. Use Optional setup only when you need to change timing or destination.'
>
<input
value={form.customerName}
@ -615,31 +775,101 @@ export default function ReviewFlowWorkspace() {
placeholder='customer@example.com'
/>
</FormField>
<FormField
label='Delay and phone'
help={`Preview: scheduled for ${previewDate}`}
<ReviewFlowDisclosure
eyebrow='Optional setup'
title='Business, destination, timing, and phone'
description={`${form.businessName} · ${selectedReviewDestination.label} · ${Number(form.delayDays) > 0 ? `scheduled ${previewDate}` : 'sends immediately'}`}
className='mb-5 shadow-none'
>
<input
min='0'
max='30'
type='number'
value={form.delayDays}
onChange={(event) =>
updateForm('delayDays', event.target.value)
<FormField
label='Business type and review destination'
help={businessTypeOptions.find((option) => option.key === currentBusinessType)?.help}
>
<input
required
value={form.businessName}
onChange={(event) =>
updateForm('businessName', event.target.value)
}
placeholder='Business name'
/>
<select
value={form.businessType}
onChange={(event) =>
updateForm('businessType', event.target.value)
}
>
{businessTypeOptions.map((option) => (
<option key={option.key} value={option.key}>
{option.label}
</option>
))}
</select>
<select
value={form.reviewDestination}
onChange={(event) =>
updateForm('reviewDestination', event.target.value)
}
>
{filteredReviewDestinationOptions.map((destination) => (
<option key={destination.key} value={destination.key}>
{destination.label}
</option>
))}
</select>
</FormField>
<FormField
label={isHostedReviewDestination ? 'Hosted review form' : 'External review link'}
help={
isHostedReviewDestination
? 'No external URL needed; the outgoing email points to a hosted /review page.'
: 'Use the exact review page where this customer should land.'
}
placeholder='Delay days'
/>
<input
value={form.phone}
onChange={(event) => updateForm('phone', event.target.value)}
placeholder='Optional phone'
/>
</FormField>
>
{isHostedReviewDestination ? (
<div className='rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm font-semibold text-emerald-900'>
Review Flow will create a secure hosted product-review link for this request.
</div>
) : (
<input
required
type='url'
value={form.reviewLink}
onChange={(event) =>
updateForm('reviewLink', event.target.value)
}
placeholder='https://your-review-destination.example/review'
/>
)}
</FormField>
<FormField
label='Delay and phone'
help={`Preview: scheduled for ${previewDate}`}
>
<input
min='0'
max='30'
type='number'
value={form.delayDays}
onChange={(event) =>
updateForm('delayDays', event.target.value)
}
placeholder='Delay days'
/>
<input
value={form.phone}
onChange={(event) => updateForm('phone', event.target.value)}
placeholder='Optional phone'
/>
</FormField>
</ReviewFlowDisclosure>
<div className='flex flex-wrap gap-3'>
<BaseButton
type='submit'
icon={mdiSend}
label={isSubmitting ? 'Queueing...' : 'Queue review request'}
label={isSubmitting ? 'Sending...' : Number(form.delayDays) > 0 ? 'Schedule review request' : 'Send review request now'}
color='info'
disabled={isSubmitting || isReviewRequestBlocked}
/>
@ -790,7 +1020,58 @@ export default function ReviewFlowWorkspace() {
</div>
</div>
<div className='mt-6 grid gap-6 lg:grid-cols-2'>
<div className='mt-6 space-y-6'>
<ReviewFlowDisclosure
eyebrow='Setup'
title='Connect payment and order sources'
description='Use this when you want Review Flow to create requests automatically from Stripe, Square, PayPal, Shopify, or WooCommerce.'
>
<PaymentProviderConnectors
initialBusinessType={currentBusinessType}
onConnected={handleProviderConnected}
/>
</ReviewFlowDisclosure>
{isStarterPlan && (
<ReviewFlowDisclosure
eyebrow='Pro upgrade'
title='Higher limits and advanced growth tools'
description='Kept out of the way unless you want to compare what Pro unlocks.'
>
<div className='grid gap-6 lg:grid-cols-[0.8fr_1.2fr] lg:items-center'>
<div>
<h3 className='text-2xl font-black text-slate-900 dark:text-white'>
Unlock higher Review Flow limits.
</h3>
<p className='mt-3 text-slate-500 dark:text-slate-400'>
Grow keeps set-it-and-forget-it review automation running. Pro adds AI replies, referrals, NPS, broadcasts, rebooking, competitor insights, and higher limits.
</p>
<BaseButton
href='/subscription'
icon={mdiOpenInNew}
label='Upgrade to Pro'
color='info'
className='mt-5'
/>
</div>
<div className='grid gap-3 md:grid-cols-3'>
{proFeaturePrompts.map(([title, copy]) => (
<div key={title} className='rounded-2xl bg-slate-50 p-4 ring-1 ring-slate-200 dark:bg-dark-800 dark:ring-dark-700'>
<p className='font-black text-slate-900 dark:text-white'>{title}</p>
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400'>{copy}</p>
</div>
))}
</div>
</div>
</ReviewFlowDisclosure>
)}
<ReviewFlowDisclosure
eyebrow='Operational history'
title='Payment events and transactions'
description={`${stats.paymentEvents} webhook events · ${stats.transactions} transactions. Open this only when you need troubleshooting details.`}
>
<div className='grid gap-6 lg:grid-cols-2'>
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
<div className='mb-5 flex items-center justify-between'>
<div>
@ -891,6 +1172,8 @@ export default function ReviewFlowWorkspace() {
)}
</CardBox>
</div>
</ReviewFlowDisclosure>
</div>
</SectionMain>
</>
);

View File

@ -234,7 +234,7 @@ export default function SubscriptionPage() {
<strong>{missingConfiguration.join(', ')}</strong>.
</p>
<p className='mt-2 text-sm font-semibold'>
Create monthly Stripe Prices for Starter and Pro, paste their Price IDs into the matching variables, add your webhook secret, then reload the backend.
Create monthly Stripe Prices for Grow and Pro, paste their Price IDs into the matching variables, add your webhook secret, then reload the backend.
</p>
</CardBox>
)}

View File

@ -698,7 +698,6 @@ const EditUsers = () => {
EditUsers.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated
portal='admin'
permission={'UPDATE_USERS'}

View File

@ -695,7 +695,6 @@ const EditUsersPage = () => {
EditUsersPage.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated
portal='admin'
permission={'UPDATE_USERS'}

View File

@ -154,7 +154,6 @@ const UsersTablesPage = () => {
UsersTablesPage.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated
portal='admin'
permission={'READ_USERS'}

View File

@ -24,9 +24,10 @@ import { SelectFieldMany } from "../../components/SelectFieldMany";
import {RichTextField} from "../../components/RichTextField";
import { create } from '../../stores/users/usersSlice'
import { useAppDispatch } from '../../stores/hooks'
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
import { useRouter } from 'next/router'
import moment from 'moment';
import { isInternalAdmin } from '../../helpers/portalRoles';
const initialValues = {
@ -164,6 +165,8 @@ const initialValues = {
const UsersNew = () => {
const router = useRouter()
const dispatch = useAppDispatch()
const { currentUser } = useAppSelector((state) => state.auth)
const canManageAccessControls = isInternalAdmin(currentUser)
@ -432,47 +435,55 @@ const UsersNew = () => {
<FormField label="App Role" labelFor="app_role">
<Field name="app_role" id="app_role" component={SelectField} options={[]} itemRef={'roles'}></Field>
</FormField>
<FormField label='Custom Permissions' labelFor='custom_permissions'>
<Field
name='custom_permissions'
id='custom_permissions'
itemRef={'permissions'}
options={[]}
component={SelectFieldMany}>
</Field>
{canManageAccessControls ? (
<FormField label="App Role" labelFor="app_role">
<Field name="app_role" id="app_role" component={SelectField} options={[]} itemRef={'roles'}></Field>
</FormField>
) : (
<div className='mb-4 rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm font-semibold text-emerald-900'>
Team members invited from the customer workspace receive the Operations Manager role by default.
</div>
)}
{canManageAccessControls && (
<FormField label='Custom Permissions' labelFor='custom_permissions'>
<Field
name='custom_permissions'
id='custom_permissions'
itemRef={'permissions'}
options={[]}
component={SelectFieldMany}>
</Field>
</FormField>
)}
@ -495,7 +506,6 @@ const UsersNew = () => {
UsersNew.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated
portal='admin'
permission={'CREATE_USERS'}

View File

@ -152,7 +152,6 @@ const UsersTablesPage = () => {
UsersTablesPage.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated
portal='admin'
permission={'READ_USERS'}

View File

@ -585,7 +585,6 @@ const UsersView = () => {
UsersView.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated
portal='admin'
permission={'READ_USERS'}

View File

@ -21,12 +21,12 @@ export const trialDays = 14;
export const subscriptionPlans: SubscriptionPlan[] = [
{
id: 'starter',
name: 'Starter',
name: 'Grow',
priceMonthly: 49,
currency: 'USD',
trialDays,
tagline: 'For small teams that want automated review collection without extra marketing automation.',
ctaLabel: 'Start Starter trial',
tagline: 'For review automation that runs after setup: requests, reminders, widgets, and clean local/online routing.',
ctaLabel: 'Start Grow trial',
limits: {
monthlyReviewRequests: 250,
businesses: 1,
@ -34,17 +34,16 @@ export const subscriptionPlans: SubscriptionPlan[] = [
paymentConnectors: 5,
},
features: [
'Review Flow dashboard',
'Manual review request creation',
'Hosted public review form',
'Customer management',
'Business profile management',
'Transaction tracking',
'Set-it-and-forget-it review request automation',
'Local, online, or Hybrid business setup',
'Automatic review requests from payments and orders',
'Manual review request queue',
'Hosted public product-review form',
'Review monitoring dashboard and queue',
'Embeddable social proof review widget',
'Customer, business, and transaction management',
'Stripe, Square, PayPal, Shopify, and WooCommerce webhook intake',
'Review request status tracking',
'Email delivery logs',
'Basic reporting',
'Standard support',
'Basic usage reporting',
],
},
{
@ -53,7 +52,7 @@ export const subscriptionPlans: SubscriptionPlan[] = [
priceMonthly: 99,
currency: 'USD',
trialDays,
tagline: 'For growing businesses that want automation, AI assistance, and reputation marketing tools.',
tagline: 'For teams that want AI replies, referrals, NPS, broadcasts, rebooking campaigns, and competitor insight tools.',
highlight: 'Best value',
ctaLabel: 'Start Pro trial',
limits: {
@ -63,19 +62,16 @@ export const subscriptionPlans: SubscriptionPlan[] = [
paymentConnectors: 5,
},
features: [
'Everything in Starter',
'Advanced automation rules',
'Everything in Grow',
'AI review reply assistant',
'Social proof widgets',
'Review monitoring workspace',
'Referral campaigns',
'Repeat booking reminders',
'NPS surveys',
'Competitor/reputation insights',
'Broadcast campaigns',
'Advanced reporting',
'Branding customization',
'Priority support',
'Referral campaign queueing',
'NPS survey campaign queueing',
'Marketing broadcasts and repeat-business campaigns',
'Competitor insight workspace',
'2,500 review requests per month',
'Up to 10 business profiles',
'Up to 10 team members',
'Subscription usage dashboard and upgrade controls',
],
},
];