diff --git a/backend/.env b/backend/.env
index 25241b7..3910891 100644
--- a/backend/.env
+++ b/backend/.env
@@ -1 +1,4 @@
PORT=8080
+TWILIO_ACCOUNT_SID=ACf0b6dd3d34b2aefffd9914c317bf04e0
+TWILIO_AUTH_TOKEN=5b4dc2c0246b699596997a212a46548a
+TWILIO_FROM_NUMBER=+17372324091
diff --git a/backend/src/db/api/businesses.js b/backend/src/db/api/businesses.js
index 6cae658..c0f1cf9 100644
--- a/backend/src/db/api/businesses.js
+++ b/backend/src/db/api/businesses.js
@@ -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 = [
diff --git a/backend/src/db/api/users.js b/backend/src/db/api/users.js
index 9376dab..a0482fc 100644
--- a/backend/src/db/api/users.js
+++ b/backend/src/db/api/users.js
@@ -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,
diff --git a/backend/src/db/migrations/20260629120000-add-business-type-and-growth-automation.js b/backend/src/db/migrations/20260629120000-add-business-type-and-growth-automation.js
new file mode 100644
index 0000000..75d8fd6
--- /dev/null
+++ b/backend/src/db/migrations/20260629120000-add-business-type-and-growth-automation.js
@@ -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;
+ }
+ },
+};
diff --git a/backend/src/db/models/businesses.js b/backend/src/db/models/businesses.js
index e8751bb..2ca0e56 100644
--- a/backend/src/db/models/businesses.js
+++ b/backend/src/db/models/businesses.js
@@ -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: {
diff --git a/backend/src/db/seeders/20260629143000-pro-demo-customer.js b/backend/src/db/seeders/20260629143000-pro-demo-customer.js
new file mode 100644
index 0000000..8a78730
--- /dev/null
+++ b/backend/src/db/seeders/20260629143000-pro-demo-customer.js
@@ -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 ',
+ 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' });
+ },
+};
diff --git a/backend/src/routes/reviewflow-public.js b/backend/src/routes/reviewflow-public.js
index 750c83a..baf6b1d 100644
--- a/backend/src/routes/reviewflow-public.js
+++ b/backend/src/routes/reviewflow-public.js
@@ -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, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+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) => `
+
+ ${'★'.repeat(Number(review.rating) || 5)}
+ ${escapeHtml(review.title || 'Great experience')}
+ ${escapeHtml(review.content)}
+ ${escapeHtml(review.reviewer)} · ${escapeHtml(review.source)}
+
+ `).join('')
+ : 'Fresh reviews will appear here after customers submit them.
';
+
+ res.status(200).type('html').send(`
+
+
+
+
+
+
+
+
+
+`);
+}));
+
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;
diff --git a/backend/src/routes/reviewflow.js b/backend/src/routes/reviewflow.js
index 63045f2..f0c5515 100644
--- a/backend/src/routes/reviewflow.js
+++ b/backend/src/routes/reviewflow.js
@@ -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: ``,
+ });
+}));
+
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;
diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js
index 19df9ae..32c9abe 100644
--- a/backend/src/routes/users.js
+++ b/backend/src/routes/users.js
@@ -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);
}));
diff --git a/backend/src/services/reviewflow.js b/backend/src/services/reviewflow.js
index 7877714..b982b5e 100644
--- a/backend/src/services/reviewflow.js
+++ b/backend/src/services/reviewflow.js
@@ -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, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+function textToHtml(value) {
+ return escapeHtml(value)
+ .split('\n')
+ .map((line) => line || ' ')
+ .join(' ');
+}
+
+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 () => `
+
+
+
Sent by Review Flow for ${escapeHtml(businessName)}.
+
+ `,
+ };
+}
+
+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 ',
+ 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 2–3 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,
diff --git a/backend/src/services/subscriptionPlans.js b/backend/src/services/subscriptionPlans.js
index 272ad1d..1199fc5 100644
--- a/backend/src/services/subscriptionPlans.js
+++ b/backend/src/services/subscriptionPlans.js
@@ -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',
],
},
];
diff --git a/backend/src/services/users.js b/backend/src/services/users.js
index 2736294..638f8c3 100644
--- a/backend/src/services/users.js
+++ b/backend/src/services/users.js
@@ -109,7 +109,7 @@ module.exports = class UsersService {
try {
let users = await UsersDBApi.findBy(
{id},
- {transaction},
+ {transaction, currentUser},
);
if (!users) {
diff --git a/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx b/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx
index 06f3ff5..f0b8872 100644
--- a/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx
+++ b/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx
@@ -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 = {
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(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) => {
event.preventDefault();
setIsConnectorSubmitting(true);
@@ -927,8 +1057,7 @@ export default function PaymentProviderConnectors({
1. Select provider
- 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.
@@ -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'
>
option.key === currentBusinessType)?.help}
>
updateConnectorForm('businessType', event.target.value)}
+ >
+ {businessTypeOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+ updateSelectedProvider(event.target.value)}
>
- {providerOptions.map((provider) => (
+ {filteredProviderOptions.map((provider) => (
{provider.label}
@@ -999,7 +1138,7 @@ export default function PaymentProviderConnectors({
updateConnectorForm('reviewDestination', event.target.value)
}
>
- {reviewDestinationOptions.map((destination) => (
+ {filteredReviewDestinationOptions.map((destination) => (
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 (
diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts
index e517baa..f99a43d 100644
--- a/frontend/src/menuAside.ts
+++ b/frontend/src/menuAside.ts
@@ -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,
diff --git a/frontend/src/pages/businesses/[businessesId].tsx b/frontend/src/pages/businesses/[businessesId].tsx
index b86bd15..125b45f 100644
--- a/frontend/src/pages/businesses/[businessesId].tsx
+++ b/frontend/src/pages/businesses/[businessesId].tsx
@@ -69,6 +69,8 @@ const EditBusinesses = () => {
'name': '',
+
+ 'business_type': 'hybrid',
@@ -586,7 +588,20 @@ const EditBusinesses = () => {
-
+
+ Local / service business
+ Online / ecommerce business
+ Hybrid business
+
+
+
{
'name': '',
+
+ 'business_type': 'hybrid',
@@ -583,7 +585,20 @@ const EditBusinessesPage = () => {
-
+
+ Local / service business
+ Online / ecommerce business
+ Hybrid business
+
+
+
{
{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.'}
{
+
+
+
+ Local / service business
+ Online / ecommerce business
+ Hybrid business
+
+
+
{
{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.'}
;
+};
+
+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(null);
+ const [subscriptionStatus, setSubscriptionStatus] = useState(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(null);
+ const [competitorInsights, setCompetitorInsights] = useState(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) => {
+ 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 (
+ <>
+
+ {getPageTitle('Growth Tools')}
+
+
+
+ {''}
+
+
+
+
+
+
+ Automated review management · set it and forget it
+
+
+ Keep review growth simple after business setup.
+
+
+ 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.
+
+
+
+ {[
+ ['Pending', summary?.stats.pending ?? 0],
+ ['Sent', summary?.stats.sent ?? 0],
+ ['Clicked', summary?.stats.clicked ?? 0],
+ ['Reviewed', summary?.stats.reviewed ?? 0],
+ ].map(([label, value]) => (
+
+ ))}
+
+
+
+
+ {message && (
+
+ Done. {message}
+
+ )}
+ {(error || aiErrorMessage) && (
+
+
{error || aiErrorMessage}
+ {(error || aiErrorMessage).includes('Upgrade to Pro') && (
+
+ )}
+
+ )}
+
+
+
+
+
+
Setup
+
Business type and automation
+
+ This is the uncluttered switch: Local hides ecommerce-only tools, Online hides local-only tools, and Hybrid keeps both.
+
+
+
+
+
+ {businesses.length > 0 && (
+
+ selectBusiness(event.target.value)}>
+ {businesses.map((business) => (
+ {business.name || 'Business'}
+ ))}
+
+
+ )}
+
+
+
+
+
+
+ Grow
+ Social proof widget
+
+ Grow includes an embeddable widget for hosted reviews. It displays verified reviews after customers submit them through Review Flow.
+
+
+ {widget?.embedCode && (
+
+
Embed code
+
{widget.embedCode}
+
+ )}
+ {widget?.reviews && (
+
+ {widget.reviews.length === 0 ? (
+
No hosted reviews yet.
+ ) : widget.reviews.map((review) => (
+
+
{'★'.repeat(review.rating || 5)}
+
{review.title || 'Customer review'}
+
{review.content}
+
+ ))}
+
+ )}
+
+
+
+
+
+
Pro
+
AI review reply assistant
+
+ {isGrowPlan &&
Pro }
+
+
+ setAiReviewText(event.target.value)} placeholder='Paste customer review text' />
+ setAiTone(event.target.value)} placeholder='Tone' />
+
+
+ {aiSuggestion && (
+
+ {aiSuggestion}
+
+ )}
+
+
+
+
+
+
+
+
+
Pro campaigns
+
Referrals, NPS, broadcasts, and rebooking
+
+ {isGrowPlan &&
}
+
+
+ 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.
+
+
+ setCampaignForm((current) => ({ ...current, campaignType: event.target.value }))}>
+ Marketing broadcast
+ Referral campaign
+ NPS survey
+ Repeat business / rebooking
+
+ setCampaignForm((current) => ({ ...current, subject: event.target.value }))} placeholder='Subject' />
+
+
+ setCampaignForm((current) => ({ ...current, message: event.target.value }))} placeholder='Message' />
+
+
+
+
+
+
+
+
Pro insights
+
Competitor insight checklist
+
+ {isGrowPlan &&
Pro }
+
+
+ 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.
+
+
+ {competitorInsights && (
+
+
+ {Object.entries(competitorInsights.metrics).map(([label, value]) => (
+
+ ))}
+
+
+
Tracked competitors
+
+ {competitorInsights.competitors.map((competitor) => {competitor} )}
+
+
+
+
Recommended next actions
+
+ {competitorInsights.recommendations.map((recommendation) => {recommendation} )}
+
+
+
+ )}
+
+
+
+ >
+ );
+}
+
+GrowthToolsPage.getLayout = function getLayout(page: ReactElement) {
+ return {page} ;
+};
diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx
index 807cce5..1291535 100644
--- a/frontend/src/pages/index.tsx
+++ b/frontend/src/pages/index.tsx
@@ -62,13 +62,13 @@ export default function Starter() {
- Review automation for modern local businesses
+ Automated review management for local, online, and hybrid businesses
Ask at the perfect moment. Earn more five-star reviews.
- 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.
@@ -141,9 +141,9 @@ export default function Starter() {
Simple pricing
-
Choose Starter or Pro.
+
Choose Grow or Pro.
- 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.
diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx
index 60a8c79..692cc83 100644
--- a/frontend/src/pages/login.tsx
+++ b/frontend/src/pages/login.tsx
@@ -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() {
Use{' '}
setLogin(e.target)}>admin@flatlogic.com{' / '}
- fc6e39e3{' / '}
- to login as Internal Admin
-
Use setLogin(e.target)}>pro@reviewflow.demo{' / '}
+ ProDemo2026!{' / '}
+ to login as Pro Demo Customer
+
Use setLogin(e.target)}>john@doe.com{' / '}
874c3b951385{' / '}
- to login as Customer Owner
+ to login as Grow Customer Owner
+
Use{' '}
+ setLogin(e.target)}>admin@flatlogic.com{' / '}
+ fc6e39e3{' / '}
+ to login as Internal Admin
= [
+ {
+ 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 = {
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 = {
};
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 (
+
+
+
+ {eyebrow && (
+
+ {eyebrow}
+
+ )}
+
+ {title}
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+ ⌄
+
+
+
+ {children}
+
+
+ );
+}
+
export default function ReviewFlowWorkspace() {
const [form, setForm] = useState(defaultForm);
const [summary, setSummary] = useState(null);
const [selected, setSelected] = useState(null);
const [created, setCreated] = useState(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) => {
@@ -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() {
-
+
- Clean workflow · trigger → customer → right review destination
+ Clean workspace · quick request queue · message preview
-
- Keep ecommerce triggers and local review destinations cleanly separated.
+
+ Focus on the review requests that need action now.
-
- 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.
+
+ The essentials stay visible. Setup, payment connectors, and webhook history are now tucked into dropdowns below.
- {[
- ['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]) => (
{value}
-
{label}
+
{label}
+
{description}
))}
@@ -441,9 +681,16 @@ export default function ReviewFlowWorkspace() {
)}
{created && (
-
-
Review request queued. {created.customer?.email} is
- scheduled for {formatDate(created.scheduled_for)}.
+
+
+ {created.status === 'sent'
+ ? 'Review request sent.'
+ : created.status === 'failed'
+ ? 'Review request delivery failed.'
+ : 'Review request scheduled.'}
+ {' '}
+ {created.customer?.email} {created.status === 'sent' ? 'was emailed' : 'is scheduled'} for {formatDate(created.scheduled_for)}.
+ {deliveryNotice &&
{deliveryNotice}
}
)}
{error && (
@@ -461,57 +708,19 @@ export default function ReviewFlowWorkspace() {
)}
-
-
- {isStarterPlan && (
-
-
-
-
- Pro upgrade prompts
-
-
- Unlock advanced reputation growth tools.
-
-
- 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.
-
-
-
-
- {proFeaturePrompts.map(([title, copy]) => (
-
- ))}
-
-
-
- )}
-
- Manual fallback
+ Quick action
- Queue a review request
+ Ask a customer for a review
- 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.
@@ -546,57 +755,8 @@ export default function ReviewFlowWorkspace() {
-
- updateForm('businessName', event.target.value)
- }
- placeholder='Business name'
- />
-
- updateForm('reviewDestination', event.target.value)
- }
- >
- {reviewDestinationOptions.map((destination) => (
-
- {destination.label}
-
- ))}
-
-
-
- {isHostedReviewDestination ? (
-
- Review Flow will create a secure hosted product-review link for this request.
-
- ) : (
-
- updateForm('reviewLink', event.target.value)
- }
- placeholder='https://your-review-destination.example/review'
- />
- )}
-
-
- 0 ? `scheduled ${previewDate}` : 'sends immediately'}`}
+ className='mb-5 shadow-none'
>
-
- updateForm('delayDays', event.target.value)
+ option.key === currentBusinessType)?.help}
+ >
+
+ updateForm('businessName', event.target.value)
+ }
+ placeholder='Business name'
+ />
+
+ updateForm('businessType', event.target.value)
+ }
+ >
+ {businessTypeOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ updateForm('reviewDestination', event.target.value)
+ }
+ >
+ {filteredReviewDestinationOptions.map((destination) => (
+
+ {destination.label}
+
+ ))}
+
+
+
- updateForm('phone', event.target.value)}
- placeholder='Optional phone'
- />
-
+ >
+ {isHostedReviewDestination ? (
+
+ Review Flow will create a secure hosted product-review link for this request.
+
+ ) : (
+
+ updateForm('reviewLink', event.target.value)
+ }
+ placeholder='https://your-review-destination.example/review'
+ />
+ )}
+
+
+
+ updateForm('delayDays', event.target.value)
+ }
+ placeholder='Delay days'
+ />
+ updateForm('phone', event.target.value)}
+ placeholder='Optional phone'
+ />
+
+
+
0 ? 'Schedule review request' : 'Send review request now'}
color='info'
disabled={isSubmitting || isReviewRequestBlocked}
/>
@@ -790,7 +1020,58 @@ export default function ReviewFlowWorkspace() {
-
+
+
+
+
+
+ {isStarterPlan && (
+
+
+
+
+ Unlock higher Review Flow limits.
+
+
+ Grow keeps set-it-and-forget-it review automation running. Pro adds AI replies, referrals, NPS, broadcasts, rebooking, competitor insights, and higher limits.
+
+
+
+
+ {proFeaturePrompts.map(([title, copy]) => (
+
+ ))}
+
+
+
+ )}
+
+
+
@@ -891,6 +1172,8 @@ export default function ReviewFlowWorkspace() {
)}
+
+
>
);
diff --git a/frontend/src/pages/subscription.tsx b/frontend/src/pages/subscription.tsx
index 445c279..1a85340 100644
--- a/frontend/src/pages/subscription.tsx
+++ b/frontend/src/pages/subscription.tsx
@@ -234,7 +234,7 @@ export default function SubscriptionPage() {
{missingConfiguration.join(', ')} .
- 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.
)}
diff --git a/frontend/src/pages/users/[usersId].tsx b/frontend/src/pages/users/[usersId].tsx
index 0efb8fe..4b6477c 100644
--- a/frontend/src/pages/users/[usersId].tsx
+++ b/frontend/src/pages/users/[usersId].tsx
@@ -698,7 +698,6 @@ const EditUsers = () => {
EditUsers.getLayout = function getLayout(page: ReactElement) {
return (
{
EditUsersPage.getLayout = function getLayout(page: ReactElement) {
return (
{
UsersTablesPage.getLayout = function getLayout(page: ReactElement) {
return (
{
const router = useRouter()
const dispatch = useAppDispatch()
+ const { currentUser } = useAppSelector((state) => state.auth)
+ const canManageAccessControls = isInternalAdmin(currentUser)
@@ -432,47 +435,55 @@ const UsersNew = () => {
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ {canManageAccessControls ? (
+
+
+ ) : (
+
+ Team members invited from the customer workspace receive the Operations Manager role by default.
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {canManageAccessControls && (
+
+
+
+
+ )}
@@ -495,7 +506,6 @@ const UsersNew = () => {
UsersNew.getLayout = function getLayout(page: ReactElement) {
return (
{
UsersTablesPage.getLayout = function getLayout(page: ReactElement) {
return (
{
UsersView.getLayout = function getLayout(page: ReactElement) {
return (