Autosave: 20260629-224348
This commit is contained in:
parent
f741ab0364
commit
30e91e2b17
@ -1 +1,4 @@
|
||||
PORT=8080
|
||||
TWILIO_ACCOUNT_SID=ACf0b6dd3d34b2aefffd9914c317bf04e0
|
||||
TWILIO_AUTH_TOKEN=5b4dc2c0246b699596997a212a46548a
|
||||
TWILIO_FROM_NUMBER=+17372324091
|
||||
|
||||
@ -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 = [
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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: {
|
||||
|
||||
387
backend/src/db/seeders/20260629143000-pro-demo-customer.js
Normal file
387
backend/src/db/seeders/20260629143000-pro-demo-customer.js
Normal file
@ -0,0 +1,387 @@
|
||||
'use strict';
|
||||
|
||||
const bcrypt = require('bcrypt');
|
||||
const config = require('../../config');
|
||||
|
||||
const DEMO_PASSWORD = 'ProDemo2026!';
|
||||
const ids = {
|
||||
user: '78e31e71-a4cd-4e73-aa11-2b0b681f0a33',
|
||||
business: '1f862b7d-1b83-4a37-95ff-6b23a0f91d01',
|
||||
customerA: 'b780ce14-f076-42a6-9d14-70c0559ef4e7',
|
||||
customerB: '089a6496-37a5-47b6-b282-814d8d4bbb49',
|
||||
customerC: 'cc7c37d2-f93a-4817-8a36-d2c26ee98b26',
|
||||
transactionA: '0e57256e-868b-4d69-a896-45f9becc926b',
|
||||
transactionB: '129b1899-4dc4-4cc8-8b30-6af4c8e64b8e',
|
||||
requestA: '9fcad09d-4d77-49e6-a08c-e7e65f8938ac',
|
||||
requestB: 'cb93d8a8-c6a7-44fc-af98-e0b18990561f',
|
||||
requestC: '4d8347f1-694b-40ec-90a7-680c7fb8501f',
|
||||
eventA: 'c9965d28-3895-4263-8fdf-ee05a5a709d3',
|
||||
eventB: 'c251b361-d407-49f3-9694-6d0e72d0dd8a',
|
||||
emailLogA: 'eb0c8667-9913-4851-b443-e18cbb22737e',
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
const now = new Date();
|
||||
const trialStartedAt = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000);
|
||||
const trialEndsAt = new Date(now.getTime() + 12 * 24 * 60 * 60 * 1000);
|
||||
const paidAtA = new Date(now.getTime() - 36 * 60 * 60 * 1000);
|
||||
const paidAtB = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const scheduledSoon = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000);
|
||||
const sentYesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
const reviewedLastWeek = new Date(now.getTime() - 6 * 24 * 60 * 60 * 1000);
|
||||
const passwordHash = bcrypt.hashSync(DEMO_PASSWORD, config.bcrypt.saltRounds);
|
||||
const [accountOwnerRole] = await queryInterface.sequelize.query(
|
||||
'SELECT id FROM "roles" WHERE name = :roleName LIMIT 1',
|
||||
{
|
||||
replacements: { roleName: 'Account Owner' },
|
||||
type: Sequelize.QueryTypes.SELECT,
|
||||
},
|
||||
);
|
||||
|
||||
if (!accountOwnerRole?.id) {
|
||||
throw new Error('Cannot seed Pro demo customer: Account Owner role was not found.');
|
||||
}
|
||||
|
||||
const [existingDemoUser] = await queryInterface.sequelize.query(
|
||||
'SELECT id FROM "users" WHERE email = :email LIMIT 1',
|
||||
{
|
||||
replacements: { email: 'pro@reviewflow.demo' },
|
||||
type: Sequelize.QueryTypes.SELECT,
|
||||
},
|
||||
);
|
||||
const demoUserId = existingDemoUser?.id || ids.user;
|
||||
|
||||
if (existingDemoUser?.id) {
|
||||
await queryInterface.sequelize.query(
|
||||
`UPDATE "users"
|
||||
SET "firstName" = :firstName,
|
||||
"lastName" = :lastName,
|
||||
"emailVerified" = true,
|
||||
"provider" = :provider,
|
||||
"password" = :password,
|
||||
"disabled" = false,
|
||||
"subscriptionPlanId" = :subscriptionPlanId,
|
||||
"subscriptionStatus" = :subscriptionStatus,
|
||||
"trialStartedAt" = :trialStartedAt,
|
||||
"trialEndsAt" = :trialEndsAt,
|
||||
"app_roleId" = :appRoleId,
|
||||
"updatedAt" = :updatedAt,
|
||||
"deletedAt" = NULL
|
||||
WHERE id = :id`,
|
||||
{
|
||||
replacements: {
|
||||
id: demoUserId,
|
||||
firstName: 'Pro',
|
||||
lastName: 'Demo Customer',
|
||||
provider: config.providers.LOCAL,
|
||||
password: passwordHash,
|
||||
subscriptionPlanId: 'pro',
|
||||
subscriptionStatus: 'trialing',
|
||||
trialStartedAt,
|
||||
trialEndsAt,
|
||||
appRoleId: accountOwnerRole.id,
|
||||
updatedAt: now,
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await queryInterface.bulkInsert('users', [
|
||||
{
|
||||
id: demoUserId,
|
||||
firstName: 'Pro',
|
||||
lastName: 'Demo Customer',
|
||||
email: 'pro@reviewflow.demo',
|
||||
disabled: false,
|
||||
password: passwordHash,
|
||||
emailVerified: true,
|
||||
provider: config.providers.LOCAL,
|
||||
subscriptionPlanId: 'pro',
|
||||
subscriptionStatus: 'trialing',
|
||||
trialStartedAt,
|
||||
trialEndsAt,
|
||||
app_roleId: accountOwnerRole.id,
|
||||
createdById: demoUserId,
|
||||
updatedById: demoUserId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
await queryInterface.sequelize.query(
|
||||
`UPDATE "users"
|
||||
SET "createdById" = COALESCE("createdById", :id),
|
||||
"updatedById" = :id
|
||||
WHERE id = :id`,
|
||||
{ replacements: { id: demoUserId } },
|
||||
);
|
||||
|
||||
await queryInterface.bulkDelete('email_delivery_logs', { id: ids.emailLogA });
|
||||
await queryInterface.bulkDelete('stripe_events', { id: { [Sequelize.Op.in]: [ids.eventA, ids.eventB] } });
|
||||
await queryInterface.bulkDelete('review_requests', { id: { [Sequelize.Op.in]: [ids.requestA, ids.requestB, ids.requestC] } });
|
||||
await queryInterface.bulkDelete('transactions', { id: { [Sequelize.Op.in]: [ids.transactionA, ids.transactionB] } });
|
||||
await queryInterface.bulkDelete('customers', { id: { [Sequelize.Op.in]: [ids.customerA, ids.customerB, ids.customerC] } });
|
||||
await queryInterface.bulkDelete('businesses', { id: ids.business });
|
||||
|
||||
await queryInterface.bulkInsert('businesses', [
|
||||
{
|
||||
id: ids.business,
|
||||
ownerId: demoUserId,
|
||||
name: 'Harbor Freight Logistics',
|
||||
business_type: 'hybrid',
|
||||
review_destination: 'google',
|
||||
google_review_link: 'https://g.page/r/harbor-freight-logistics/review',
|
||||
yelp_review_link: 'https://www.yelp.com/biz/harbor-freight-logistics',
|
||||
facebook_review_link: 'https://www.facebook.com/harborfreightlogistics/reviews',
|
||||
trustpilot_review_link: 'https://www.trustpilot.com/review/harborfreightlogistics.example',
|
||||
custom_review_link: 'https://reviews.example.com/harbor-freight-logistics',
|
||||
delay_days: 3,
|
||||
email_subject_template: 'How was your delivery with Harbor Freight Logistics?',
|
||||
email_body_template: 'Hi {customerName}, thanks for choosing Harbor Freight Logistics. Please leave a review here: {reviewLink}',
|
||||
is_active: true,
|
||||
stripe_connected: true,
|
||||
stripe_account_reference: 'acct_demo_harbor_logistics',
|
||||
stripe_connected_at: now,
|
||||
paypal_connected: true,
|
||||
paypal_merchant_reference: 'merchant_demo_harbor_logistics',
|
||||
paypal_connected_at: now,
|
||||
shopify_connected: true,
|
||||
shopify_store_reference: 'harbor-demo.myshopify.com',
|
||||
shopify_connected_at: now,
|
||||
shopify_hosted_reviews_enabled: true,
|
||||
automation_mode: 'set_and_forget',
|
||||
followup_enabled: true,
|
||||
followup_delay_days: 3,
|
||||
max_followups: 1,
|
||||
ai_reply_enabled: true,
|
||||
referral_enabled: true,
|
||||
referral_offer: '$25 credit for every referred shipper who books a delivery.',
|
||||
nps_enabled: true,
|
||||
nps_question: 'How likely are you to recommend our delivery team?',
|
||||
social_widget_enabled: true,
|
||||
broadcast_enabled: true,
|
||||
rebooking_enabled: true,
|
||||
competitor_insights_enabled: true,
|
||||
competitor_urls: 'https://www.trustpilot.com/review/example-competitor-logistics',
|
||||
review_widget_theme: 'light',
|
||||
createdById: demoUserId,
|
||||
updatedById: demoUserId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
]);
|
||||
|
||||
await queryInterface.bulkInsert('customers', [
|
||||
{
|
||||
id: ids.customerA,
|
||||
businessId: ids.business,
|
||||
email: 'mia@northstarfoods.example',
|
||||
name: 'Mia Torres',
|
||||
phone: '+1 555 0101',
|
||||
stripe_customer_reference: 'cus_demo_mia_torres',
|
||||
contact_status: 'active',
|
||||
last_transaction_at: paidAtA,
|
||||
createdById: demoUserId,
|
||||
updatedById: demoUserId,
|
||||
createdAt: paidAtA,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: ids.customerB,
|
||||
businessId: ids.business,
|
||||
email: 'dispatch@evergreenretail.example',
|
||||
name: 'Evergreen Retail Dispatch',
|
||||
phone: '+1 555 0102',
|
||||
paypal_customer_reference: 'payer_demo_evergreen',
|
||||
contact_status: 'active',
|
||||
last_transaction_at: paidAtB,
|
||||
createdById: demoUserId,
|
||||
updatedById: demoUserId,
|
||||
createdAt: paidAtB,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: ids.customerC,
|
||||
businessId: ids.business,
|
||||
email: 'ops@ridgewayparts.example',
|
||||
name: 'Ridgeway Parts Ops',
|
||||
phone: '+1 555 0103',
|
||||
shopify_customer_reference: 'shopify_customer_demo_ridgeway',
|
||||
contact_status: 'active',
|
||||
last_transaction_at: reviewedLastWeek,
|
||||
createdById: demoUserId,
|
||||
updatedById: demoUserId,
|
||||
createdAt: reviewedLastWeek,
|
||||
updatedAt: now,
|
||||
},
|
||||
]);
|
||||
|
||||
await queryInterface.bulkInsert('transactions', [
|
||||
{
|
||||
id: ids.transactionA,
|
||||
businessId: ids.business,
|
||||
customerId: ids.customerA,
|
||||
stripe_payment_reference: 'pi_demo_roadway_1001',
|
||||
payment_provider: 'stripe',
|
||||
provider_event_reference: 'evt_demo_roadway_1001',
|
||||
amount: 1280.5,
|
||||
currency: 'USD',
|
||||
payment_status: 'succeeded',
|
||||
paid_at: paidAtA,
|
||||
description: 'Temperature-controlled regional delivery',
|
||||
receipt_email: 'mia@northstarfoods.example',
|
||||
createdById: demoUserId,
|
||||
updatedById: demoUserId,
|
||||
createdAt: paidAtA,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: ids.transactionB,
|
||||
businessId: ids.business,
|
||||
customerId: ids.customerB,
|
||||
paypal_payment_reference: 'paypal_demo_evergreen_2044',
|
||||
payment_provider: 'paypal',
|
||||
provider_event_reference: 'WH-DEMO-EVERGREEN-2044',
|
||||
amount: 845,
|
||||
currency: 'USD',
|
||||
payment_status: 'succeeded',
|
||||
paid_at: paidAtB,
|
||||
description: 'Last-mile pallet delivery',
|
||||
receipt_email: 'dispatch@evergreenretail.example',
|
||||
createdById: demoUserId,
|
||||
updatedById: demoUserId,
|
||||
createdAt: paidAtB,
|
||||
updatedAt: now,
|
||||
},
|
||||
]);
|
||||
|
||||
await queryInterface.bulkInsert('review_requests', [
|
||||
{
|
||||
id: ids.requestA,
|
||||
businessId: ids.business,
|
||||
customerId: ids.customerA,
|
||||
transactionId: ids.transactionA,
|
||||
status: 'pending',
|
||||
scheduled_for: scheduledSoon,
|
||||
email_subject: 'How was your delivery with Harbor Freight Logistics?',
|
||||
email_body: 'Hi Mia,\n\nThank you for choosing Harbor Freight Logistics. Would you share a quick review of your delivery experience?\n\nLeave a review: https://g.page/r/harbor-freight-logistics/review\n\nThank you,\nHarbor Freight Logistics',
|
||||
review_link: 'https://g.page/r/harbor-freight-logistics/review',
|
||||
tracking_token: 'demo-pro-pending-token',
|
||||
review_platform: 'google',
|
||||
createdById: demoUserId,
|
||||
updatedById: demoUserId,
|
||||
createdAt: paidAtA,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: ids.requestB,
|
||||
businessId: ids.business,
|
||||
customerId: ids.customerB,
|
||||
transactionId: ids.transactionB,
|
||||
status: 'clicked',
|
||||
scheduled_for: sentYesterday,
|
||||
sent_at: sentYesterday,
|
||||
clicked_at: new Date(sentYesterday.getTime() + 3 * 60 * 60 * 1000),
|
||||
email_subject: 'Thanks from Harbor Freight Logistics',
|
||||
email_body: 'Hi Evergreen Retail Dispatch,\n\nThanks for trusting our team with your pallet delivery. Please share your feedback when you have a moment.\n\nLeave a review: https://reviews.example.com/harbor-freight-logistics\n\nThank you,\nHarbor Freight Logistics',
|
||||
review_link: 'https://reviews.example.com/harbor-freight-logistics',
|
||||
tracking_token: 'demo-pro-clicked-token',
|
||||
review_platform: 'custom',
|
||||
createdById: demoUserId,
|
||||
updatedById: demoUserId,
|
||||
createdAt: sentYesterday,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: ids.requestC,
|
||||
businessId: ids.business,
|
||||
customerId: ids.customerC,
|
||||
status: 'reviewed',
|
||||
scheduled_for: reviewedLastWeek,
|
||||
sent_at: reviewedLastWeek,
|
||||
clicked_at: new Date(reviewedLastWeek.getTime() + 2 * 60 * 60 * 1000),
|
||||
reviewed_at: new Date(reviewedLastWeek.getTime() + 4 * 60 * 60 * 1000),
|
||||
submitted_at: new Date(reviewedLastWeek.getTime() + 4 * 60 * 60 * 1000),
|
||||
email_subject: 'How did our delivery team do?',
|
||||
email_body: 'Hi Ridgeway Parts Ops,\n\nThanks for your order. Please rate your delivery experience using the hosted review form.\n\nLeave a review: /review/demo-pro-reviewed-token\n\nThank you,\nHarbor Freight Logistics',
|
||||
review_link: '/review/demo-pro-reviewed-token',
|
||||
tracking_token: 'demo-pro-reviewed-token',
|
||||
review_platform: 'shopify_hosted',
|
||||
review_rating: 5,
|
||||
review_title: 'Dependable delivery team',
|
||||
review_content: 'The driver arrived on schedule and the shipment tracking updates were clear.',
|
||||
reviewer_display_name: 'Ridgeway Parts Ops',
|
||||
createdById: demoUserId,
|
||||
updatedById: demoUserId,
|
||||
createdAt: reviewedLastWeek,
|
||||
updatedAt: now,
|
||||
},
|
||||
]);
|
||||
|
||||
await queryInterface.bulkInsert('stripe_events', [
|
||||
{
|
||||
id: ids.eventA,
|
||||
businessId: ids.business,
|
||||
stripe_event_reference: 'evt_demo_roadway_1001',
|
||||
provider: 'stripe',
|
||||
provider_event_type: 'payment_intent.succeeded',
|
||||
event_type: 'payment_intent_succeeded',
|
||||
received_at: paidAtA,
|
||||
processed: true,
|
||||
processed_at: paidAtA,
|
||||
payload_json: JSON.stringify({ demo: true, provider: 'stripe', amount: 1280.5 }),
|
||||
createdById: demoUserId,
|
||||
updatedById: demoUserId,
|
||||
createdAt: paidAtA,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: ids.eventB,
|
||||
businessId: ids.business,
|
||||
stripe_event_reference: 'WH-DEMO-EVERGREEN-2044',
|
||||
provider: 'paypal',
|
||||
provider_event_type: 'PAYMENT.CAPTURE.COMPLETED',
|
||||
event_type: 'charge_succeeded',
|
||||
received_at: paidAtB,
|
||||
processed: true,
|
||||
processed_at: paidAtB,
|
||||
payload_json: JSON.stringify({ demo: true, provider: 'paypal', amount: 845 }),
|
||||
createdById: demoUserId,
|
||||
updatedById: demoUserId,
|
||||
createdAt: paidAtB,
|
||||
updatedAt: now,
|
||||
},
|
||||
]);
|
||||
|
||||
await queryInterface.bulkInsert('email_delivery_logs', [
|
||||
{
|
||||
id: ids.emailLogA,
|
||||
review_requestId: ids.requestB,
|
||||
provider: 'smtp',
|
||||
provider_message_reference: 'smtp-demo-clicked-request',
|
||||
delivery_status: 'delivered',
|
||||
queued_at: sentYesterday,
|
||||
sent_at: sentYesterday,
|
||||
delivered_at: new Date(sentYesterday.getTime() + 8 * 60 * 1000),
|
||||
to_email: 'dispatch@evergreenretail.example',
|
||||
from_email: 'ReviewFlow <app@flatlogic.app>',
|
||||
subject: 'Thanks from Harbor Freight Logistics',
|
||||
createdById: demoUserId,
|
||||
updatedById: demoUserId,
|
||||
createdAt: sentYesterday,
|
||||
updatedAt: now,
|
||||
},
|
||||
]);
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.bulkDelete('email_delivery_logs', { id: ids.emailLogA });
|
||||
await queryInterface.bulkDelete('stripe_events', { id: { [Sequelize.Op.in]: [ids.eventA, ids.eventB] } });
|
||||
await queryInterface.bulkDelete('review_requests', { id: { [Sequelize.Op.in]: [ids.requestA, ids.requestB, ids.requestC] } });
|
||||
await queryInterface.bulkDelete('transactions', { id: { [Sequelize.Op.in]: [ids.transactionA, ids.transactionB] } });
|
||||
await queryInterface.bulkDelete('customers', { id: { [Sequelize.Op.in]: [ids.customerA, ids.customerB, ids.customerC] } });
|
||||
await queryInterface.bulkDelete('businesses', { id: ids.business });
|
||||
await queryInterface.bulkDelete('users', { email: 'pro@reviewflow.demo' });
|
||||
},
|
||||
};
|
||||
@ -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, '"')
|
||||
.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) => `
|
||||
<article class="review-card">
|
||||
<div class="stars" aria-label="${escapeHtml(review.rating)} out of 5 stars">${'★'.repeat(Number(review.rating) || 5)}</div>
|
||||
<h3>${escapeHtml(review.title || 'Great experience')}</h3>
|
||||
<p>${escapeHtml(review.content)}</p>
|
||||
<footer>${escapeHtml(review.reviewer)} · ${escapeHtml(review.source)}</footer>
|
||||
</article>
|
||||
`).join('')
|
||||
: '<div class="empty">Fresh reviews will appear here after customers submit them.</div>';
|
||||
|
||||
res.status(200).type('html').send(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>
|
||||
:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||
body { margin: 0; background: transparent; color: #0f172a; }
|
||||
.widget { border: 1px solid #e2e8f0; border-radius: 18px; padding: 18px; background: #fff; box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08); }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
||||
.eyebrow { color: #059669; font-size: 11px; font-weight: 900; letter-spacing: .22em; text-transform: uppercase; }
|
||||
h2 { margin: 4px 0 0; font-size: 20px; line-height: 1.2; }
|
||||
.badge { border-radius: 999px; background: #ecfdf5; color: #047857; font-size: 12px; font-weight: 800; padding: 6px 10px; white-space: nowrap; }
|
||||
.reviews { display: grid; gap: 12px; }
|
||||
.review-card { border: 1px solid #e2e8f0; border-radius: 14px; padding: 14px; background: #f8fafc; }
|
||||
.stars { color: #f59e0b; letter-spacing: .08em; font-size: 14px; }
|
||||
h3 { margin: 8px 0 6px; font-size: 16px; }
|
||||
p { margin: 0; color: #475569; line-height: 1.55; }
|
||||
footer { margin-top: 10px; color: #64748b; font-size: 12px; font-weight: 700; }
|
||||
.empty { border: 1px dashed #cbd5e1; border-radius: 14px; padding: 18px; color: #64748b; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<section class="widget" aria-label="Customer reviews for ${escapeHtml(widget.business.name)}">
|
||||
<div class="header">
|
||||
<div>
|
||||
<div class="eyebrow">Verified reviews</div>
|
||||
<h2>${escapeHtml(widget.business.name)}</h2>
|
||||
</div>
|
||||
<div class="badge">Powered by Review Flow</div>
|
||||
</div>
|
||||
<div class="reviews">${reviewsHtml}</div>
|
||||
</section>
|
||||
</body>
|
||||
</html>`);
|
||||
}));
|
||||
|
||||
router.use('/', require('../helpers').commonErrorHandler);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@ -54,6 +54,34 @@ function normalizeReviewDestination(value) {
|
||||
return 'google';
|
||||
}
|
||||
|
||||
function normalizeBusinessType(value, fallback = 'hybrid') {
|
||||
return ReviewFlowService.normalizeBusinessType(value, fallback);
|
||||
}
|
||||
|
||||
function parseBoolean(value, fallback = false) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return value === true || value === 'true' || value === 'on' || value === 1 || value === '1';
|
||||
}
|
||||
|
||||
function parseInteger(value, fallback, min, max) {
|
||||
const parsed = Number(value);
|
||||
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.max(min, Math.min(Math.round(parsed), max));
|
||||
}
|
||||
|
||||
async function assertProFeatureForEnabledFlag(currentUser, enabled, featureKey) {
|
||||
if (enabled) {
|
||||
await SubscriptionService.assertFeatureAccess(currentUser, featureKey);
|
||||
}
|
||||
}
|
||||
|
||||
function getReviewLinkField(reviewDestination) {
|
||||
return REVIEW_LINK_FIELDS[reviewDestination] || null;
|
||||
}
|
||||
@ -126,6 +154,7 @@ router.get('/summary', wrapAsync(async (req, res) => {
|
||||
paymentEvents,
|
||||
recentTransactions,
|
||||
recentEvents,
|
||||
businesses,
|
||||
] = await Promise.all([
|
||||
db.review_requests.count({ where: { createdById: currentUser.id, status: 'pending' } }),
|
||||
db.review_requests.count({ where: { createdById: currentUser.id, status: 'sent' } }),
|
||||
@ -151,6 +180,11 @@ router.get('/summary', wrapAsync(async (req, res) => {
|
||||
order: [['createdAt', 'DESC']],
|
||||
limit: 6,
|
||||
}),
|
||||
db.businesses.findAll({
|
||||
where: { createdById: currentUser.id },
|
||||
order: [['updatedAt', 'DESC']],
|
||||
limit: 25,
|
||||
}),
|
||||
]);
|
||||
|
||||
res.status(200).send({
|
||||
@ -158,6 +192,8 @@ router.get('/summary', wrapAsync(async (req, res) => {
|
||||
requests,
|
||||
recentTransactions,
|
||||
recentEvents,
|
||||
businesses: businesses.map((business) => ReviewFlowService.serializeBusiness(req, business)),
|
||||
primaryBusiness: businesses[0] ? ReviewFlowService.serializeBusiness(req, businesses[0]) : null,
|
||||
});
|
||||
}));
|
||||
|
||||
@ -167,6 +203,12 @@ router.post('/request', wrapAsync(async (req, res) => {
|
||||
const businessName = normalizeString(body.businessName);
|
||||
const reviewLink = normalizeString(body.reviewLink);
|
||||
const reviewDestination = normalizeReviewDestination(body.reviewDestination || body.reviewPlatform || 'google');
|
||||
const businessType = normalizeBusinessType(body.businessType || body.business_type, 'hybrid');
|
||||
if (!ReviewFlowService.isReviewDestinationAllowedForBusinessType(businessType, reviewDestination)) {
|
||||
const error = new Error('This review destination does not match the selected business type. Choose Hybrid if this business needs both local and online options.');
|
||||
error.code = 400;
|
||||
throw error;
|
||||
}
|
||||
const isHostedReviewDestination = reviewDestination === 'shopify_hosted';
|
||||
const reviewLinkField = getReviewLinkField(reviewDestination);
|
||||
const customerEmail = normalizeString(body.customerEmail).toLowerCase();
|
||||
@ -206,10 +248,12 @@ router.post('/request', wrapAsync(async (req, res) => {
|
||||
? ReviewFlowService.getHostedReviewUrl(req, trackingToken)
|
||||
: reviewLink;
|
||||
const transaction = await db.sequelize.transaction();
|
||||
let transactionCommitted = false;
|
||||
|
||||
try {
|
||||
const businessDefaults = {
|
||||
name: businessName,
|
||||
business_type: businessType,
|
||||
review_destination: reviewDestination,
|
||||
shopify_hosted_reviews_enabled: isHostedReviewDestination,
|
||||
delay_days: delayDays,
|
||||
@ -218,6 +262,7 @@ router.post('/request', wrapAsync(async (req, res) => {
|
||||
is_active: true,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
ownerId: currentUser.id,
|
||||
};
|
||||
|
||||
if (reviewLink && reviewLinkField) {
|
||||
@ -231,6 +276,7 @@ router.post('/request', wrapAsync(async (req, res) => {
|
||||
});
|
||||
|
||||
const businessUpdates = {
|
||||
business_type: businessType,
|
||||
review_destination: reviewDestination,
|
||||
shopify_hosted_reviews_enabled: business.shopify_hosted_reviews_enabled || isHostedReviewDestination,
|
||||
delay_days: delayDays,
|
||||
@ -282,6 +328,16 @@ router.post('/request', wrapAsync(async (req, res) => {
|
||||
}, { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
transactionCommitted = true;
|
||||
|
||||
let delivery = null;
|
||||
|
||||
if (scheduledFor.getTime() <= Date.now()) {
|
||||
delivery = await ReviewFlowService.processDueReviewRequests(currentUser, {
|
||||
limit: 1,
|
||||
requestId: reviewRequest.id,
|
||||
});
|
||||
}
|
||||
|
||||
const createdRequest = await db.review_requests.findByPk(reviewRequest.id, {
|
||||
include: [
|
||||
@ -290,13 +346,137 @@ router.post('/request', wrapAsync(async (req, res) => {
|
||||
],
|
||||
});
|
||||
|
||||
res.status(201).send({ request: createdRequest });
|
||||
res.status(201).send({ request: createdRequest, delivery });
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
if (!transactionCommitted) {
|
||||
await transaction.rollback();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
router.post('/automation/run-due', wrapAsync(async (req, res) => {
|
||||
await SubscriptionService.assertFeatureAccess(req.currentUser, 'set_and_forget_automation');
|
||||
const result = await ReviewFlowService.processDueReviewRequests(req.currentUser, req.body || {});
|
||||
|
||||
res.status(200).send(result);
|
||||
}));
|
||||
|
||||
router.put('/growth-tools/business', wrapAsync(async (req, res) => {
|
||||
const currentUser = req.currentUser;
|
||||
const body = req.body || {};
|
||||
const businessId = normalizeString(body.businessId || body.id);
|
||||
const businessName = normalizeString(body.businessName || body.name || 'Review Flow Business');
|
||||
let business = businessId
|
||||
? await db.businesses.findOne({ where: { id: businessId, createdById: currentUser.id } })
|
||||
: await db.businesses.findOne({ where: { name: businessName, createdById: currentUser.id } });
|
||||
|
||||
if (businessId && !business) {
|
||||
const error = new Error('Business not found for this account.');
|
||||
error.code = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!business) {
|
||||
await SubscriptionService.assertCanCreateBusinesses(currentUser, 1);
|
||||
business = await db.businesses.create({
|
||||
name: businessName,
|
||||
business_type: normalizeBusinessType(body.businessType || body.business_type, 'hybrid'),
|
||||
automation_mode: 'set_and_forget',
|
||||
is_active: true,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
ownerId: currentUser.id,
|
||||
});
|
||||
}
|
||||
|
||||
const businessType = normalizeBusinessType(body.businessType || body.business_type, business.business_type || 'hybrid');
|
||||
const reviewDestination = normalizeReviewDestination(body.reviewDestination || body.review_destination || business.review_destination || 'google');
|
||||
|
||||
if (!ReviewFlowService.isReviewDestinationAllowedForBusinessType(businessType, reviewDestination)) {
|
||||
const error = new Error('This review destination does not match the selected business type. Choose Hybrid if this business needs both local and online options.');
|
||||
error.code = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const aiReplyEnabled = parseBoolean(body.aiReplyEnabled ?? body.ai_reply_enabled, business.ai_reply_enabled);
|
||||
const referralEnabled = parseBoolean(body.referralEnabled ?? body.referral_enabled, business.referral_enabled);
|
||||
const npsEnabled = parseBoolean(body.npsEnabled ?? body.nps_enabled, business.nps_enabled);
|
||||
const broadcastEnabled = parseBoolean(body.broadcastEnabled ?? body.broadcast_enabled, business.broadcast_enabled);
|
||||
const rebookingEnabled = parseBoolean(body.rebookingEnabled ?? body.rebooking_enabled, business.rebooking_enabled);
|
||||
const competitorInsightsEnabled = parseBoolean(body.competitorInsightsEnabled ?? body.competitor_insights_enabled, business.competitor_insights_enabled);
|
||||
|
||||
await assertProFeatureForEnabledFlag(currentUser, aiReplyEnabled, 'ai_review_replies');
|
||||
await assertProFeatureForEnabledFlag(currentUser, referralEnabled, 'referral_campaigns');
|
||||
await assertProFeatureForEnabledFlag(currentUser, npsEnabled, 'nps_surveys');
|
||||
await assertProFeatureForEnabledFlag(currentUser, broadcastEnabled, 'marketing_broadcasts');
|
||||
await assertProFeatureForEnabledFlag(currentUser, rebookingEnabled, 'rebooking_campaigns');
|
||||
await assertProFeatureForEnabledFlag(currentUser, competitorInsightsEnabled, 'competitor_insights');
|
||||
|
||||
const updatePayload = {
|
||||
name: businessName || business.name,
|
||||
business_type: businessType,
|
||||
automation_mode: normalizeString(body.automationMode || body.automation_mode) || 'set_and_forget',
|
||||
review_destination: reviewDestination,
|
||||
delay_days: parseInteger(body.delayDays ?? body.delay_days, business.delay_days || 7, 0, 30),
|
||||
followup_enabled: parseBoolean(body.followupEnabled ?? body.followup_enabled, business.followup_enabled !== false),
|
||||
followup_delay_days: parseInteger(body.followupDelayDays ?? body.followup_delay_days, business.followup_delay_days || 3, 1, 30),
|
||||
max_followups: parseInteger(body.maxFollowups ?? body.max_followups, business.max_followups || 1, 0, 5),
|
||||
ai_reply_enabled: aiReplyEnabled,
|
||||
referral_enabled: referralEnabled,
|
||||
referral_offer: normalizeString(body.referralOffer ?? body.referral_offer) || business.referral_offer || '',
|
||||
nps_enabled: npsEnabled,
|
||||
nps_question: normalizeString(body.npsQuestion ?? body.nps_question) || business.nps_question || 'How likely are you to recommend us to a friend?',
|
||||
social_widget_enabled: parseBoolean(body.socialWidgetEnabled ?? body.social_widget_enabled, business.social_widget_enabled !== false),
|
||||
broadcast_enabled: broadcastEnabled,
|
||||
rebooking_enabled: rebookingEnabled,
|
||||
competitor_insights_enabled: competitorInsightsEnabled,
|
||||
competitor_urls: normalizeString(body.competitorUrls ?? body.competitor_urls) || business.competitor_urls || '',
|
||||
review_widget_theme: normalizeString(body.reviewWidgetTheme ?? body.review_widget_theme) || business.review_widget_theme || 'light',
|
||||
is_active: true,
|
||||
updatedById: currentUser.id,
|
||||
};
|
||||
|
||||
await business.update(updatePayload);
|
||||
const refreshedBusiness = await db.businesses.findByPk(business.id);
|
||||
|
||||
res.status(200).send({ business: ReviewFlowService.serializeBusiness(req, refreshedBusiness) });
|
||||
}));
|
||||
|
||||
router.post('/growth-tools/broadcast', wrapAsync(async (req, res) => {
|
||||
const campaignType = normalizeString(req.body?.campaignType || 'broadcast');
|
||||
const featureByCampaign = {
|
||||
broadcast: 'marketing_broadcasts',
|
||||
referral: 'referral_campaigns',
|
||||
nps: 'nps_surveys',
|
||||
rebooking: 'rebooking_campaigns',
|
||||
};
|
||||
|
||||
await SubscriptionService.assertFeatureAccess(req.currentUser, featureByCampaign[campaignType] || 'marketing_broadcasts');
|
||||
const result = await ReviewFlowService.queueCustomerCampaign(req.currentUser, req.body || {});
|
||||
|
||||
res.status(200).send(result);
|
||||
}));
|
||||
|
||||
router.post('/growth-tools/competitor-insights', wrapAsync(async (req, res) => {
|
||||
await SubscriptionService.assertFeatureAccess(req.currentUser, 'competitor_insights');
|
||||
const result = await ReviewFlowService.buildCompetitorInsights(req.currentUser, req.body || {});
|
||||
|
||||
res.status(200).send(result);
|
||||
}));
|
||||
|
||||
router.get('/social-widget/:businessId', wrapAsync(async (req, res) => {
|
||||
await SubscriptionService.assertFeatureAccess(req.currentUser, 'social_proof_widgets');
|
||||
const result = await ReviewFlowService.getSocialWidgetReviews(req.params.businessId, req.query || {});
|
||||
const origin = `${req.protocol}://${req.get('host')}`;
|
||||
|
||||
res.status(200).send({
|
||||
...result,
|
||||
embedCode: `<iframe src="${origin}/api/reviewflow-public/widgets/${req.params.businessId}" style="width:100%;border:0;min-height:320px;border-radius:16px;" loading="lazy"></iframe>`,
|
||||
});
|
||||
}));
|
||||
|
||||
router.use('/', require('../helpers').commonErrorHandler);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@ -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);
|
||||
}));
|
||||
|
||||
@ -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, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function textToHtml(value) {
|
||||
return escapeHtml(value)
|
||||
.split('\n')
|
||||
.map((line) => line || ' ')
|
||||
.join('<br />');
|
||||
}
|
||||
|
||||
function buildReviewRequestEmail(request, toEmail) {
|
||||
const businessName = request.business?.name || 'Review Flow';
|
||||
const customerName = request.customer?.name || 'there';
|
||||
const reviewLink = request.review_link || '';
|
||||
const body = request.email_body || buildEmailBody(customerName, businessName, reviewLink);
|
||||
|
||||
return {
|
||||
to: toEmail,
|
||||
subject: request.email_subject || `How was your experience with ${businessName}?`,
|
||||
html: async () => `
|
||||
<div style="font-family:Arial,sans-serif;color:#0f172a;line-height:1.6;max-width:640px;margin:0 auto;padding:24px;">
|
||||
<div style="border:1px solid #e2e8f0;border-radius:18px;padding:24px;background:#ffffff;">
|
||||
<div style="font-size:15px;">${textToHtml(body)}</div>
|
||||
${reviewLink ? `<p style="margin-top:24px;"><a href="${escapeHtml(reviewLink)}" style="display:inline-block;background:#4f46e5;color:#ffffff;text-decoration:none;padding:12px 18px;border-radius:999px;font-weight:700;">Leave a review</a></p>` : ''}
|
||||
</div>
|
||||
<p style="font-size:12px;color:#64748b;margin-top:16px;">Sent by Review Flow for ${escapeHtml(businessName)}.</p>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
function getSmsConfig() {
|
||||
return {
|
||||
accountSid: process.env.TWILIO_ACCOUNT_SID || '',
|
||||
authToken: process.env.TWILIO_AUTH_TOKEN || '',
|
||||
fromNumber: process.env.TWILIO_FROM_NUMBER || process.env.SMS_FROM_NUMBER || '',
|
||||
};
|
||||
}
|
||||
|
||||
function isSmsConfigured() {
|
||||
const smsConfig = getSmsConfig();
|
||||
return Boolean(smsConfig.accountSid && smsConfig.authToken && smsConfig.fromNumber);
|
||||
}
|
||||
|
||||
async function sendReviewRequestSms(request) {
|
||||
const toPhone = normalizeString(request.customer?.phone);
|
||||
|
||||
if (!toPhone) {
|
||||
return { channel: 'sms', status: 'skipped', reason: 'No customer phone number was provided.' };
|
||||
}
|
||||
|
||||
if (!isSmsConfigured()) {
|
||||
return {
|
||||
channel: 'sms',
|
||||
status: 'skipped',
|
||||
to: toPhone,
|
||||
reason: 'SMS provider is not configured. Set TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_FROM_NUMBER to enable SMS delivery.',
|
||||
};
|
||||
}
|
||||
|
||||
const smsConfig = getSmsConfig();
|
||||
const businessName = request.business?.name || 'our business';
|
||||
const reviewLink = request.review_link || '';
|
||||
const message = `Thanks for choosing ${businessName}. Please leave a review: ${reviewLink}`.slice(0, 1500);
|
||||
const payload = new URLSearchParams({
|
||||
To: toPhone,
|
||||
From: smsConfig.fromNumber,
|
||||
Body: message,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`https://api.twilio.com/2010-04-01/Accounts/${encodeURIComponent(smsConfig.accountSid)}/Messages.json`,
|
||||
payload,
|
||||
{
|
||||
auth: {
|
||||
username: smsConfig.accountSid,
|
||||
password: smsConfig.authToken,
|
||||
},
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
timeout: 15000,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
channel: 'sms',
|
||||
status: 'sent',
|
||||
to: toPhone,
|
||||
providerMessageReference: response.data?.sid || null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Review Flow SMS delivery failed:', {
|
||||
requestId: request.id,
|
||||
to: toPhone,
|
||||
message: error.message,
|
||||
response: error.response?.data,
|
||||
});
|
||||
|
||||
return {
|
||||
channel: 'sms',
|
||||
status: 'failed',
|
||||
to: toPhone,
|
||||
reason: error.response?.data?.message || error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function sendReviewRequestNotifications(request, currentUser, options = {}) {
|
||||
const now = options.now || new Date();
|
||||
const toEmail = normalizeEmail(request.customer?.email);
|
||||
const deliveries = [];
|
||||
|
||||
if (!EMAIL_PATTERN.test(toEmail)) {
|
||||
const error = new Error(`Missing customer email for request ${request.id}`);
|
||||
error.code = 'missing_customer_email';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const emailLog = await db.email_delivery_logs.create({
|
||||
provider: EmailSender.isConfigured ? 'smtp' : 'unknown',
|
||||
provider_message_reference: `reviewflow-${request.id}`,
|
||||
delivery_status: 'queued',
|
||||
queued_at: now,
|
||||
to_email: toEmail,
|
||||
from_email: config.email?.from || 'ReviewFlow <app@flatlogic.app>',
|
||||
subject: request.email_subject,
|
||||
review_requestId: request.id,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
});
|
||||
|
||||
if (!EmailSender.isConfigured) {
|
||||
const reason = 'Email provider is not configured. Set EMAIL_USER and EMAIL_PASS to enable SMTP delivery.';
|
||||
await emailLog.update({
|
||||
delivery_status: 'failed',
|
||||
error_details: reason,
|
||||
updatedById: currentUser.id,
|
||||
});
|
||||
const error = new Error(reason);
|
||||
error.code = 'email_provider_not_configured';
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const emailResult = await new EmailSender(buildReviewRequestEmail(request, toEmail)).send();
|
||||
await emailLog.update({
|
||||
provider: 'smtp',
|
||||
provider_message_reference: emailResult?.messageId || emailLog.provider_message_reference,
|
||||
delivery_status: 'sent',
|
||||
sent_at: new Date(),
|
||||
updatedById: currentUser.id,
|
||||
});
|
||||
deliveries.push({
|
||||
channel: 'email',
|
||||
status: 'sent',
|
||||
to: toEmail,
|
||||
providerMessageReference: emailResult?.messageId || null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Review Flow email delivery failed:', {
|
||||
requestId: request.id,
|
||||
to: toEmail,
|
||||
message: error.message,
|
||||
});
|
||||
await emailLog.update({
|
||||
delivery_status: 'failed',
|
||||
error_details: error.message,
|
||||
updatedById: currentUser.id,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const smsDelivery = await sendReviewRequestSms(request);
|
||||
deliveries.push(smsDelivery);
|
||||
|
||||
return deliveries;
|
||||
}
|
||||
|
||||
function renderTemplate(template, replacements) {
|
||||
if (!template) {
|
||||
return '';
|
||||
@ -672,6 +920,22 @@ function serializeBusiness(req, business) {
|
||||
return {
|
||||
id: business.id,
|
||||
name: business.name,
|
||||
business_type: normalizeBusinessType(business.business_type),
|
||||
automation_mode: business.automation_mode || 'set_and_forget',
|
||||
followup_enabled: business.followup_enabled !== false,
|
||||
followup_delay_days: business.followup_delay_days ?? 3,
|
||||
max_followups: business.max_followups ?? 1,
|
||||
ai_reply_enabled: Boolean(business.ai_reply_enabled),
|
||||
referral_enabled: Boolean(business.referral_enabled),
|
||||
referral_offer: business.referral_offer || '',
|
||||
nps_enabled: Boolean(business.nps_enabled),
|
||||
nps_question: business.nps_question || '',
|
||||
social_widget_enabled: business.social_widget_enabled !== false,
|
||||
broadcast_enabled: Boolean(business.broadcast_enabled),
|
||||
rebooking_enabled: Boolean(business.rebooking_enabled),
|
||||
competitor_insights_enabled: Boolean(business.competitor_insights_enabled),
|
||||
competitor_urls: business.competitor_urls || '',
|
||||
review_widget_theme: business.review_widget_theme || 'light',
|
||||
google_review_link: business.google_review_link,
|
||||
yelp_review_link: business.yelp_review_link,
|
||||
facebook_review_link: business.facebook_review_link,
|
||||
@ -738,6 +1002,10 @@ async function connectProvider(currentUser, body, req) {
|
||||
business = await db.businesses.findOne({ where: { name: businessName, createdById: currentUser.id } });
|
||||
}
|
||||
|
||||
const businessType = normalizeBusinessType(body.businessType || body.business_type, business?.business_type || 'hybrid');
|
||||
assertProviderAllowedForBusinessType(businessType, config.provider);
|
||||
assertReviewDestinationAllowedForBusinessType(businessType, reviewDestination);
|
||||
|
||||
if (reviewLink) {
|
||||
validateUrl(reviewLink, 'Enter a valid review page URL before connecting a webhook.');
|
||||
}
|
||||
@ -751,6 +1019,7 @@ async function connectProvider(currentUser, body, req) {
|
||||
|
||||
const createPayload = {
|
||||
name: businessName,
|
||||
business_type: businessType,
|
||||
review_destination: reviewDestination,
|
||||
shopify_hosted_reviews_enabled: Boolean(config.hostedReviewProvider || reviewDestination === 'shopify_hosted'),
|
||||
delay_days: delayDays,
|
||||
@ -771,6 +1040,7 @@ async function connectProvider(currentUser, body, req) {
|
||||
|
||||
const updates = {
|
||||
is_active: true,
|
||||
business_type: businessType,
|
||||
delay_days: delayDays,
|
||||
review_destination: reviewDestination,
|
||||
shopify_hosted_reviews_enabled: Boolean(
|
||||
@ -797,6 +1067,238 @@ async function connectProvider(currentUser, body, req) {
|
||||
return serializeBusiness(req, refreshedBusiness);
|
||||
}
|
||||
|
||||
async function getSocialWidgetReviews(businessId, options = {}) {
|
||||
const business = await db.businesses.findByPk(businessId);
|
||||
|
||||
if (!business || business.social_widget_enabled === false) {
|
||||
throw httpError('Review widget is not enabled for this business.', 404);
|
||||
}
|
||||
|
||||
const reviews = await db.review_requests.findAll({
|
||||
where: {
|
||||
businessId: business.id,
|
||||
status: 'reviewed',
|
||||
review_rating: { [db.Sequelize.Op.gte]: Number(options.minimumRating) || 4 },
|
||||
},
|
||||
include: [
|
||||
{ model: db.customers, as: 'customer' },
|
||||
{ model: db.transactions, as: 'transaction' },
|
||||
],
|
||||
order: [['reviewed_at', 'DESC'], ['updatedAt', 'DESC']],
|
||||
limit: Math.min(Number(options.limit) || 8, 25),
|
||||
});
|
||||
|
||||
return {
|
||||
business: {
|
||||
id: business.id,
|
||||
name: business.name,
|
||||
business_type: normalizeBusinessType(business.business_type),
|
||||
review_widget_theme: business.review_widget_theme || 'light',
|
||||
},
|
||||
reviews: reviews.map((review) => ({
|
||||
id: review.id,
|
||||
rating: review.review_rating,
|
||||
title: review.review_title || '',
|
||||
content: review.review_content || '',
|
||||
reviewer: review.reviewer_display_name || review.customer?.name || 'Verified customer',
|
||||
reviewed_at: review.reviewed_at || review.submitted_at || review.updatedAt,
|
||||
source: review.review_platform || 'Review Flow',
|
||||
transaction: review.transaction ? {
|
||||
payment_provider: review.transaction.payment_provider,
|
||||
description: review.transaction.description,
|
||||
} : null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function processDueReviewRequests(currentUser, options = {}) {
|
||||
const now = new Date();
|
||||
const limit = Math.min(Number(options.limit) || 50, 200);
|
||||
const where = {
|
||||
createdById: currentUser.id,
|
||||
status: 'pending',
|
||||
scheduled_for: { [db.Sequelize.Op.lte]: now },
|
||||
};
|
||||
|
||||
if (options.requestId) {
|
||||
where.id = options.requestId;
|
||||
}
|
||||
|
||||
const dueRequests = await db.review_requests.findAll({
|
||||
where,
|
||||
include: [
|
||||
{ model: db.businesses, as: 'business' },
|
||||
{ model: db.customers, as: 'customer' },
|
||||
],
|
||||
order: [['scheduled_for', 'ASC']],
|
||||
limit,
|
||||
});
|
||||
const cronRun = await db.cron_runs.create({
|
||||
job_name: 'reviewflow_set_and_forget_due_requests',
|
||||
started_at: now,
|
||||
run_status: 'skipped',
|
||||
processed_count: 0,
|
||||
sent_count: 0,
|
||||
error_count: 0,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
});
|
||||
|
||||
let sentCount = 0;
|
||||
let errorCount = 0;
|
||||
const errors = [];
|
||||
const deliveries = [];
|
||||
|
||||
for (const request of dueRequests) {
|
||||
try {
|
||||
const requestDeliveries = await sendReviewRequestNotifications(request, currentUser, { now });
|
||||
deliveries.push({ requestId: request.id, deliveries: requestDeliveries });
|
||||
|
||||
await request.update({
|
||||
status: 'sent',
|
||||
sent_at: new Date(),
|
||||
failure_reason: null,
|
||||
updatedById: currentUser.id,
|
||||
});
|
||||
sentCount += 1;
|
||||
} catch (error) {
|
||||
console.error('Review Flow request delivery failed:', {
|
||||
requestId: request.id,
|
||||
message: error.message,
|
||||
});
|
||||
errorCount += 1;
|
||||
errors.push(error.message);
|
||||
deliveries.push({
|
||||
requestId: request.id,
|
||||
deliveries: [{ channel: 'email', status: 'failed', reason: error.message }],
|
||||
});
|
||||
await request.update({
|
||||
status: 'failed',
|
||||
failure_reason: error.message,
|
||||
updatedById: currentUser.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await cronRun.update({
|
||||
finished_at: new Date(),
|
||||
run_status: errorCount && sentCount ? 'partial' : errorCount ? 'failed' : dueRequests.length ? 'success' : 'skipped',
|
||||
processed_count: dueRequests.length,
|
||||
sent_count: sentCount,
|
||||
error_count: errorCount,
|
||||
error_summary: errors.slice(0, 10).join('\n') || null,
|
||||
updatedById: currentUser.id,
|
||||
});
|
||||
|
||||
return {
|
||||
processed: dueRequests.length,
|
||||
sent: sentCount,
|
||||
failed: errorCount,
|
||||
errors,
|
||||
deliveries,
|
||||
cronRunId: cronRun.id,
|
||||
};
|
||||
}
|
||||
|
||||
async function queueCustomerCampaign(currentUser, body = {}) {
|
||||
const businessId = normalizeString(body.businessId);
|
||||
const subject = normalizeString(body.subject).slice(0, 200);
|
||||
const message = normalizeString(body.message).slice(0, 5000);
|
||||
const campaignType = normalizeString(body.campaignType || 'broadcast') || 'broadcast';
|
||||
|
||||
requireField(subject, 'Campaign subject is required.');
|
||||
requireField(message, 'Campaign message is required.');
|
||||
|
||||
const where = { createdById: currentUser.id };
|
||||
|
||||
if (businessId) {
|
||||
where.businessId = businessId;
|
||||
}
|
||||
|
||||
const customers = await db.customers.findAll({
|
||||
where,
|
||||
order: [['updatedAt', 'DESC']],
|
||||
limit: Math.min(Number(body.limit) || 250, 500),
|
||||
});
|
||||
const deliverableCustomers = customers.filter((customer) => EMAIL_PATTERN.test(normalizeEmail(customer.email)));
|
||||
const now = new Date();
|
||||
|
||||
if (!deliverableCustomers.length) {
|
||||
return {
|
||||
queued: 0,
|
||||
skipped: customers.length,
|
||||
campaignType,
|
||||
message: 'No customers with valid email addresses were found for this campaign.',
|
||||
};
|
||||
}
|
||||
|
||||
await db.email_delivery_logs.bulkCreate(deliverableCustomers.map((customer) => ({
|
||||
provider: 'unknown',
|
||||
provider_message_reference: `${campaignType}-${customer.id}-${now.getTime()}`,
|
||||
delivery_status: 'queued',
|
||||
queued_at: now,
|
||||
to_email: normalizeEmail(customer.email),
|
||||
from_email: 'reviews@reviewflow.local',
|
||||
subject,
|
||||
error_details: `Queued ${campaignType} campaign from Growth Tools. Message: ${message}`,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
})));
|
||||
|
||||
return {
|
||||
queued: deliverableCustomers.length,
|
||||
skipped: customers.length - deliverableCustomers.length,
|
||||
campaignType,
|
||||
message: `${deliverableCustomers.length} ${campaignType} messages were queued for provider handoff.`,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildCompetitorInsights(currentUser, body = {}) {
|
||||
const businessId = normalizeString(body.businessId);
|
||||
const competitorUrls = normalizeString(body.competitorUrls || body.competitor_urls);
|
||||
|
||||
requireField(competitorUrls, 'Add at least one competitor URL or name.');
|
||||
|
||||
const business = businessId
|
||||
? await db.businesses.findOne({ where: { id: businessId, createdById: currentUser.id } })
|
||||
: await db.businesses.findOne({ where: { createdById: currentUser.id }, order: [['updatedAt', 'DESC']] });
|
||||
|
||||
if (!business) {
|
||||
throw httpError('Create a business profile before saving competitor insights.', 400);
|
||||
}
|
||||
|
||||
await business.update({
|
||||
competitor_urls: competitorUrls,
|
||||
competitor_insights_enabled: true,
|
||||
updatedById: currentUser.id,
|
||||
});
|
||||
|
||||
const [reviewed, pending, sent, customers] = await Promise.all([
|
||||
db.review_requests.count({ where: { createdById: currentUser.id, status: 'reviewed' } }),
|
||||
db.review_requests.count({ where: { createdById: currentUser.id, status: 'pending' } }),
|
||||
db.review_requests.count({ where: { createdById: currentUser.id, status: 'sent' } }),
|
||||
db.customers.count({ where: { createdById: currentUser.id } }),
|
||||
]);
|
||||
const competitors = competitorUrls.split('\n').map((item) => item.trim()).filter(Boolean).slice(0, 8);
|
||||
|
||||
return {
|
||||
business: serializeBusiness({ get: () => '', protocol: 'https', headers: {} }, business),
|
||||
competitors,
|
||||
metrics: { reviewed, pending, sent, customers },
|
||||
recommendations: [
|
||||
reviewed < 10
|
||||
? 'Prioritize review volume first: keep automation on until you have at least 10 fresh testimonials for widgets and sales pages.'
|
||||
: 'You have enough reviewed feedback to rotate testimonials into your social proof widget.',
|
||||
pending > sent
|
||||
? 'There are more pending than sent requests. Run the set-it-and-forget-it processor or check email provider setup.'
|
||||
: 'Your queue is moving. Keep request timing consistent so competitors cannot outpace your freshness.',
|
||||
competitors.length > 3
|
||||
? 'Track the top 3 competitors most often mentioned by prospects so the dashboard stays uncluttered.'
|
||||
: 'Add 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,
|
||||
|
||||
@ -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',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@ -109,7 +109,7 @@ module.exports = class UsersService {
|
||||
try {
|
||||
let users = await UsersDBApi.findBy(
|
||||
{id},
|
||||
{transaction},
|
||||
{transaction, currentUser},
|
||||
);
|
||||
|
||||
if (!users) {
|
||||
|
||||
@ -13,6 +13,8 @@ import CardBox from '../CardBox';
|
||||
import FormField from '../FormField';
|
||||
import { getBusinessProfileUsageLabel } from '../../helpers/businessPlanLabels';
|
||||
|
||||
type BusinessType = 'local' | 'online' | 'hybrid';
|
||||
|
||||
export interface ProviderConnector {
|
||||
key: 'stripe' | 'square' | 'paypal' | 'shopify' | 'woocommerce' | string;
|
||||
label: string;
|
||||
@ -30,6 +32,7 @@ export interface ProviderConnector {
|
||||
export interface ConnectorBusiness {
|
||||
id: string;
|
||||
name?: string;
|
||||
business_type?: BusinessType;
|
||||
google_review_link?: string;
|
||||
yelp_review_link?: string;
|
||||
facebook_review_link?: string;
|
||||
@ -45,6 +48,7 @@ export interface ConnectorBusiness {
|
||||
|
||||
export interface ConnectorFormValues {
|
||||
provider: string;
|
||||
businessType: BusinessType;
|
||||
businessName: string;
|
||||
reviewDestination: string;
|
||||
reviewLink: string;
|
||||
@ -57,6 +61,7 @@ interface PaymentProviderConnectorsProps {
|
||||
eyebrow?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
initialBusinessType?: BusinessType;
|
||||
onConnected?: (
|
||||
business: ConnectorBusiness,
|
||||
connectorForm: ConnectorFormValues,
|
||||
@ -82,6 +87,7 @@ type ConnectorSubscriptionStatus = {
|
||||
|
||||
const connectorDefaults: ConnectorFormValues = {
|
||||
provider: 'stripe',
|
||||
businessType: 'hybrid',
|
||||
businessName: 'Review Flow Studio',
|
||||
reviewDestination: 'google',
|
||||
reviewLink: 'https://g.page/r/example/review',
|
||||
@ -95,6 +101,7 @@ const providerOptions = [
|
||||
label: 'Stripe',
|
||||
categoryLabel: 'Payment trigger',
|
||||
defaultReviewDestination: 'google',
|
||||
businessTypes: ['local', 'online', 'hybrid'],
|
||||
description: 'Connect card and checkout payments from Stripe.',
|
||||
},
|
||||
{
|
||||
@ -102,6 +109,7 @@ const providerOptions = [
|
||||
label: 'PayPal',
|
||||
categoryLabel: 'Payment trigger',
|
||||
defaultReviewDestination: 'google',
|
||||
businessTypes: ['local', 'online', 'hybrid'],
|
||||
description: 'Connect completed PayPal captures and sales.',
|
||||
},
|
||||
{
|
||||
@ -109,6 +117,7 @@ const providerOptions = [
|
||||
label: 'Square',
|
||||
categoryLabel: 'Payment trigger',
|
||||
defaultReviewDestination: 'google',
|
||||
businessTypes: ['local', 'hybrid'],
|
||||
description: 'Connect Square payment notifications.',
|
||||
},
|
||||
{
|
||||
@ -116,6 +125,7 @@ const providerOptions = [
|
||||
label: 'Shopify',
|
||||
categoryLabel: 'Ecommerce order trigger + hosted reviews',
|
||||
defaultReviewDestination: 'shopify_hosted',
|
||||
businessTypes: ['online', 'hybrid'],
|
||||
description:
|
||||
'Connect paid Shopify orders; customers review products on a hosted Review Flow form.',
|
||||
},
|
||||
@ -124,6 +134,7 @@ const providerOptions = [
|
||||
label: 'WooCommerce',
|
||||
categoryLabel: 'Ecommerce order trigger',
|
||||
defaultReviewDestination: 'trustpilot',
|
||||
businessTypes: ['online', 'hybrid'],
|
||||
description: 'Connect WooCommerce orders from your WordPress store.',
|
||||
},
|
||||
];
|
||||
@ -133,6 +144,7 @@ const reviewDestinationOptions = [
|
||||
key: 'google',
|
||||
label: 'Google',
|
||||
group: 'Local review destinations',
|
||||
scope: 'local',
|
||||
mode: 'external_link',
|
||||
description: 'For local businesses collecting Google profile reviews.',
|
||||
},
|
||||
@ -140,6 +152,7 @@ const reviewDestinationOptions = [
|
||||
key: 'facebook',
|
||||
label: 'Facebook',
|
||||
group: 'Local review destinations',
|
||||
scope: 'local',
|
||||
mode: 'external_link',
|
||||
description: 'For local Facebook recommendations and reviews.',
|
||||
},
|
||||
@ -147,6 +160,7 @@ const reviewDestinationOptions = [
|
||||
key: 'yelp',
|
||||
label: 'Yelp',
|
||||
group: 'Local review destinations',
|
||||
scope: 'local',
|
||||
mode: 'external_link',
|
||||
description: 'For local-service Yelp review requests.',
|
||||
},
|
||||
@ -154,6 +168,7 @@ const reviewDestinationOptions = [
|
||||
key: 'angi',
|
||||
label: 'Angi',
|
||||
group: 'Local review destinations',
|
||||
scope: 'local',
|
||||
mode: 'external_link',
|
||||
description: 'For home-service Angi profile review requests.',
|
||||
},
|
||||
@ -161,6 +176,7 @@ const reviewDestinationOptions = [
|
||||
key: 'opentable',
|
||||
label: 'OpenTable',
|
||||
group: 'Local review destinations',
|
||||
scope: 'local',
|
||||
mode: 'external_link',
|
||||
description: 'For restaurant guests leaving OpenTable reviews.',
|
||||
},
|
||||
@ -168,6 +184,7 @@ const reviewDestinationOptions = [
|
||||
key: 'shopify_hosted',
|
||||
label: 'Shopify hosted product review',
|
||||
group: 'Ecommerce review destinations',
|
||||
scope: 'online',
|
||||
mode: 'hosted_form',
|
||||
description:
|
||||
'Review Flow hosts the product review form after a Shopify paid order.',
|
||||
@ -176,6 +193,7 @@ const reviewDestinationOptions = [
|
||||
key: 'trustpilot',
|
||||
label: 'Trustpilot',
|
||||
group: 'Ecommerce review destinations',
|
||||
scope: 'online',
|
||||
mode: 'external_link',
|
||||
description: 'For ecommerce brand/store review invitations.',
|
||||
},
|
||||
@ -183,6 +201,7 @@ const reviewDestinationOptions = [
|
||||
key: 'custom',
|
||||
label: 'Custom review page',
|
||||
group: 'Custom destination',
|
||||
scope: 'hybrid',
|
||||
mode: 'external_link',
|
||||
description: 'Use any review page you control.',
|
||||
},
|
||||
@ -203,6 +222,85 @@ const reviewDestinationGroups = [
|
||||
},
|
||||
];
|
||||
|
||||
const businessTypeOptions: Array<{
|
||||
key: BusinessType;
|
||||
label: string;
|
||||
help: string;
|
||||
}> = [
|
||||
{
|
||||
key: 'local',
|
||||
label: 'Local / service',
|
||||
help: 'Keeps payment triggers focused on local review destinations.',
|
||||
},
|
||||
{
|
||||
key: 'online',
|
||||
label: 'Online / ecommerce',
|
||||
help: 'Keeps order triggers focused on ecommerce review destinations.',
|
||||
},
|
||||
{
|
||||
key: 'hybrid',
|
||||
label: 'Hybrid',
|
||||
help: 'Shows both local and online triggers for mixed businesses.',
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeBusinessType(value?: string): BusinessType {
|
||||
if (value === 'local' || value === 'online' || value === 'hybrid') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return 'hybrid';
|
||||
}
|
||||
|
||||
function destinationAllowedForBusinessType(
|
||||
businessType: BusinessType,
|
||||
destination: (typeof reviewDestinationOptions)[number],
|
||||
) {
|
||||
if (businessType === 'hybrid' || destination.scope === 'hybrid') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return destination.scope === businessType;
|
||||
}
|
||||
|
||||
function getReviewDestinationsForBusinessType(businessType: BusinessType) {
|
||||
return reviewDestinationOptions.filter((destination) =>
|
||||
destinationAllowedForBusinessType(businessType, destination),
|
||||
);
|
||||
}
|
||||
|
||||
function getDefaultReviewDestinationForBusinessType(businessType: BusinessType) {
|
||||
if (businessType === 'online') return 'shopify_hosted';
|
||||
return 'google';
|
||||
}
|
||||
|
||||
function getAllowedReviewDestination(
|
||||
businessType: BusinessType,
|
||||
reviewDestination?: string,
|
||||
) {
|
||||
const allowedDestinations = getReviewDestinationsForBusinessType(businessType);
|
||||
const isAllowed = allowedDestinations.some(
|
||||
(destination) => destination.key === reviewDestination,
|
||||
);
|
||||
|
||||
return isAllowed
|
||||
? reviewDestination || allowedDestinations[0].key
|
||||
: getDefaultReviewDestinationForBusinessType(businessType);
|
||||
}
|
||||
|
||||
function providerAllowedForBusinessType(
|
||||
businessType: BusinessType,
|
||||
provider: (typeof providerOptions)[number],
|
||||
) {
|
||||
return provider.businessTypes.includes(businessType);
|
||||
}
|
||||
|
||||
function getProvidersForBusinessType(businessType: BusinessType) {
|
||||
return providerOptions.filter((provider) =>
|
||||
providerAllowedForBusinessType(businessType, provider),
|
||||
);
|
||||
}
|
||||
|
||||
const providerInstructions: Record<string, string[]> = {
|
||||
stripe: [
|
||||
'Stripe Dashboard → Developers → Webhooks → Add endpoint.',
|
||||
@ -547,6 +645,7 @@ export default function PaymentProviderConnectors({
|
||||
eyebrow = 'Order triggers and review destinations',
|
||||
title = 'Connect payment/ecommerce triggers without mixing local review channels',
|
||||
description = 'Payment and ecommerce providers trigger review requests. Review destinations decide where customers leave feedback: local profiles, ecommerce review links, or the hosted Shopify product-review form.',
|
||||
initialBusinessType = 'hybrid',
|
||||
onConnected,
|
||||
}: PaymentProviderConnectorsProps) {
|
||||
const [connectorForm, setConnectorForm] =
|
||||
@ -561,10 +660,13 @@ export default function PaymentProviderConnectors({
|
||||
const [subscriptionStatus, setSubscriptionStatus] =
|
||||
useState<ConnectorSubscriptionStatus | null>(null);
|
||||
|
||||
const currentBusinessType = normalizeBusinessType(connectorForm.businessType);
|
||||
const filteredProviderOptions = getProvidersForBusinessType(currentBusinessType);
|
||||
const filteredReviewDestinationOptions = getReviewDestinationsForBusinessType(currentBusinessType);
|
||||
const selectedProvider =
|
||||
providerOptions.find(
|
||||
filteredProviderOptions.find(
|
||||
(provider) => provider.key === connectorForm.provider,
|
||||
) || providerOptions[0];
|
||||
) || filteredProviderOptions[0];
|
||||
const selectedSetup =
|
||||
providerSetupDetails[selectedProvider.key] || providerSetupDetails.stripe;
|
||||
const selectedApiBackup =
|
||||
@ -576,9 +678,9 @@ export default function PaymentProviderConnectors({
|
||||
? 'shopify_hosted'
|
||||
: connectorForm.reviewDestination;
|
||||
const selectedReviewDestination =
|
||||
reviewDestinationOptions.find(
|
||||
filteredReviewDestinationOptions.find(
|
||||
(destination) => destination.key === effectiveReviewDestination,
|
||||
) || reviewDestinationOptions[0];
|
||||
) || filteredReviewDestinationOptions[0];
|
||||
const isHostedReviewDestination =
|
||||
selectedReviewDestination.mode === 'hosted_form';
|
||||
|
||||
@ -601,9 +703,9 @@ export default function PaymentProviderConnectors({
|
||||
|
||||
return {
|
||||
connectedCount,
|
||||
totalCount: providers.length || providerOptions.length,
|
||||
totalCount: providers.length || filteredProviderOptions.length,
|
||||
};
|
||||
}, [connectors]);
|
||||
}, [connectors, filteredProviderOptions.length]);
|
||||
|
||||
const selectedWebhookTargets = useMemo(
|
||||
() =>
|
||||
@ -627,22 +729,46 @@ export default function PaymentProviderConnectors({
|
||||
key: keyof ConnectorFormValues,
|
||||
value: string,
|
||||
) => {
|
||||
setConnectorForm((current) => ({ ...current, [key]: value }));
|
||||
setConnectorForm((current) => {
|
||||
if (key === 'businessType') {
|
||||
const businessType = normalizeBusinessType(value);
|
||||
const providers = getProvidersForBusinessType(businessType);
|
||||
const provider = providers.find(
|
||||
(providerOption) => providerOption.key === current.provider,
|
||||
) || providers[0];
|
||||
|
||||
return {
|
||||
...current,
|
||||
businessType,
|
||||
provider: provider.key,
|
||||
reviewDestination: getAllowedReviewDestination(
|
||||
businessType,
|
||||
provider.defaultReviewDestination === 'shopify_hosted'
|
||||
? 'shopify_hosted'
|
||||
: current.reviewDestination,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { ...current, [key]: value };
|
||||
});
|
||||
};
|
||||
|
||||
const updateSelectedProvider = (providerKey: string) => {
|
||||
const provider =
|
||||
providerOptions.find(
|
||||
filteredProviderOptions.find(
|
||||
(providerOption) => providerOption.key === providerKey,
|
||||
) || providerOptions[0];
|
||||
) || filteredProviderOptions[0];
|
||||
|
||||
setConnectorForm((current) => ({
|
||||
...current,
|
||||
provider: provider.key,
|
||||
reviewDestination:
|
||||
reviewDestination: getAllowedReviewDestination(
|
||||
currentBusinessType,
|
||||
provider.defaultReviewDestination === 'shopify_hosted'
|
||||
? 'shopify_hosted'
|
||||
: current.reviewDestination,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
@ -690,6 +816,10 @@ export default function PaymentProviderConnectors({
|
||||
loadSubscriptionStatus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
updateConnectorForm('businessType', normalizeBusinessType(initialBusinessType));
|
||||
}, [initialBusinessType]);
|
||||
|
||||
const handleConnectorSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setIsConnectorSubmitting(true);
|
||||
@ -927,8 +1057,7 @@ export default function PaymentProviderConnectors({
|
||||
1. Select provider
|
||||
</p>
|
||||
<p className='mt-1 text-slate-500 dark:text-slate-400'>
|
||||
Pick Stripe, PayPal, Square, Shopify, or WooCommerce from the
|
||||
dropdown.
|
||||
Pick the relevant provider from the filtered dropdown; Local, Online, and Hybrid setups show different choices.
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-xl bg-white p-3 text-sm ring-1 ring-indigo-100 dark:bg-dark-900 dark:ring-indigo-900'>
|
||||
@ -957,14 +1086,24 @@ export default function PaymentProviderConnectors({
|
||||
className='mb-6 rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-dark-700 dark:bg-dark-800'
|
||||
>
|
||||
<FormField
|
||||
label='Provider and business'
|
||||
help='Choose the provider, then use the same business name to reuse its review settings.'
|
||||
label='Business type, provider, and business'
|
||||
help={businessTypeOptions.find((option) => option.key === currentBusinessType)?.help}
|
||||
>
|
||||
<select
|
||||
value={connectorForm.provider}
|
||||
value={connectorForm.businessType}
|
||||
onChange={(event) => updateConnectorForm('businessType', event.target.value)}
|
||||
>
|
||||
{businessTypeOptions.map((option) => (
|
||||
<option key={`${option.key}-business-type`} value={option.key}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={selectedProvider.key}
|
||||
onChange={(event) => updateSelectedProvider(event.target.value)}
|
||||
>
|
||||
{providerOptions.map((provider) => (
|
||||
{filteredProviderOptions.map((provider) => (
|
||||
<option key={`${provider.key}-option`} value={provider.key}>
|
||||
{provider.label}
|
||||
</option>
|
||||
@ -999,7 +1138,7 @@ export default function PaymentProviderConnectors({
|
||||
updateConnectorForm('reviewDestination', event.target.value)
|
||||
}
|
||||
>
|
||||
{reviewDestinationOptions.map((destination) => (
|
||||
{filteredReviewDestinationOptions.map((destination) => (
|
||||
<option
|
||||
key={`${destination.key}-destination`}
|
||||
value={destination.key}
|
||||
|
||||
@ -27,10 +27,18 @@ export const SelectField = ({ options, field, form, itemRef, showField, disabled
|
||||
|
||||
async function callApi(inputValue: string, loadedOptions: any[]) {
|
||||
const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}`;
|
||||
const { data } = await axios(path);
|
||||
return {
|
||||
options: data.map(mapResponseToValuesAndLabels),
|
||||
hasMore: data.length === PAGE_SIZE,
|
||||
try {
|
||||
const { data } = await axios(path);
|
||||
return {
|
||||
options: data.map(mapResponseToValuesAndLabels),
|
||||
hasMore: data.length === PAGE_SIZE,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load options for ${itemRef}:`, error);
|
||||
return {
|
||||
options: [],
|
||||
hasMore: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
|
||||
@ -42,10 +42,18 @@ export const SelectFieldMany = ({ options, field, form, itemRef, showField }) =>
|
||||
|
||||
async function callApi(inputValue: string, loadedOptions: any[]) {
|
||||
const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}`;
|
||||
const { data } = await axios(path);
|
||||
return {
|
||||
options: data.map(mapResponseToValuesAndLabels),
|
||||
hasMore: data.length === PAGE_SIZE,
|
||||
try {
|
||||
const { data } = await axios(path);
|
||||
return {
|
||||
options: data.map(mapResponseToValuesAndLabels),
|
||||
hasMore: data.length === PAGE_SIZE,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load options for ${itemRef}:`, error);
|
||||
return {
|
||||
options: [],
|
||||
hasMore: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -69,6 +69,8 @@ const EditBusinesses = () => {
|
||||
|
||||
|
||||
'name': '',
|
||||
|
||||
'business_type': 'hybrid',
|
||||
|
||||
|
||||
|
||||
@ -586,7 +588,20 @@ const EditBusinesses = () => {
|
||||
|
||||
|
||||
|
||||
<FormField
|
||||
|
||||
|
||||
<FormField
|
||||
label="Business type"
|
||||
labelFor="business_type"
|
||||
help="Choose Local, Online, or Hybrid so Review Flow hides irrelevant setup options."
|
||||
>
|
||||
<Field name="business_type" id="business_type" component="select">
|
||||
<option value="local">Local / service business</option>
|
||||
<option value="online">Online / ecommerce business</option>
|
||||
<option value="hybrid">Hybrid business</option>
|
||||
</Field>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Google review link"
|
||||
>
|
||||
<Field
|
||||
|
||||
@ -69,6 +69,8 @@ const EditBusinessesPage = () => {
|
||||
|
||||
|
||||
'name': '',
|
||||
|
||||
'business_type': 'hybrid',
|
||||
|
||||
|
||||
|
||||
@ -583,7 +585,20 @@ const EditBusinessesPage = () => {
|
||||
|
||||
|
||||
|
||||
<FormField
|
||||
|
||||
|
||||
<FormField
|
||||
label="Business type"
|
||||
labelFor="business_type"
|
||||
help="Choose Local, Online, or Hybrid so Review Flow hides irrelevant setup options."
|
||||
>
|
||||
<Field name="business_type" id="business_type" component="select">
|
||||
<option value="local">Local / service business</option>
|
||||
<option value="online">Online / ecommerce business</option>
|
||||
<option value="hybrid">Hybrid business</option>
|
||||
</Field>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Google review link"
|
||||
>
|
||||
<Field
|
||||
|
||||
@ -106,7 +106,7 @@ const BusinessesTablesPage = () => {
|
||||
<p className='mt-2 text-sm leading-6'>
|
||||
{isAdminPortal
|
||||
? 'Manage customer business profiles used for review links, email templates, delay timing, and payment/webhook settings. Internal admin management is not limited by customer subscription plans.'
|
||||
: 'Stores the business profile used for review links, email templates, delay timing, and payment/webhook settings. Starter accounts manage one business profile; Pro accounts can manage up to ten.'}
|
||||
: 'Stores the business profile used for review links, email templates, delay timing, and payment/webhook settings. Grow accounts manage one business profile; Pro accounts can manage up to ten.'}
|
||||
</p>
|
||||
</CardBox>
|
||||
<SubscriptionLimitGate
|
||||
|
||||
@ -48,6 +48,8 @@ const initialValues = {
|
||||
|
||||
|
||||
name: '',
|
||||
|
||||
business_type: 'hybrid',
|
||||
|
||||
|
||||
|
||||
@ -359,8 +361,21 @@ const BusinessesNew = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField
|
||||
label="Business type"
|
||||
labelFor="business_type"
|
||||
help="Choose Local, Online, or Hybrid so Review Flow hides irrelevant setup options."
|
||||
>
|
||||
<Field name="business_type" id="business_type" component="select">
|
||||
<option value="local">Local / service business</option>
|
||||
<option value="online">Online / ecommerce business</option>
|
||||
<option value="hybrid">Hybrid business</option>
|
||||
</Field>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Google review link"
|
||||
>
|
||||
<Field
|
||||
|
||||
@ -106,7 +106,7 @@ const BusinessesTablesPage = () => {
|
||||
<p className='mt-2 text-sm leading-6'>
|
||||
{isAdminPortal
|
||||
? 'Manage customer business profiles used for review links, email templates, delay timing, and payment/webhook settings. Internal admin management is not limited by customer subscription plans.'
|
||||
: 'Stores the business profile used for review links, email templates, delay timing, and payment/webhook settings. Starter accounts manage one business profile; Pro accounts can manage up to ten.'}
|
||||
: 'Stores the business profile used for review links, email templates, delay timing, and payment/webhook settings. Grow accounts manage one business profile; Pro accounts can manage up to ten.'}
|
||||
</p>
|
||||
</CardBox>
|
||||
<SubscriptionLimitGate
|
||||
|
||||
755
frontend/src/pages/growth-tools.tsx
Normal file
755
frontend/src/pages/growth-tools.tsx
Normal file
@ -0,0 +1,755 @@
|
||||
import {
|
||||
mdiCheckCircleOutline,
|
||||
mdiCreditCardOutline,
|
||||
mdiOpenInNew,
|
||||
mdiRefresh,
|
||||
mdiSend,
|
||||
mdiStarCircleOutline,
|
||||
} from '@mdi/js';
|
||||
import axios from 'axios';
|
||||
import Head from 'next/head';
|
||||
import React, { FormEvent, ReactElement, useEffect, useMemo, useState } from 'react';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import CardBox from '../components/CardBox';
|
||||
import FormField from '../components/FormField';
|
||||
import SectionMain from '../components/SectionMain';
|
||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
import { getPageTitle } from '../config';
|
||||
import { aiResponse } from '../stores/openAiSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
|
||||
type BusinessType = 'local' | 'online' | 'hybrid';
|
||||
|
||||
type ReviewBusiness = {
|
||||
id: string;
|
||||
name?: string;
|
||||
business_type?: BusinessType;
|
||||
review_destination?: string;
|
||||
delay_days?: number;
|
||||
automation_mode?: string;
|
||||
followup_enabled?: boolean;
|
||||
followup_delay_days?: number;
|
||||
max_followups?: number;
|
||||
ai_reply_enabled?: boolean;
|
||||
referral_enabled?: boolean;
|
||||
referral_offer?: string;
|
||||
nps_enabled?: boolean;
|
||||
nps_question?: string;
|
||||
social_widget_enabled?: boolean;
|
||||
broadcast_enabled?: boolean;
|
||||
rebooking_enabled?: boolean;
|
||||
competitor_insights_enabled?: boolean;
|
||||
competitor_urls?: string;
|
||||
review_widget_theme?: string;
|
||||
};
|
||||
|
||||
type SummaryResponse = {
|
||||
stats: {
|
||||
pending: number;
|
||||
sent: number;
|
||||
clicked: number;
|
||||
reviewed: number;
|
||||
customers: number;
|
||||
transactions: number;
|
||||
paymentEvents: number;
|
||||
};
|
||||
businesses?: ReviewBusiness[];
|
||||
primaryBusiness?: ReviewBusiness | null;
|
||||
};
|
||||
|
||||
type SubscriptionStatusResponse = {
|
||||
subscription: {
|
||||
planId: string;
|
||||
planName: string;
|
||||
effectiveStatus: string;
|
||||
isActive: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type WidgetResponse = {
|
||||
embedCode?: string;
|
||||
business?: { name?: string };
|
||||
reviews?: Array<{
|
||||
id: string;
|
||||
rating?: number;
|
||||
title?: string;
|
||||
content?: string;
|
||||
reviewer?: string;
|
||||
source?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type CompetitorInsightsResponse = {
|
||||
competitors: string[];
|
||||
metrics: {
|
||||
reviewed: number;
|
||||
pending: number;
|
||||
sent: number;
|
||||
customers: number;
|
||||
};
|
||||
recommendations: string[];
|
||||
};
|
||||
|
||||
const businessTypeOptions: Array<{ key: BusinessType; label: string; help: string }> = [
|
||||
{
|
||||
key: 'local',
|
||||
label: 'Local / service business',
|
||||
help: 'Use this for businesses that collect local profile reviews such as Google, Facebook, Yelp, Angi, or OpenTable.',
|
||||
},
|
||||
{
|
||||
key: 'online',
|
||||
label: 'Online / ecommerce business',
|
||||
help: 'Use this for stores and online brands that collect product or ecommerce reviews.',
|
||||
},
|
||||
{
|
||||
key: 'hybrid',
|
||||
label: 'Hybrid business',
|
||||
help: 'Use this when the same business needs both local and online review workflows.',
|
||||
},
|
||||
];
|
||||
|
||||
const reviewDestinationOptions = [
|
||||
{ key: 'google', label: 'Google', scope: 'local' },
|
||||
{ key: 'facebook', label: 'Facebook', scope: 'local' },
|
||||
{ key: 'yelp', label: 'Yelp', scope: 'local' },
|
||||
{ key: 'angi', label: 'Angi', scope: 'local' },
|
||||
{ key: 'opentable', label: 'OpenTable', scope: 'local' },
|
||||
{ key: 'shopify_hosted', label: 'Shopify hosted product review', scope: 'online' },
|
||||
{ key: 'trustpilot', label: 'Trustpilot', scope: 'online' },
|
||||
{ key: 'custom', label: 'Custom review page', scope: 'hybrid' },
|
||||
];
|
||||
|
||||
const defaultSettings = {
|
||||
businessName: 'Review Flow Business',
|
||||
businessType: 'hybrid' as BusinessType,
|
||||
reviewDestination: 'google',
|
||||
delayDays: '7',
|
||||
followupEnabled: true,
|
||||
followupDelayDays: '3',
|
||||
maxFollowups: '1',
|
||||
aiReplyEnabled: false,
|
||||
referralEnabled: false,
|
||||
referralOffer: 'Give $25, get $25 when a referred customer completes their first purchase.',
|
||||
npsEnabled: false,
|
||||
npsQuestion: 'How likely are you to recommend us to a friend?',
|
||||
socialWidgetEnabled: true,
|
||||
broadcastEnabled: false,
|
||||
rebookingEnabled: false,
|
||||
competitorInsightsEnabled: false,
|
||||
competitorUrls: '',
|
||||
reviewWidgetTheme: 'light',
|
||||
};
|
||||
|
||||
const defaultCampaign = {
|
||||
campaignType: 'broadcast',
|
||||
subject: 'Quick update from our team',
|
||||
message: 'Thanks for being a customer. We appreciate your support and wanted to share a quick update.',
|
||||
};
|
||||
|
||||
function normalizeBusinessType(value?: string): BusinessType {
|
||||
if (value === 'local' || value === 'online' || value === 'hybrid') return value;
|
||||
return 'hybrid';
|
||||
}
|
||||
|
||||
function destinationAllowedForBusinessType(businessType: BusinessType, destination: typeof reviewDestinationOptions[number]) {
|
||||
if (businessType === 'hybrid' || destination.scope === 'hybrid') return true;
|
||||
return destination.scope === businessType;
|
||||
}
|
||||
|
||||
function getDestinationsForBusinessType(businessType: BusinessType) {
|
||||
return reviewDestinationOptions.filter((destination) =>
|
||||
destinationAllowedForBusinessType(businessType, destination),
|
||||
);
|
||||
}
|
||||
|
||||
function getDefaultDestination(businessType: BusinessType) {
|
||||
return businessType === 'online' ? 'shopify_hosted' : 'google';
|
||||
}
|
||||
|
||||
function coerceDestination(businessType: BusinessType, destination?: string) {
|
||||
const destinations = getDestinationsForBusinessType(businessType);
|
||||
return destinations.some((option) => option.key === destination)
|
||||
? destination || destinations[0].key
|
||||
: getDefaultDestination(businessType);
|
||||
}
|
||||
|
||||
function businessToSettings(business?: ReviewBusiness | null) {
|
||||
if (!business) return defaultSettings;
|
||||
|
||||
const businessType = normalizeBusinessType(business.business_type);
|
||||
|
||||
return {
|
||||
businessName: business.name || defaultSettings.businessName,
|
||||
businessType,
|
||||
reviewDestination: coerceDestination(businessType, business.review_destination),
|
||||
delayDays: String(business.delay_days ?? 7),
|
||||
followupEnabled: business.followup_enabled !== false,
|
||||
followupDelayDays: String(business.followup_delay_days ?? 3),
|
||||
maxFollowups: String(business.max_followups ?? 1),
|
||||
aiReplyEnabled: Boolean(business.ai_reply_enabled),
|
||||
referralEnabled: Boolean(business.referral_enabled),
|
||||
referralOffer: business.referral_offer || defaultSettings.referralOffer,
|
||||
npsEnabled: Boolean(business.nps_enabled),
|
||||
npsQuestion: business.nps_question || defaultSettings.npsQuestion,
|
||||
socialWidgetEnabled: business.social_widget_enabled !== false,
|
||||
broadcastEnabled: Boolean(business.broadcast_enabled),
|
||||
rebookingEnabled: Boolean(business.rebooking_enabled),
|
||||
competitorInsightsEnabled: Boolean(business.competitor_insights_enabled),
|
||||
competitorUrls: business.competitor_urls || '',
|
||||
reviewWidgetTheme: business.review_widget_theme || 'light',
|
||||
};
|
||||
}
|
||||
|
||||
function extractAiResponseText(response: any) {
|
||||
const output = response?.output || response?.data?.output || [];
|
||||
|
||||
for (const item of output) {
|
||||
if (item?.type !== 'message') continue;
|
||||
|
||||
for (const content of item.content || []) {
|
||||
if (content?.type === 'output_text' && content.text) {
|
||||
return String(content.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function getCampaignLabel(campaignType: string) {
|
||||
if (campaignType === 'referral') return 'Referral campaign';
|
||||
if (campaignType === 'nps') return 'NPS survey';
|
||||
if (campaignType === 'rebooking') return 'Repeat business / rebooking';
|
||||
return 'Marketing broadcast';
|
||||
}
|
||||
|
||||
export default function GrowthToolsPage() {
|
||||
const dispatch = useAppDispatch();
|
||||
const { isAskingResponse, errorMessage: aiErrorMessage } = useAppSelector((state) => state.openAi);
|
||||
const [summary, setSummary] = useState<SummaryResponse | null>(null);
|
||||
const [subscriptionStatus, setSubscriptionStatus] = useState<SubscriptionStatusResponse | null>(null);
|
||||
const [selectedBusinessId, setSelectedBusinessId] = useState('');
|
||||
const [settingsForm, setSettingsForm] = useState(defaultSettings);
|
||||
const [campaignForm, setCampaignForm] = useState(defaultCampaign);
|
||||
const [aiReviewText, setAiReviewText] = useState('Great service, fast communication, and the team made everything easy.');
|
||||
const [aiTone, setAiTone] = useState('friendly, concise, and professional');
|
||||
const [aiSuggestion, setAiSuggestion] = useState('');
|
||||
const [widget, setWidget] = useState<WidgetResponse | null>(null);
|
||||
const [competitorInsights, setCompetitorInsights] = useState<CompetitorInsightsResponse | null>(null);
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isWorking, setIsWorking] = useState(false);
|
||||
|
||||
const businesses = summary?.businesses || [];
|
||||
const selectedBusiness = useMemo(
|
||||
() => businesses.find((business) => business.id === selectedBusinessId) || summary?.primaryBusiness || null,
|
||||
[businesses, selectedBusinessId, summary?.primaryBusiness],
|
||||
);
|
||||
const currentBusinessType = normalizeBusinessType(settingsForm.businessType);
|
||||
const destinationOptions = getDestinationsForBusinessType(currentBusinessType);
|
||||
const isGrowPlan = subscriptionStatus?.subscription.planId === 'starter';
|
||||
const hasSelectedBusiness = Boolean(selectedBusinessId || selectedBusiness?.id);
|
||||
|
||||
const loadData = async () => {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const [summaryResponse, subscriptionResponse] = await Promise.all([
|
||||
axios.get('/reviewflow/summary'),
|
||||
axios.get('/subscription/me'),
|
||||
]);
|
||||
const loadedSummary = summaryResponse.data as SummaryResponse;
|
||||
const primaryBusiness = loadedSummary.primaryBusiness || loadedSummary.businesses?.[0] || null;
|
||||
|
||||
setSummary(loadedSummary);
|
||||
setSubscriptionStatus(subscriptionResponse.data);
|
||||
|
||||
if (primaryBusiness) {
|
||||
setSelectedBusinessId(primaryBusiness.id);
|
||||
setSettingsForm(businessToSettings(primaryBusiness));
|
||||
}
|
||||
} catch (requestError) {
|
||||
console.error('Failed to load Growth Tools:', requestError);
|
||||
setError('Could not load Growth Tools. Refresh the page or try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const updateSettings = (key: keyof typeof defaultSettings, value: string | boolean) => {
|
||||
setSettingsForm((current) => {
|
||||
if (key === 'businessType') {
|
||||
const businessType = normalizeBusinessType(String(value));
|
||||
return {
|
||||
...current,
|
||||
businessType,
|
||||
reviewDestination: coerceDestination(businessType, current.reviewDestination),
|
||||
};
|
||||
}
|
||||
|
||||
return { ...current, [key]: value };
|
||||
});
|
||||
};
|
||||
|
||||
const selectBusiness = (businessId: string) => {
|
||||
const business = businesses.find((item) => item.id === businessId);
|
||||
setSelectedBusinessId(businessId);
|
||||
setSettingsForm(businessToSettings(business));
|
||||
setWidget(null);
|
||||
setCompetitorInsights(null);
|
||||
};
|
||||
|
||||
const saveSettings = async (event?: FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault();
|
||||
setIsSaving(true);
|
||||
setMessage('');
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await axios.put('/reviewflow/growth-tools/business', {
|
||||
businessId: selectedBusinessId,
|
||||
...settingsForm,
|
||||
delayDays: Number(settingsForm.delayDays),
|
||||
followupDelayDays: Number(settingsForm.followupDelayDays),
|
||||
maxFollowups: Number(settingsForm.maxFollowups),
|
||||
});
|
||||
const business = response.data.business as ReviewBusiness;
|
||||
setSelectedBusinessId(business.id);
|
||||
setSettingsForm(businessToSettings(business));
|
||||
setMessage('Growth settings saved. The workspace will now keep irrelevant options hidden for this business type.');
|
||||
await loadData();
|
||||
} catch (requestError) {
|
||||
console.error('Failed to save Growth Tools settings:', requestError);
|
||||
if (axios.isAxiosError(requestError) && requestError.response?.data) {
|
||||
setError(String(requestError.response.data));
|
||||
} else {
|
||||
setError('Could not save these settings. Please try again.');
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runDueAutomation = async () => {
|
||||
setIsWorking(true);
|
||||
setMessage('');
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await axios.post('/reviewflow/automation/run-due', { limit: 100 });
|
||||
setMessage(
|
||||
`Set-it-and-forget-it run complete: ${response.data.processed} processed, ${response.data.sent} handed off, ${response.data.failed} failed.`,
|
||||
);
|
||||
await loadData();
|
||||
} catch (requestError) {
|
||||
console.error('Failed to run due review automation:', requestError);
|
||||
if (axios.isAxiosError(requestError) && requestError.response?.data) {
|
||||
setError(String(requestError.response.data));
|
||||
} else {
|
||||
setError('Could not run due automation. Please try again.');
|
||||
}
|
||||
} finally {
|
||||
setIsWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadWidget = async () => {
|
||||
if (!hasSelectedBusiness) return;
|
||||
setIsWorking(true);
|
||||
setMessage('');
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/reviewflow/social-widget/${selectedBusinessId || selectedBusiness?.id}`);
|
||||
setWidget(response.data);
|
||||
setMessage('Social proof widget refreshed. Copy the embed code into a website page where you want reviews to appear.');
|
||||
} catch (requestError) {
|
||||
console.error('Failed to load social proof widget:', requestError);
|
||||
if (axios.isAxiosError(requestError) && requestError.response?.data) {
|
||||
setError(String(requestError.response.data));
|
||||
} else {
|
||||
setError('Could not load the social proof widget.');
|
||||
}
|
||||
} finally {
|
||||
setIsWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const launchCampaign = async () => {
|
||||
setIsWorking(true);
|
||||
setMessage('');
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await axios.post('/reviewflow/growth-tools/broadcast', {
|
||||
businessId: selectedBusinessId,
|
||||
...campaignForm,
|
||||
});
|
||||
setMessage(`${getCampaignLabel(campaignForm.campaignType)} queued: ${response.data.queued} customers, ${response.data.skipped} skipped.`);
|
||||
} catch (requestError) {
|
||||
console.error('Failed to queue Growth Tools campaign:', requestError);
|
||||
if (axios.isAxiosError(requestError) && requestError.response?.data) {
|
||||
setError(String(requestError.response.data));
|
||||
} else {
|
||||
setError('Could not queue this campaign. Please try again.');
|
||||
}
|
||||
} finally {
|
||||
setIsWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generateAiReply = async () => {
|
||||
setAiSuggestion('');
|
||||
setError('');
|
||||
|
||||
if (isGrowPlan) {
|
||||
setError('Grow does not include AI review replies. Upgrade to Pro to unlock it.');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
input: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You write short, warm, non-defensive review replies for a small business. Keep replies under 90 words and do not mention private customer data.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Business: ${settingsForm.businessName}\nTone: ${aiTone}\nCustomer review: ${aiReviewText}\nWrite one public reply.`,
|
||||
},
|
||||
],
|
||||
options: { poll_interval: 5, poll_timeout: 300 },
|
||||
};
|
||||
|
||||
const resultAction = await dispatch(aiResponse(payload));
|
||||
|
||||
if (aiResponse.fulfilled.match(resultAction)) {
|
||||
const text = extractAiResponseText(resultAction.payload);
|
||||
setAiSuggestion(text || 'AI returned a response, but no text output was found.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('AI reply assistant failed:', resultAction.payload || resultAction.error);
|
||||
setError('AI reply assistant failed. Check the AI proxy configuration and try again.');
|
||||
};
|
||||
|
||||
const runCompetitorInsights = async () => {
|
||||
setIsWorking(true);
|
||||
setCompetitorInsights(null);
|
||||
setError('');
|
||||
setMessage('');
|
||||
|
||||
try {
|
||||
const response = await axios.post('/reviewflow/growth-tools/competitor-insights', {
|
||||
businessId: selectedBusinessId,
|
||||
competitorUrls: settingsForm.competitorUrls,
|
||||
});
|
||||
setCompetitorInsights(response.data);
|
||||
setMessage('Competitor insight checklist updated.');
|
||||
} catch (requestError) {
|
||||
console.error('Failed to build competitor insights:', requestError);
|
||||
if (axios.isAxiosError(requestError) && requestError.response?.data) {
|
||||
setError(String(requestError.response.data));
|
||||
} else {
|
||||
setError('Could not build competitor insights. Please try again.');
|
||||
}
|
||||
} finally {
|
||||
setIsWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Growth Tools')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiStarCircleOutline} title='Growth Tools' main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<div className='mb-6 overflow-hidden rounded-3xl bg-gradient-to-br from-slate-950 via-indigo-950 to-emerald-950 p-6 text-white shadow-2xl'>
|
||||
<div className='grid gap-6 lg:grid-cols-[1.2fr_0.8fr] lg:items-center'>
|
||||
<div>
|
||||
<p className='mb-3 inline-flex rounded-full bg-white/10 px-4 py-1 text-sm font-semibold text-emerald-200 ring-1 ring-white/20'>
|
||||
Automated review management · set it and forget it
|
||||
</p>
|
||||
<h2 className='max-w-3xl text-4xl font-black tracking-tight md:text-5xl'>
|
||||
Keep review growth simple after business setup.
|
||||
</h2>
|
||||
<p className='mt-4 max-w-2xl text-base text-slate-200 md:text-lg'>
|
||||
Local, Online, and Hybrid settings control which tools are visible. Grow handles the automated review engine. Pro unlocks AI replies, referrals, NPS, broadcasts, rebooking, and competitor insights.
|
||||
</p>
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
{[
|
||||
['Pending', summary?.stats.pending ?? 0],
|
||||
['Sent', summary?.stats.sent ?? 0],
|
||||
['Clicked', summary?.stats.clicked ?? 0],
|
||||
['Reviewed', summary?.stats.reviewed ?? 0],
|
||||
].map(([label, value]) => (
|
||||
<div key={label} className='rounded-2xl bg-white/10 p-4 ring-1 ring-white/15 backdrop-blur'>
|
||||
<div className='text-3xl font-black'>{value}</div>
|
||||
<div className='text-sm text-slate-300'>{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className='mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-emerald-900'>
|
||||
<strong>Done.</strong> {message}
|
||||
</div>
|
||||
)}
|
||||
{(error || aiErrorMessage) && (
|
||||
<div className='mb-6 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-rose-900'>
|
||||
<p>{error || aiErrorMessage}</p>
|
||||
{(error || aiErrorMessage).includes('Upgrade to Pro') && (
|
||||
<BaseButton href='/subscription' icon={mdiCreditCardOutline} label='Upgrade to Pro' color='danger' className='mt-3' />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='mb-6 grid gap-6 xl:grid-cols-[0.9fr_1.1fr]'>
|
||||
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
|
||||
<div className='mb-5 flex items-start justify-between gap-4'>
|
||||
<div>
|
||||
<p className='text-sm font-bold uppercase tracking-[0.25em] text-emerald-500'>Setup</p>
|
||||
<h3 className='text-2xl font-black text-slate-900 dark:text-white'>Business type and automation</h3>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400'>
|
||||
This is the uncluttered switch: Local hides ecommerce-only tools, Online hides local-only tools, and Hybrid keeps both.
|
||||
</p>
|
||||
</div>
|
||||
<BaseButton icon={mdiRefresh} label='Refresh' color='whiteDark' onClick={loadData} disabled={isLoading} />
|
||||
</div>
|
||||
|
||||
{businesses.length > 0 && (
|
||||
<FormField label='Business profile' help='Choose which business profile these growth settings apply to.'>
|
||||
<select value={selectedBusinessId} onChange={(event) => selectBusiness(event.target.value)}>
|
||||
{businesses.map((business) => (
|
||||
<option key={business.id} value={business.id}>{business.name || 'Business'}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<form onSubmit={saveSettings}>
|
||||
<FormField label='Business and type' help={businessTypeOptions.find((option) => option.key === currentBusinessType)?.help}>
|
||||
<input
|
||||
required
|
||||
value={settingsForm.businessName}
|
||||
onChange={(event) => updateSettings('businessName', event.target.value)}
|
||||
placeholder='Business name'
|
||||
/>
|
||||
<select value={settingsForm.businessType} onChange={(event) => updateSettings('businessType', event.target.value)}>
|
||||
{businessTypeOptions.map((option) => (
|
||||
<option key={option.key} value={option.key}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={settingsForm.reviewDestination} onChange={(event) => updateSettings('reviewDestination', event.target.value)}>
|
||||
{destinationOptions.map((destination) => (
|
||||
<option key={destination.key} value={destination.key}>{destination.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<FormField label='Set-it-and-forget-it timing' help='Due requests can be handed off from the queue without manually opening each one.'>
|
||||
<input
|
||||
min='0'
|
||||
max='30'
|
||||
type='number'
|
||||
value={settingsForm.delayDays}
|
||||
onChange={(event) => updateSettings('delayDays', event.target.value)}
|
||||
placeholder='Initial delay days'
|
||||
/>
|
||||
<input
|
||||
min='1'
|
||||
max='30'
|
||||
type='number'
|
||||
value={settingsForm.followupDelayDays}
|
||||
onChange={(event) => updateSettings('followupDelayDays', event.target.value)}
|
||||
placeholder='Follow-up delay days'
|
||||
/>
|
||||
<input
|
||||
min='0'
|
||||
max='5'
|
||||
type='number'
|
||||
value={settingsForm.maxFollowups}
|
||||
onChange={(event) => updateSettings('maxFollowups', event.target.value)}
|
||||
placeholder='Max follow-ups'
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className='mb-5 grid gap-3 md:grid-cols-2'>
|
||||
{[
|
||||
['followupEnabled', 'Follow-ups', 'Automatically prepare follow-up handoffs for customers who have not clicked.'],
|
||||
['socialWidgetEnabled', 'Social proof widget', 'Show verified hosted reviews on websites and landing pages.'],
|
||||
['aiReplyEnabled', 'AI replies (Pro)', 'Generate review replies using the existing AI proxy.'],
|
||||
['referralEnabled', 'Referrals (Pro)', 'Queue referral campaign messages for customers.'],
|
||||
['npsEnabled', 'NPS surveys (Pro)', 'Queue NPS survey outreach.'],
|
||||
['broadcastEnabled', 'Broadcasts (Pro)', 'Queue marketing broadcasts.'],
|
||||
['rebookingEnabled', 'Rebooking (Pro)', 'Queue repeat-business campaigns.'],
|
||||
['competitorInsightsEnabled', 'Competitor insights (Pro)', 'Save competitors and build an action checklist.'],
|
||||
].map(([key, label, help]) => (
|
||||
<label key={key} className='flex gap-3 rounded-2xl border border-slate-200 p-4 text-sm dark:border-dark-700'>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={Boolean(settingsForm[key as keyof typeof defaultSettings])}
|
||||
onChange={(event) => updateSettings(key as keyof typeof defaultSettings, event.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<span className='block font-black text-slate-900 dark:text-white'>{label}</span>
|
||||
<span className='mt-1 block leading-5 text-slate-500 dark:text-slate-400'>{help}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<FormField label='Referral, NPS, and competitor defaults' help='Pro tools use these defaults when queueing campaigns or building insights.'>
|
||||
<input value={settingsForm.referralOffer} onChange={(event) => updateSettings('referralOffer', event.target.value)} placeholder='Referral offer' />
|
||||
<input value={settingsForm.npsQuestion} onChange={(event) => updateSettings('npsQuestion', event.target.value)} placeholder='NPS question' />
|
||||
</FormField>
|
||||
<FormField label='Competitors' help='One competitor name or URL per line. Keep this focused on your top direct alternatives.'>
|
||||
<textarea value={settingsForm.competitorUrls} onChange={(event) => updateSettings('competitorUrls', event.target.value)} placeholder='nicejob.com Another competitor' />
|
||||
</FormField>
|
||||
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
<BaseButton type='submit' icon={mdiCheckCircleOutline} label={isSaving ? 'Saving...' : 'Save settings'} color='info' disabled={isSaving} />
|
||||
<BaseButton type='button' icon={mdiSend} label='Run due automation' color='success' onClick={runDueAutomation} disabled={isWorking} />
|
||||
</div>
|
||||
</form>
|
||||
</CardBox>
|
||||
|
||||
<div className='grid gap-6'>
|
||||
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
|
||||
<p className='text-sm font-bold uppercase tracking-[0.25em] text-indigo-500'>Grow</p>
|
||||
<h3 className='mt-1 text-2xl font-black text-slate-900 dark:text-white'>Social proof widget</h3>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400'>
|
||||
Grow includes an embeddable widget for hosted reviews. It displays verified reviews after customers submit them through Review Flow.
|
||||
</p>
|
||||
<BaseButton icon={mdiOpenInNew} label='Refresh widget code' color='info' className='mt-4' onClick={loadWidget} disabled={!hasSelectedBusiness || isWorking} />
|
||||
{widget?.embedCode && (
|
||||
<div className='mt-4 rounded-2xl bg-slate-950 p-4 text-sm text-emerald-100'>
|
||||
<p className='mb-2 font-black text-white'>Embed code</p>
|
||||
<code className='break-all'>{widget.embedCode}</code>
|
||||
</div>
|
||||
)}
|
||||
{widget?.reviews && (
|
||||
<div className='mt-4 grid gap-3'>
|
||||
{widget.reviews.length === 0 ? (
|
||||
<div className='rounded-2xl border border-dashed border-slate-200 p-5 text-center text-slate-500'>No hosted reviews yet.</div>
|
||||
) : widget.reviews.map((review) => (
|
||||
<div key={review.id} className='rounded-2xl bg-slate-50 p-4 dark:bg-dark-800'>
|
||||
<p className='font-black text-amber-500'>{'★'.repeat(review.rating || 5)}</p>
|
||||
<p className='mt-1 font-bold text-slate-900 dark:text-white'>{review.title || 'Customer review'}</p>
|
||||
<p className='mt-1 text-sm text-slate-500'>{review.content}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
|
||||
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div>
|
||||
<p className='text-sm font-bold uppercase tracking-[0.25em] text-fuchsia-500'>Pro</p>
|
||||
<h3 className='mt-1 text-2xl font-black text-slate-900 dark:text-white'>AI review reply assistant</h3>
|
||||
</div>
|
||||
{isGrowPlan && <span className='rounded-full bg-indigo-100 px-3 py-1 text-xs font-black text-indigo-700'>Pro</span>}
|
||||
</div>
|
||||
<FormField label='Review and tone' help='Uses the existing /api/ai/response proxy through Redux.'>
|
||||
<textarea value={aiReviewText} onChange={(event) => setAiReviewText(event.target.value)} placeholder='Paste customer review text' />
|
||||
<input value={aiTone} onChange={(event) => setAiTone(event.target.value)} placeholder='Tone' />
|
||||
</FormField>
|
||||
<BaseButton icon={mdiSend} label={isAskingResponse ? 'Generating...' : 'Generate reply'} color='info' onClick={generateAiReply} disabled={isAskingResponse || !aiReviewText.trim() || isGrowPlan} />
|
||||
{aiSuggestion && (
|
||||
<div className='mt-4 rounded-2xl border border-indigo-100 bg-indigo-50 p-4 text-sm leading-6 text-indigo-950'>
|
||||
{aiSuggestion}
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-2'>
|
||||
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div>
|
||||
<p className='text-sm font-bold uppercase tracking-[0.25em] text-emerald-500'>Pro campaigns</p>
|
||||
<h3 className='mt-1 text-2xl font-black text-slate-900 dark:text-white'>Referrals, NPS, broadcasts, and rebooking</h3>
|
||||
</div>
|
||||
{isGrowPlan && <BaseButton href='/subscription' icon={mdiCreditCardOutline} label='Upgrade' color='info' />}
|
||||
</div>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400'>
|
||||
These campaign actions queue customer messages in Email Delivery so a provider handoff can process them. They do not hide failures; invalid or missing emails are skipped.
|
||||
</p>
|
||||
<FormField label='Campaign' help='Choose the type, then write the subject and message.'>
|
||||
<select value={campaignForm.campaignType} onChange={(event) => setCampaignForm((current) => ({ ...current, campaignType: event.target.value }))}>
|
||||
<option value='broadcast'>Marketing broadcast</option>
|
||||
<option value='referral'>Referral campaign</option>
|
||||
<option value='nps'>NPS survey</option>
|
||||
<option value='rebooking'>Repeat business / rebooking</option>
|
||||
</select>
|
||||
<input value={campaignForm.subject} onChange={(event) => setCampaignForm((current) => ({ ...current, subject: event.target.value }))} placeholder='Subject' />
|
||||
</FormField>
|
||||
<FormField label='Message' help='This is stored in the delivery log details for provider handoff.'>
|
||||
<textarea value={campaignForm.message} onChange={(event) => setCampaignForm((current) => ({ ...current, message: event.target.value }))} placeholder='Message' />
|
||||
</FormField>
|
||||
<BaseButton icon={mdiSend} label='Queue campaign' color='info' onClick={launchCampaign} disabled={isWorking} />
|
||||
</CardBox>
|
||||
|
||||
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div>
|
||||
<p className='text-sm font-bold uppercase tracking-[0.25em] text-amber-500'>Pro insights</p>
|
||||
<h3 className='mt-1 text-2xl font-black text-slate-900 dark:text-white'>Competitor insight checklist</h3>
|
||||
</div>
|
||||
{isGrowPlan && <span className='rounded-full bg-indigo-100 px-3 py-1 text-xs font-black text-indigo-700'>Pro</span>}
|
||||
</div>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400'>
|
||||
Save focused competitors and generate an internal action checklist from your own review stats. This does not scrape competitor sites; it keeps the workspace lightweight and safe.
|
||||
</p>
|
||||
<BaseButton icon={mdiRefresh} label='Build insights' color='warning' className='mt-4' onClick={runCompetitorInsights} disabled={isWorking || !settingsForm.competitorUrls.trim()} />
|
||||
{competitorInsights && (
|
||||
<div className='mt-4 space-y-4'>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
{Object.entries(competitorInsights.metrics).map(([label, value]) => (
|
||||
<div key={label} className='rounded-2xl bg-slate-50 p-4 dark:bg-dark-800'>
|
||||
<p className='text-2xl font-black'>{value}</p>
|
||||
<p className='text-sm capitalize text-slate-500'>{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className='rounded-2xl border border-slate-200 p-4 dark:border-dark-700'>
|
||||
<p className='font-black text-slate-900 dark:text-white'>Tracked competitors</p>
|
||||
<ul className='mt-2 list-disc space-y-1 pl-5 text-sm text-slate-500'>
|
||||
{competitorInsights.competitors.map((competitor) => <li key={competitor}>{competitor}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className='rounded-2xl bg-amber-50 p-4 text-amber-950'>
|
||||
<p className='font-black'>Recommended next actions</p>
|
||||
<ul className='mt-2 list-disc space-y-2 pl-5 text-sm leading-6'>
|
||||
{competitorInsights.recommendations.map((recommendation) => <li key={recommendation}>{recommendation}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
</div>
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
GrowthToolsPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated portal='customer'>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
@ -62,13 +62,13 @@ export default function Starter() {
|
||||
<div>
|
||||
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm font-bold text-emerald-700">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-500" />
|
||||
Review automation for modern local businesses
|
||||
Automated review management for local, online, and hybrid businesses
|
||||
</div>
|
||||
<h1 className="max-w-4xl text-5xl font-black leading-[0.95] tracking-tight text-slate-950 md:text-7xl">
|
||||
Ask at the perfect moment. Earn more five-star reviews.
|
||||
</h1>
|
||||
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600">
|
||||
Review Flow turns Stripe, Square, PayPal, Shopify, and WooCommerce webhooks into scheduled review requests with a clean queue, message preview, and admin controls already wired into your app.
|
||||
Review Flow turns payments, orders, and manual customer moments into scheduled review requests, social proof, and growth campaigns while hiding irrelevant options after setup.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<BaseButton href="/reviewflow" icon={mdiStarCircleOutline} label="Open Review Flow" color="info" className="shadow-xl shadow-indigo-600/20" />
|
||||
@ -141,9 +141,9 @@ export default function Starter() {
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<p className="text-sm font-black uppercase tracking-[0.3em] text-emerald-600">Simple pricing</p>
|
||||
<h2 className="mt-4 text-4xl font-black tracking-tight text-slate-950 md:text-5xl">Choose Starter or Pro.</h2>
|
||||
<h2 className="mt-4 text-4xl font-black tracking-tight text-slate-950 md:text-5xl">Choose Grow or Pro.</h2>
|
||||
<p className="mt-5 text-lg leading-8 text-slate-600">
|
||||
Every plan starts with a {trialDays}-day free trial. Starter covers the core review workflow. Pro adds the advanced automation and reputation marketing tools growing teams need.
|
||||
Every plan starts with a {trialDays}-day free trial. Grow covers set-it-and-forget-it review automation. Pro adds AI replies, referrals, NPS, broadcasts, rebooking, competitor insights, and higher limits.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@ -38,8 +38,8 @@ export default function Login() {
|
||||
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
|
||||
(state) => state.auth,
|
||||
);
|
||||
const [initialValues, setInitialValues] = React.useState({ email:'admin@flatlogic.com',
|
||||
password: 'fc6e39e3',
|
||||
const [initialValues, setInitialValues] = React.useState({ email:'pro@reviewflow.demo',
|
||||
password: 'ProDemo2026!',
|
||||
remember: true })
|
||||
|
||||
const title = 'Review Flow'
|
||||
@ -62,15 +62,15 @@ export default function Login() {
|
||||
'Transportation teams can manage businesses, customers, transactions, payment events, and review requests without jumping tools.',
|
||||
},
|
||||
{
|
||||
title: 'Clear Starter and Pro tiers',
|
||||
title: 'Clear Grow and Pro tiers',
|
||||
description:
|
||||
'Starter is $49/month for the core review workflow. Pro is $99/month for higher limits, automation, AI, and reputation marketing tools.',
|
||||
'Grow is $49/month for automated review management. Pro is $99/month for AI replies, referrals, NPS, broadcasts, rebooking, competitor insights, and higher limits.',
|
||||
},
|
||||
];
|
||||
|
||||
const pricingPlans = [
|
||||
{
|
||||
name: 'Starter',
|
||||
name: 'Grow',
|
||||
price: '$49',
|
||||
description:
|
||||
'Best for small teams that need the core Review Flow workflow and simple monthly limits.',
|
||||
@ -84,7 +84,7 @@ export default function Login() {
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Starter limits',
|
||||
title: 'Grow limits',
|
||||
features: [
|
||||
'250 review requests per month.',
|
||||
'1 business profile.',
|
||||
@ -98,23 +98,23 @@ export default function Login() {
|
||||
name: 'Pro',
|
||||
price: '$99',
|
||||
description:
|
||||
'Best for growing teams that want higher limits, automation, AI assistance, and reputation marketing tools.',
|
||||
'Best for growing teams that need higher Review Flow limits on the same working workflow.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Everything in Starter',
|
||||
title: 'Everything in Grow',
|
||||
features: [
|
||||
'2,500 review requests per month.',
|
||||
'10 business profiles.',
|
||||
'10 team members.',
|
||||
'Priority support and advanced reporting.',
|
||||
'Subscription usage dashboard and upgrade controls.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Growth tools',
|
||||
title: 'Working Pro upgrades',
|
||||
features: [
|
||||
'Advanced automation rules.',
|
||||
'AI review reply assistant.',
|
||||
'Social proof widgets, referral campaigns, repeat booking reminders, NPS surveys, and broadcasts.',
|
||||
'Higher monthly review-request limit.',
|
||||
'More business profiles for multiple locations or brands.',
|
||||
'Larger team-member limit with the same invitation workflow.',
|
||||
],
|
||||
},
|
||||
],
|
||||
@ -219,16 +219,22 @@ export default function Login() {
|
||||
|
||||
<p className='mb-2'>Use{' '}
|
||||
<code className={`cursor-pointer ${textColor} `}
|
||||
data-password="fc6e39e3"
|
||||
onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '}
|
||||
<code className={`${textColor}`}>fc6e39e3</code>{' / '}
|
||||
to login as Internal Admin</p>
|
||||
<p>Use <code
|
||||
data-password="ProDemo2026!"
|
||||
onClick={(e) => setLogin(e.target)}>pro@reviewflow.demo</code>{' / '}
|
||||
<code className={`${textColor}`}>ProDemo2026!</code>{' / '}
|
||||
to login as Pro Demo Customer</p>
|
||||
<p className='mb-2'>Use <code
|
||||
className={`cursor-pointer ${textColor} `}
|
||||
data-password="874c3b951385"
|
||||
onClick={(e) => setLogin(e.target)}>john@doe.com</code>{' / '}
|
||||
<code className={`${textColor}`}>874c3b951385</code>{' / '}
|
||||
to login as Customer Owner</p>
|
||||
to login as Grow Customer Owner</p>
|
||||
<p>Use{' '}
|
||||
<code className={`cursor-pointer ${textColor} `}
|
||||
data-password="fc6e39e3"
|
||||
onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '}
|
||||
<code className={`${textColor}`}>fc6e39e3</code>{' / '}
|
||||
to login as Internal Admin</p>
|
||||
</div>
|
||||
<div>
|
||||
<BaseIcon
|
||||
|
||||
@ -32,12 +32,22 @@ import { getBusinessProfileLimitLabel } from '../helpers/businessPlanLabels';
|
||||
interface ReviewBusiness {
|
||||
id?: string;
|
||||
name?: string;
|
||||
business_type?: BusinessType;
|
||||
google_review_link?: string;
|
||||
yelp_review_link?: string;
|
||||
facebook_review_link?: string;
|
||||
trustpilot_review_link?: string;
|
||||
angi_review_link?: string;
|
||||
opentable_review_link?: string;
|
||||
custom_review_link?: string;
|
||||
review_destination?: string;
|
||||
delay_days?: number;
|
||||
}
|
||||
|
||||
interface ReviewCustomer {
|
||||
name?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
interface ReviewTransaction {
|
||||
@ -72,12 +82,33 @@ interface ReviewRequest {
|
||||
review_link?: string;
|
||||
review_platform?: string;
|
||||
review_rating?: number;
|
||||
failure_reason?: string;
|
||||
createdAt?: string;
|
||||
business?: ReviewBusiness;
|
||||
customer?: ReviewCustomer;
|
||||
transaction?: ReviewTransaction;
|
||||
}
|
||||
|
||||
interface ReviewDeliveryAttempt {
|
||||
channel?: string;
|
||||
status?: string;
|
||||
to?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface ReviewDeliveryGroup {
|
||||
requestId?: string;
|
||||
deliveries?: ReviewDeliveryAttempt[];
|
||||
}
|
||||
|
||||
interface ReviewDeliveryResponse {
|
||||
processed?: number;
|
||||
sent?: number;
|
||||
failed?: number;
|
||||
errors?: string[];
|
||||
deliveries?: ReviewDeliveryGroup[];
|
||||
}
|
||||
|
||||
interface SummaryResponse {
|
||||
stats: {
|
||||
pending: number;
|
||||
@ -91,6 +122,8 @@ interface SummaryResponse {
|
||||
requests: ReviewRequest[];
|
||||
recentTransactions?: ReviewTransaction[];
|
||||
recentEvents?: ReviewEvent[];
|
||||
businesses?: ReviewBusiness[];
|
||||
primaryBusiness?: ReviewBusiness | null;
|
||||
}
|
||||
|
||||
interface SubscriptionStatusResponse {
|
||||
@ -116,27 +149,113 @@ interface SubscriptionStatusResponse {
|
||||
};
|
||||
}
|
||||
|
||||
type BusinessType = 'local' | 'online' | 'hybrid';
|
||||
|
||||
const defaultForm = {
|
||||
businessName: 'Review Flow Studio',
|
||||
businessType: 'hybrid' as BusinessType,
|
||||
reviewDestination: 'google',
|
||||
reviewLink: 'https://g.page/r/example/review',
|
||||
delayDays: '7',
|
||||
delayDays: '0',
|
||||
customerName: '',
|
||||
customerEmail: '',
|
||||
phone: '',
|
||||
};
|
||||
|
||||
const reviewDestinationOptions = [
|
||||
{ key: 'google', label: 'Google', requiresLink: true },
|
||||
{ key: 'facebook', label: 'Facebook', requiresLink: true },
|
||||
{ key: 'yelp', label: 'Yelp', requiresLink: true },
|
||||
{ key: 'angi', label: 'Angi', requiresLink: true },
|
||||
{ key: 'opentable', label: 'OpenTable', requiresLink: true },
|
||||
{ key: 'trustpilot', label: 'Trustpilot', requiresLink: true },
|
||||
{ key: 'shopify_hosted', label: 'Shopify hosted product review', requiresLink: false },
|
||||
{ key: 'custom', label: 'Custom review page', requiresLink: true },
|
||||
{ key: 'google', label: 'Google', requiresLink: true, scope: 'local' },
|
||||
{ key: 'facebook', label: 'Facebook', requiresLink: true, scope: 'local' },
|
||||
{ key: 'yelp', label: 'Yelp', requiresLink: true, scope: 'local' },
|
||||
{ key: 'angi', label: 'Angi', requiresLink: true, scope: 'local' },
|
||||
{ key: 'opentable', label: 'OpenTable', requiresLink: true, scope: 'local' },
|
||||
{ key: 'trustpilot', label: 'Trustpilot', requiresLink: true, scope: 'online' },
|
||||
{ key: 'shopify_hosted', label: 'Shopify hosted product review', requiresLink: false, scope: 'online' },
|
||||
{ key: 'custom', label: 'Custom review page', requiresLink: true, scope: 'hybrid' },
|
||||
];
|
||||
|
||||
const businessTypeOptions: Array<{
|
||||
key: BusinessType;
|
||||
label: string;
|
||||
help: string;
|
||||
}> = [
|
||||
{
|
||||
key: 'local',
|
||||
label: 'Local / service business',
|
||||
help: 'Shows local review destinations like Google, Facebook, Yelp, Angi, and OpenTable.',
|
||||
},
|
||||
{
|
||||
key: 'online',
|
||||
label: 'Online / ecommerce business',
|
||||
help: 'Shows ecommerce destinations like Shopify hosted reviews and Trustpilot.',
|
||||
},
|
||||
{
|
||||
key: 'hybrid',
|
||||
label: 'Hybrid business',
|
||||
help: 'Shows both local and online options for businesses that need both.',
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeBusinessType(value?: string): BusinessType {
|
||||
if (value === 'local' || value === 'online' || value === 'hybrid') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return 'hybrid';
|
||||
}
|
||||
|
||||
function destinationAllowedForBusinessType(
|
||||
businessType: BusinessType,
|
||||
destination: (typeof reviewDestinationOptions)[number],
|
||||
) {
|
||||
if (businessType === 'hybrid' || destination.scope === 'hybrid') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return destination.scope === businessType;
|
||||
}
|
||||
|
||||
function getReviewDestinationsForBusinessType(businessType: BusinessType) {
|
||||
return reviewDestinationOptions.filter((destination) =>
|
||||
destinationAllowedForBusinessType(businessType, destination),
|
||||
);
|
||||
}
|
||||
|
||||
function getDefaultReviewDestinationForBusinessType(businessType: BusinessType) {
|
||||
if (businessType === 'online') return 'shopify_hosted';
|
||||
return 'google';
|
||||
}
|
||||
|
||||
function getAllowedReviewDestination(
|
||||
businessType: BusinessType,
|
||||
reviewDestination?: string,
|
||||
) {
|
||||
const allowedDestinations = getReviewDestinationsForBusinessType(businessType);
|
||||
const isAllowed = allowedDestinations.some(
|
||||
(destination) => destination.key === reviewDestination,
|
||||
);
|
||||
|
||||
return isAllowed
|
||||
? reviewDestination || allowedDestinations[0].key
|
||||
: getDefaultReviewDestinationForBusinessType(businessType);
|
||||
}
|
||||
|
||||
function getReviewLinkForDestination(
|
||||
business: ReviewBusiness,
|
||||
destination?: string,
|
||||
) {
|
||||
const destinationKey = destination || business.review_destination || 'google';
|
||||
|
||||
if (destinationKey === 'google') return business.google_review_link || '';
|
||||
if (destinationKey === 'facebook') return business.facebook_review_link || '';
|
||||
if (destinationKey === 'yelp') return business.yelp_review_link || '';
|
||||
if (destinationKey === 'angi') return business.angi_review_link || '';
|
||||
if (destinationKey === 'opentable') return business.opentable_review_link || '';
|
||||
if (destinationKey === 'trustpilot') return business.trustpilot_review_link || '';
|
||||
if (destinationKey === 'custom') return business.custom_review_link || '';
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
const statusStyles: Record<string, string> = {
|
||||
pending: 'bg-amber-100 text-amber-800 ring-amber-200',
|
||||
sent: 'bg-sky-100 text-sky-800 ring-sky-200',
|
||||
@ -146,9 +265,9 @@ const statusStyles: Record<string, string> = {
|
||||
};
|
||||
|
||||
const proFeaturePrompts = [
|
||||
['Advanced automation', 'Create rules for timing, destinations, and follow-up behavior.'],
|
||||
['AI reply assistant', 'Draft thoughtful review replies faster from one workspace.'],
|
||||
['Reputation marketing', 'Unlock widgets, referral campaigns, NPS surveys, and broadcasts.'],
|
||||
['Higher request volume', 'Queue up to 2,500 review requests per month.'],
|
||||
['More business profiles', 'Manage up to 10 locations, brands, or service lines.'],
|
||||
['Larger team access', 'Invite up to 10 team members with the same permission-controlled workflow.'],
|
||||
];
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
@ -186,11 +305,63 @@ function formatAmount(amount?: string | number, currency?: string) {
|
||||
}).format(numericAmount);
|
||||
}
|
||||
|
||||
type ReviewFlowDisclosureProps = {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function ReviewFlowDisclosure({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
defaultOpen = false,
|
||||
className = '',
|
||||
}: ReviewFlowDisclosureProps) {
|
||||
const detailsProps = defaultOpen ? { open: true } : {};
|
||||
|
||||
return (
|
||||
<details
|
||||
{...detailsProps}
|
||||
className={`group overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-xl dark:border-dark-700 dark:bg-dark-900 ${className}`}
|
||||
>
|
||||
<summary className='flex cursor-pointer list-none items-center justify-between gap-4 p-5 [&::-webkit-details-marker]:hidden'>
|
||||
<div>
|
||||
{eyebrow && (
|
||||
<p className='text-xs font-black uppercase tracking-[0.25em] text-slate-400'>
|
||||
{eyebrow}
|
||||
</p>
|
||||
)}
|
||||
<h3 className='mt-1 text-xl font-black text-slate-900 dark:text-white'>
|
||||
{title}
|
||||
</h3>
|
||||
{description && (
|
||||
<p className='mt-1 text-sm leading-6 text-slate-500 dark:text-slate-400'>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className='flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-100 text-lg font-black text-slate-600 transition group-open:rotate-180 dark:bg-dark-800 dark:text-slate-200'>
|
||||
⌄
|
||||
</span>
|
||||
</summary>
|
||||
<div className='border-t border-slate-100 p-5 dark:border-dark-700'>
|
||||
{children}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReviewFlowWorkspace() {
|
||||
const [form, setForm] = useState(defaultForm);
|
||||
const [summary, setSummary] = useState<SummaryResponse | null>(null);
|
||||
const [selected, setSelected] = useState<ReviewRequest | null>(null);
|
||||
const [created, setCreated] = useState<ReviewRequest | null>(null);
|
||||
const [deliveryNotice, setDeliveryNotice] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@ -210,10 +381,12 @@ export default function ReviewFlowWorkspace() {
|
||||
transactions: 0,
|
||||
paymentEvents: 0,
|
||||
};
|
||||
const currentBusinessType = normalizeBusinessType(form.businessType);
|
||||
const filteredReviewDestinationOptions = getReviewDestinationsForBusinessType(currentBusinessType);
|
||||
const selectedReviewDestination =
|
||||
reviewDestinationOptions.find(
|
||||
filteredReviewDestinationOptions.find(
|
||||
(destination) => destination.key === form.reviewDestination,
|
||||
) || reviewDestinationOptions[0];
|
||||
) || filteredReviewDestinationOptions[0];
|
||||
const isHostedReviewDestination = !selectedReviewDestination.requiresLink;
|
||||
|
||||
const previewDate = useMemo(() => {
|
||||
@ -230,6 +403,28 @@ export default function ReviewFlowWorkspace() {
|
||||
try {
|
||||
const response = await axios.get('/reviewflow/summary');
|
||||
setSummary(response.data);
|
||||
const primaryBusiness = response.data.primaryBusiness as ReviewBusiness | null;
|
||||
if (primaryBusiness) {
|
||||
setForm((current) => {
|
||||
const businessType = normalizeBusinessType(primaryBusiness.business_type);
|
||||
const reviewDestination = getAllowedReviewDestination(
|
||||
businessType,
|
||||
primaryBusiness.review_destination || current.reviewDestination,
|
||||
);
|
||||
|
||||
return {
|
||||
...current,
|
||||
businessName:
|
||||
current.businessName === defaultForm.businessName && primaryBusiness.name
|
||||
? primaryBusiness.name
|
||||
: current.businessName,
|
||||
businessType,
|
||||
reviewDestination,
|
||||
reviewLink: getReviewLinkForDestination(primaryBusiness, reviewDestination) || current.reviewLink,
|
||||
delayDays: current.delayDays,
|
||||
};
|
||||
});
|
||||
}
|
||||
if (!selected && response.data.requests?.length) {
|
||||
setSelected(response.data.requests[0]);
|
||||
}
|
||||
@ -270,7 +465,21 @@ export default function ReviewFlowWorkspace() {
|
||||
}, []);
|
||||
|
||||
const updateForm = (key: keyof typeof defaultForm, value: string) => {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
setForm((current) => {
|
||||
if (key === 'businessType') {
|
||||
const businessType = normalizeBusinessType(value);
|
||||
return {
|
||||
...current,
|
||||
businessType,
|
||||
reviewDestination: getAllowedReviewDestination(
|
||||
businessType,
|
||||
current.reviewDestination,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { ...current, [key]: value };
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
@ -278,6 +487,7 @@ export default function ReviewFlowWorkspace() {
|
||||
setIsSubmitting(true);
|
||||
setError('');
|
||||
setCreated(null);
|
||||
setDeliveryNotice('');
|
||||
|
||||
try {
|
||||
const response = await axios.post('/reviewflow/request', {
|
||||
@ -286,8 +496,37 @@ export default function ReviewFlowWorkspace() {
|
||||
delayDays: Number(form.delayDays),
|
||||
});
|
||||
const newRequest = response.data.request;
|
||||
const delivery = response.data.delivery as ReviewDeliveryResponse | null;
|
||||
const deliveryAttempts = delivery?.deliveries?.flatMap(
|
||||
(group) => group.deliveries || [],
|
||||
) || [];
|
||||
const smsAttempt = deliveryAttempts.find(
|
||||
(attempt) => attempt.channel === 'sms',
|
||||
);
|
||||
const failedMessage = delivery?.errors?.[0];
|
||||
const noticeParts: string[] = [];
|
||||
|
||||
if (delivery?.sent) {
|
||||
noticeParts.push('Email sent through SMTP.');
|
||||
}
|
||||
|
||||
if (smsAttempt?.status === 'sent') {
|
||||
noticeParts.push('SMS sent.');
|
||||
} else if (smsAttempt?.status === 'skipped' && smsAttempt.reason) {
|
||||
noticeParts.push(`SMS skipped: ${smsAttempt.reason}`);
|
||||
} else if (smsAttempt?.status === 'failed' && smsAttempt.reason) {
|
||||
noticeParts.push(`SMS failed: ${smsAttempt.reason}`);
|
||||
}
|
||||
|
||||
setDeliveryNotice(noticeParts.join(' '));
|
||||
setCreated(newRequest);
|
||||
setSelected(newRequest);
|
||||
|
||||
if (failedMessage || newRequest.status === 'failed') {
|
||||
setError(
|
||||
`Request was created, but delivery failed: ${failedMessage || newRequest.failure_reason || 'Unknown delivery error'}`,
|
||||
);
|
||||
}
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
customerName: '',
|
||||
@ -316,6 +555,7 @@ export default function ReviewFlowWorkspace() {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
businessName: connectorForm.businessName,
|
||||
businessType: normalizeBusinessType(connectorForm.businessType),
|
||||
reviewDestination: connectorForm.reviewDestination,
|
||||
reviewLink: connectorForm.reviewLink,
|
||||
delayDays: connectorForm.delayDays,
|
||||
@ -349,6 +589,12 @@ export default function ReviewFlowWorkspace() {
|
||||
const isReviewRequestBlocked = Boolean(
|
||||
isSubscriptionInactive || isReviewRequestLimitReached,
|
||||
);
|
||||
const focusMetrics = [
|
||||
['Pending', stats.pending, 'Needs attention'],
|
||||
['Sent', stats.sent, 'Waiting for a customer'],
|
||||
['Clicked', stats.clicked, 'Opened the review link'],
|
||||
['Reviewed', stats.reviewed, 'Completed reviews'],
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -365,33 +611,27 @@ export default function ReviewFlowWorkspace() {
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<div className='mb-6 overflow-hidden rounded-3xl bg-gradient-to-br from-slate-950 via-indigo-950 to-emerald-950 p-6 text-white shadow-2xl'>
|
||||
<div className='grid gap-6 lg:grid-cols-[1.2fr_0.8fr] lg:items-center'>
|
||||
<div className='grid gap-6 lg:grid-cols-[1fr_1fr] lg:items-center'>
|
||||
<div>
|
||||
<p className='mb-3 inline-flex rounded-full bg-white/10 px-4 py-1 text-sm font-semibold text-emerald-200 ring-1 ring-white/20'>
|
||||
Clean workflow · trigger → customer → right review destination
|
||||
Clean workspace · quick request queue · message preview
|
||||
</p>
|
||||
<h2 className='max-w-3xl text-4xl font-black tracking-tight md:text-5xl'>
|
||||
Keep ecommerce triggers and local review destinations cleanly separated.
|
||||
<h2 className='max-w-3xl text-3xl font-black tracking-tight md:text-4xl'>
|
||||
Focus on the review requests that need action now.
|
||||
</h2>
|
||||
<p className='mt-4 max-w-2xl text-base text-slate-200 md:text-lg'>
|
||||
Stripe, Square, PayPal, Shopify, and WooCommerce create customers and transactions from webhooks. Google, Facebook, Yelp, Angi, OpenTable, Trustpilot, and Shopify hosted reviews are treated as review destinations.
|
||||
<p className='mt-4 max-w-2xl text-base text-slate-200'>
|
||||
The essentials stay visible. Setup, payment connectors, and webhook history are now tucked into dropdowns below.
|
||||
</p>
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
{[
|
||||
['Events', stats.paymentEvents],
|
||||
['Payments', stats.transactions],
|
||||
['Pending', stats.pending],
|
||||
['Customers', stats.customers],
|
||||
['Clicked', stats.clicked],
|
||||
['Reviewed', stats.reviewed],
|
||||
].map(([label, value]) => (
|
||||
{focusMetrics.map(([label, value, description]) => (
|
||||
<div
|
||||
key={label}
|
||||
className='rounded-2xl bg-white/10 p-4 ring-1 ring-white/15 backdrop-blur'
|
||||
>
|
||||
<div className='text-3xl font-black'>{value}</div>
|
||||
<div className='text-sm text-slate-300'>{label}</div>
|
||||
<div className='text-sm font-bold text-white'>{label}</div>
|
||||
<div className='text-xs text-slate-300'>{description}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -441,9 +681,16 @@ export default function ReviewFlowWorkspace() {
|
||||
)}
|
||||
|
||||
{created && (
|
||||
<div className='mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-emerald-900'>
|
||||
<strong>Review request queued.</strong> {created.customer?.email} is
|
||||
scheduled for {formatDate(created.scheduled_for)}.
|
||||
<div className={`mb-6 rounded-2xl border p-4 ${created.status === 'failed' ? 'border-rose-200 bg-rose-50 text-rose-900' : 'border-emerald-200 bg-emerald-50 text-emerald-900'}`}>
|
||||
<strong>
|
||||
{created.status === 'sent'
|
||||
? 'Review request sent.'
|
||||
: created.status === 'failed'
|
||||
? 'Review request delivery failed.'
|
||||
: 'Review request scheduled.'}
|
||||
</strong>{' '}
|
||||
{created.customer?.email} {created.status === 'sent' ? 'was emailed' : 'is scheduled'} for {formatDate(created.scheduled_for)}.
|
||||
{deliveryNotice && <p className='mt-2 text-sm'>{deliveryNotice}</p>}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
@ -461,57 +708,19 @@ export default function ReviewFlowWorkspace() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PaymentProviderConnectors
|
||||
className='mb-6'
|
||||
onConnected={handleProviderConnected}
|
||||
/>
|
||||
|
||||
{isStarterPlan && (
|
||||
<CardBox className='mb-6 border-0 bg-gradient-to-br from-indigo-950 to-slate-950 text-white shadow-2xl'>
|
||||
<div className='grid gap-6 lg:grid-cols-[0.8fr_1.2fr] lg:items-center'>
|
||||
<div>
|
||||
<p className='text-sm font-black uppercase tracking-[0.25em] text-emerald-300'>
|
||||
Pro upgrade prompts
|
||||
</p>
|
||||
<h3 className='mt-2 text-3xl font-black'>
|
||||
Unlock advanced reputation growth tools.
|
||||
</h3>
|
||||
<p className='mt-3 text-slate-300'>
|
||||
Starter keeps the core review workflow running. Pro raises limits to 10 business profiles and unlocks the next automation, AI, and marketing modules as they are enabled.
|
||||
</p>
|
||||
<BaseButton
|
||||
href='/subscription'
|
||||
icon={mdiOpenInNew}
|
||||
label='Upgrade to Pro'
|
||||
color='info'
|
||||
className='mt-5'
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-3 md:grid-cols-3'>
|
||||
{proFeaturePrompts.map(([title, copy]) => (
|
||||
<div key={title} className='rounded-2xl bg-white/10 p-4 ring-1 ring-white/15'>
|
||||
<p className='font-black'>{title}</p>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-300'>{copy}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
)}
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-[0.95fr_1.05fr]'>
|
||||
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
|
||||
<div className='mb-6 flex items-start justify-between gap-4'>
|
||||
<div>
|
||||
<p className='text-sm font-bold uppercase tracking-[0.25em] text-emerald-500'>
|
||||
Manual fallback
|
||||
Quick action
|
||||
</p>
|
||||
<h3 className='text-2xl font-black text-slate-900 dark:text-white'>
|
||||
Queue a review request
|
||||
Ask a customer for a review
|
||||
</h3>
|
||||
<p className='mt-2 text-sm text-slate-500 dark:text-slate-400'>
|
||||
Use this when a payment did not come through a webhook, or
|
||||
when you want to test the review queue manually.
|
||||
Enter the customer first. Business setup, destination, timing,
|
||||
and phone are available in Optional setup.
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-2xl bg-emerald-100 p-3 text-emerald-700'>
|
||||
@ -546,57 +755,8 @@ export default function ReviewFlowWorkspace() {
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FormField
|
||||
label='Business and review destination'
|
||||
help='Choose the destination first. Shopify hosted reviews generate a Review Flow form automatically.'
|
||||
>
|
||||
<input
|
||||
required
|
||||
value={form.businessName}
|
||||
onChange={(event) =>
|
||||
updateForm('businessName', event.target.value)
|
||||
}
|
||||
placeholder='Business name'
|
||||
/>
|
||||
<select
|
||||
value={form.reviewDestination}
|
||||
onChange={(event) =>
|
||||
updateForm('reviewDestination', event.target.value)
|
||||
}
|
||||
>
|
||||
{reviewDestinationOptions.map((destination) => (
|
||||
<option key={destination.key} value={destination.key}>
|
||||
{destination.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={isHostedReviewDestination ? 'Hosted review form' : 'External review link'}
|
||||
help={
|
||||
isHostedReviewDestination
|
||||
? 'No external URL needed; the outgoing email points to a hosted /review page.'
|
||||
: 'Use the exact review page where this customer should land.'
|
||||
}
|
||||
>
|
||||
{isHostedReviewDestination ? (
|
||||
<div className='rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm font-semibold text-emerald-900'>
|
||||
Review Flow will create a secure hosted product-review link for this request.
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
required
|
||||
type='url'
|
||||
value={form.reviewLink}
|
||||
onChange={(event) =>
|
||||
updateForm('reviewLink', event.target.value)
|
||||
}
|
||||
placeholder='https://your-review-destination.example/review'
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField
|
||||
label='Customer'
|
||||
help='Webhook payments fill this automatically when the provider sends a customer email.'
|
||||
label='Customer to ask for a review'
|
||||
help='Most manual requests only need a customer name and email. Use Optional setup only when you need to change timing or destination.'
|
||||
>
|
||||
<input
|
||||
value={form.customerName}
|
||||
@ -615,31 +775,101 @@ export default function ReviewFlowWorkspace() {
|
||||
placeholder='customer@example.com'
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label='Delay and phone'
|
||||
help={`Preview: scheduled for ${previewDate}`}
|
||||
|
||||
<ReviewFlowDisclosure
|
||||
eyebrow='Optional setup'
|
||||
title='Business, destination, timing, and phone'
|
||||
description={`${form.businessName} · ${selectedReviewDestination.label} · ${Number(form.delayDays) > 0 ? `scheduled ${previewDate}` : 'sends immediately'}`}
|
||||
className='mb-5 shadow-none'
|
||||
>
|
||||
<input
|
||||
min='0'
|
||||
max='30'
|
||||
type='number'
|
||||
value={form.delayDays}
|
||||
onChange={(event) =>
|
||||
updateForm('delayDays', event.target.value)
|
||||
<FormField
|
||||
label='Business type and review destination'
|
||||
help={businessTypeOptions.find((option) => option.key === currentBusinessType)?.help}
|
||||
>
|
||||
<input
|
||||
required
|
||||
value={form.businessName}
|
||||
onChange={(event) =>
|
||||
updateForm('businessName', event.target.value)
|
||||
}
|
||||
placeholder='Business name'
|
||||
/>
|
||||
<select
|
||||
value={form.businessType}
|
||||
onChange={(event) =>
|
||||
updateForm('businessType', event.target.value)
|
||||
}
|
||||
>
|
||||
{businessTypeOptions.map((option) => (
|
||||
<option key={option.key} value={option.key}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={form.reviewDestination}
|
||||
onChange={(event) =>
|
||||
updateForm('reviewDestination', event.target.value)
|
||||
}
|
||||
>
|
||||
{filteredReviewDestinationOptions.map((destination) => (
|
||||
<option key={destination.key} value={destination.key}>
|
||||
{destination.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={isHostedReviewDestination ? 'Hosted review form' : 'External review link'}
|
||||
help={
|
||||
isHostedReviewDestination
|
||||
? 'No external URL needed; the outgoing email points to a hosted /review page.'
|
||||
: 'Use the exact review page where this customer should land.'
|
||||
}
|
||||
placeholder='Delay days'
|
||||
/>
|
||||
<input
|
||||
value={form.phone}
|
||||
onChange={(event) => updateForm('phone', event.target.value)}
|
||||
placeholder='Optional phone'
|
||||
/>
|
||||
</FormField>
|
||||
>
|
||||
{isHostedReviewDestination ? (
|
||||
<div className='rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm font-semibold text-emerald-900'>
|
||||
Review Flow will create a secure hosted product-review link for this request.
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
required
|
||||
type='url'
|
||||
value={form.reviewLink}
|
||||
onChange={(event) =>
|
||||
updateForm('reviewLink', event.target.value)
|
||||
}
|
||||
placeholder='https://your-review-destination.example/review'
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField
|
||||
label='Delay and phone'
|
||||
help={`Preview: scheduled for ${previewDate}`}
|
||||
>
|
||||
<input
|
||||
min='0'
|
||||
max='30'
|
||||
type='number'
|
||||
value={form.delayDays}
|
||||
onChange={(event) =>
|
||||
updateForm('delayDays', event.target.value)
|
||||
}
|
||||
placeholder='Delay days'
|
||||
/>
|
||||
<input
|
||||
value={form.phone}
|
||||
onChange={(event) => updateForm('phone', event.target.value)}
|
||||
placeholder='Optional phone'
|
||||
/>
|
||||
</FormField>
|
||||
</ReviewFlowDisclosure>
|
||||
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
<BaseButton
|
||||
type='submit'
|
||||
icon={mdiSend}
|
||||
label={isSubmitting ? 'Queueing...' : 'Queue review request'}
|
||||
label={isSubmitting ? 'Sending...' : Number(form.delayDays) > 0 ? 'Schedule review request' : 'Send review request now'}
|
||||
color='info'
|
||||
disabled={isSubmitting || isReviewRequestBlocked}
|
||||
/>
|
||||
@ -790,7 +1020,58 @@ export default function ReviewFlowWorkspace() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 grid gap-6 lg:grid-cols-2'>
|
||||
<div className='mt-6 space-y-6'>
|
||||
<ReviewFlowDisclosure
|
||||
eyebrow='Setup'
|
||||
title='Connect payment and order sources'
|
||||
description='Use this when you want Review Flow to create requests automatically from Stripe, Square, PayPal, Shopify, or WooCommerce.'
|
||||
>
|
||||
<PaymentProviderConnectors
|
||||
initialBusinessType={currentBusinessType}
|
||||
onConnected={handleProviderConnected}
|
||||
/>
|
||||
</ReviewFlowDisclosure>
|
||||
|
||||
{isStarterPlan && (
|
||||
<ReviewFlowDisclosure
|
||||
eyebrow='Pro upgrade'
|
||||
title='Higher limits and advanced growth tools'
|
||||
description='Kept out of the way unless you want to compare what Pro unlocks.'
|
||||
>
|
||||
<div className='grid gap-6 lg:grid-cols-[0.8fr_1.2fr] lg:items-center'>
|
||||
<div>
|
||||
<h3 className='text-2xl font-black text-slate-900 dark:text-white'>
|
||||
Unlock higher Review Flow limits.
|
||||
</h3>
|
||||
<p className='mt-3 text-slate-500 dark:text-slate-400'>
|
||||
Grow keeps set-it-and-forget-it review automation running. Pro adds AI replies, referrals, NPS, broadcasts, rebooking, competitor insights, and higher limits.
|
||||
</p>
|
||||
<BaseButton
|
||||
href='/subscription'
|
||||
icon={mdiOpenInNew}
|
||||
label='Upgrade to Pro'
|
||||
color='info'
|
||||
className='mt-5'
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-3 md:grid-cols-3'>
|
||||
{proFeaturePrompts.map(([title, copy]) => (
|
||||
<div key={title} className='rounded-2xl bg-slate-50 p-4 ring-1 ring-slate-200 dark:bg-dark-800 dark:ring-dark-700'>
|
||||
<p className='font-black text-slate-900 dark:text-white'>{title}</p>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400'>{copy}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ReviewFlowDisclosure>
|
||||
)}
|
||||
|
||||
<ReviewFlowDisclosure
|
||||
eyebrow='Operational history'
|
||||
title='Payment events and transactions'
|
||||
description={`${stats.paymentEvents} webhook events · ${stats.transactions} transactions. Open this only when you need troubleshooting details.`}
|
||||
>
|
||||
<div className='grid gap-6 lg:grid-cols-2'>
|
||||
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
|
||||
<div className='mb-5 flex items-center justify-between'>
|
||||
<div>
|
||||
@ -891,6 +1172,8 @@ export default function ReviewFlowWorkspace() {
|
||||
)}
|
||||
</CardBox>
|
||||
</div>
|
||||
</ReviewFlowDisclosure>
|
||||
</div>
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -234,7 +234,7 @@ export default function SubscriptionPage() {
|
||||
<strong>{missingConfiguration.join(', ')}</strong>.
|
||||
</p>
|
||||
<p className='mt-2 text-sm font-semibold'>
|
||||
Create monthly Stripe Prices for Starter and Pro, paste their Price IDs into the matching variables, add your webhook secret, then reload the backend.
|
||||
Create monthly Stripe Prices for Grow and Pro, paste their Price IDs into the matching variables, add your webhook secret, then reload the backend.
|
||||
</p>
|
||||
</CardBox>
|
||||
)}
|
||||
|
||||
@ -698,7 +698,6 @@ const EditUsers = () => {
|
||||
EditUsers.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'UPDATE_USERS'}
|
||||
|
||||
|
||||
@ -695,7 +695,6 @@ const EditUsersPage = () => {
|
||||
EditUsersPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'UPDATE_USERS'}
|
||||
|
||||
|
||||
@ -154,7 +154,6 @@ const UsersTablesPage = () => {
|
||||
UsersTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'READ_USERS'}
|
||||
|
||||
|
||||
@ -24,9 +24,10 @@ import { SelectFieldMany } from "../../components/SelectFieldMany";
|
||||
import {RichTextField} from "../../components/RichTextField";
|
||||
|
||||
import { create } from '../../stores/users/usersSlice'
|
||||
import { useAppDispatch } from '../../stores/hooks'
|
||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
|
||||
import { useRouter } from 'next/router'
|
||||
import moment from 'moment';
|
||||
import { isInternalAdmin } from '../../helpers/portalRoles';
|
||||
|
||||
const initialValues = {
|
||||
|
||||
@ -164,6 +165,8 @@ const initialValues = {
|
||||
const UsersNew = () => {
|
||||
const router = useRouter()
|
||||
const dispatch = useAppDispatch()
|
||||
const { currentUser } = useAppSelector((state) => state.auth)
|
||||
const canManageAccessControls = isInternalAdmin(currentUser)
|
||||
|
||||
|
||||
|
||||
@ -432,47 +435,55 @@ const UsersNew = () => {
|
||||
|
||||
|
||||
|
||||
<FormField label="App Role" labelFor="app_role">
|
||||
<Field name="app_role" id="app_role" component={SelectField} options={[]} itemRef={'roles'}></Field>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label='Custom Permissions' labelFor='custom_permissions'>
|
||||
<Field
|
||||
name='custom_permissions'
|
||||
id='custom_permissions'
|
||||
itemRef={'permissions'}
|
||||
options={[]}
|
||||
component={SelectFieldMany}>
|
||||
</Field>
|
||||
{canManageAccessControls ? (
|
||||
<FormField label="App Role" labelFor="app_role">
|
||||
<Field name="app_role" id="app_role" component={SelectField} options={[]} itemRef={'roles'}></Field>
|
||||
</FormField>
|
||||
) : (
|
||||
<div className='mb-4 rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm font-semibold text-emerald-900'>
|
||||
Team members invited from the customer workspace receive the Operations Manager role by default.
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
{canManageAccessControls && (
|
||||
<FormField label='Custom Permissions' labelFor='custom_permissions'>
|
||||
<Field
|
||||
name='custom_permissions'
|
||||
id='custom_permissions'
|
||||
itemRef={'permissions'}
|
||||
options={[]}
|
||||
component={SelectFieldMany}>
|
||||
</Field>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
@ -495,7 +506,6 @@ const UsersNew = () => {
|
||||
UsersNew.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'CREATE_USERS'}
|
||||
|
||||
|
||||
@ -152,7 +152,6 @@ const UsersTablesPage = () => {
|
||||
UsersTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'READ_USERS'}
|
||||
|
||||
|
||||
@ -585,7 +585,6 @@ const UsersView = () => {
|
||||
UsersView.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'READ_USERS'}
|
||||
|
||||
|
||||
@ -21,12 +21,12 @@ export const trialDays = 14;
|
||||
export const subscriptionPlans: SubscriptionPlan[] = [
|
||||
{
|
||||
id: 'starter',
|
||||
name: 'Starter',
|
||||
name: 'Grow',
|
||||
priceMonthly: 49,
|
||||
currency: 'USD',
|
||||
trialDays,
|
||||
tagline: 'For small teams that want automated review collection without extra marketing automation.',
|
||||
ctaLabel: 'Start Starter trial',
|
||||
tagline: 'For review automation that runs after setup: requests, reminders, widgets, and clean local/online routing.',
|
||||
ctaLabel: 'Start Grow trial',
|
||||
limits: {
|
||||
monthlyReviewRequests: 250,
|
||||
businesses: 1,
|
||||
@ -34,17 +34,16 @@ export const subscriptionPlans: SubscriptionPlan[] = [
|
||||
paymentConnectors: 5,
|
||||
},
|
||||
features: [
|
||||
'Review Flow dashboard',
|
||||
'Manual review request creation',
|
||||
'Hosted public review form',
|
||||
'Customer management',
|
||||
'Business profile management',
|
||||
'Transaction tracking',
|
||||
'Set-it-and-forget-it review request automation',
|
||||
'Local, online, or Hybrid business setup',
|
||||
'Automatic review requests from payments and orders',
|
||||
'Manual review request queue',
|
||||
'Hosted public product-review form',
|
||||
'Review monitoring dashboard and queue',
|
||||
'Embeddable social proof review widget',
|
||||
'Customer, business, and transaction management',
|
||||
'Stripe, Square, PayPal, Shopify, and WooCommerce webhook intake',
|
||||
'Review request status tracking',
|
||||
'Email delivery logs',
|
||||
'Basic reporting',
|
||||
'Standard support',
|
||||
'Basic usage reporting',
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -53,7 +52,7 @@ export const subscriptionPlans: SubscriptionPlan[] = [
|
||||
priceMonthly: 99,
|
||||
currency: 'USD',
|
||||
trialDays,
|
||||
tagline: 'For growing businesses that want automation, AI assistance, and reputation marketing tools.',
|
||||
tagline: 'For teams that want AI replies, referrals, NPS, broadcasts, rebooking campaigns, and competitor insight tools.',
|
||||
highlight: 'Best value',
|
||||
ctaLabel: 'Start Pro trial',
|
||||
limits: {
|
||||
@ -63,19 +62,16 @@ export const subscriptionPlans: SubscriptionPlan[] = [
|
||||
paymentConnectors: 5,
|
||||
},
|
||||
features: [
|
||||
'Everything in Starter',
|
||||
'Advanced automation rules',
|
||||
'Everything in Grow',
|
||||
'AI review reply assistant',
|
||||
'Social proof widgets',
|
||||
'Review monitoring workspace',
|
||||
'Referral campaigns',
|
||||
'Repeat booking reminders',
|
||||
'NPS surveys',
|
||||
'Competitor/reputation insights',
|
||||
'Broadcast campaigns',
|
||||
'Advanced reporting',
|
||||
'Branding customization',
|
||||
'Priority support',
|
||||
'Referral campaign queueing',
|
||||
'NPS survey campaign queueing',
|
||||
'Marketing broadcasts and repeat-business campaigns',
|
||||
'Competitor insight workspace',
|
||||
'2,500 review requests per month',
|
||||
'Up to 10 business profiles',
|
||||
'Up to 10 team members',
|
||||
'Subscription usage dashboard and upgrade controls',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user