Compare commits

..

6 Commits

Author SHA1 Message Date
Flatlogic Bot
30e91e2b17 Autosave: 20260629-224348 2026-06-29 22:43:42 +00:00
Flatlogic Bot
f741ab0364 Base 2026-06-29 19:07:34 +00:00
Flatlogic Bot
c7ec13b78b Autosave: 20260629-175315 2026-06-29 17:53:10 +00:00
Flatlogic Bot
3dfd47bae8 Autosave: 20260629-071644 2026-06-29 07:16:39 +00:00
Flatlogic Bot
df9c6cb725 Autosave: 20260629-060213 2026-06-29 06:02:08 +00:00
Flatlogic Bot
e4186ae090 Autosave: 20260629-035900 2026-06-29 03:58:56 +00:00
99 changed files with 12805 additions and 975 deletions

View File

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

View File

@ -36,6 +36,7 @@
"sequelize": "6.35.2",
"sequelize-json-schema": "^2.1.1",
"sqlite": "4.0.15",
"stripe": "^22.3.0",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.0",
"tedious": "^18.2.4"

View File

@ -65,6 +65,12 @@ const config = {
gpt_key: process.env.GPT_KEY || '',
stripe: {
secretKey: process.env.STRIPE_SECRET_KEY || '',
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
starterPriceId: process.env.STRIPE_STARTER_PRICE_ID || '',
proPriceId: process.env.STRIPE_PRO_PRICE_ID || '',
},
};
config.pexelsKey = process.env.PEXELS_KEY || '';

View File

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

View File

@ -1,7 +1,5 @@
const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils');
@ -382,15 +380,12 @@ module.exports = class Review_requestsDBApi {
offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
let include = [
{
model: db.businesses,
as: 'business',
required: Boolean(filter.business),
where: filter.business ? {
[Op.or]: [
@ -408,6 +403,7 @@ module.exports = class Review_requestsDBApi {
{
model: db.customers,
as: 'customer',
required: Boolean(filter.customer),
where: filter.customer ? {
[Op.or]: [
@ -425,6 +421,7 @@ module.exports = class Review_requestsDBApi {
{
model: db.transactions,
as: 'transaction',
required: Boolean(filter.transaction),
where: filter.transaction ? {
[Op.or]: [

View File

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

View File

@ -0,0 +1,112 @@
'use strict';
const businessColumns = {
stripe_webhook_token: { type: 'TEXT' },
square_account_reference: { type: 'TEXT' },
square_connected: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
square_connected_at: { type: 'DATE' },
square_webhook_token: { type: 'TEXT' },
paypal_merchant_reference: { type: 'TEXT' },
paypal_connected: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
paypal_connected_at: { type: 'DATE' },
paypal_webhook_token: { type: 'TEXT' },
};
const customerColumns = {
square_customer_reference: { type: 'TEXT' },
paypal_customer_reference: { type: 'TEXT' },
};
const transactionColumns = {
businessId: { type: 'UUID', references: { model: 'businesses', key: 'id' } },
payment_provider: { type: 'TEXT' },
square_payment_reference: { type: 'TEXT' },
paypal_payment_reference: { type: 'TEXT' },
provider_event_reference: { type: 'TEXT' },
};
const eventColumns = {
provider: { type: 'TEXT' },
provider_event_type: { type: 'TEXT' },
};
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 === 'DATE') {
normalized.type = Sequelize.DataTypes.DATE;
}
if (definition.type === 'UUID') {
normalized.type = Sequelize.DataTypes.UUID;
}
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 addColumnsIfMissing(queryInterface, Sequelize, transaction, 'customers', customerColumns);
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'transactions', transactionColumns);
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'stripe_events', eventColumns);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
async down(queryInterface) {
const transaction = await queryInterface.sequelize.transaction();
try {
await removeColumnsIfPresent(queryInterface, transaction, 'stripe_events', eventColumns);
await removeColumnsIfPresent(queryInterface, transaction, 'transactions', transactionColumns);
await removeColumnsIfPresent(queryInterface, transaction, 'customers', customerColumns);
await removeColumnsIfPresent(queryInterface, transaction, 'businesses', businessColumns);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
};

View File

@ -0,0 +1,97 @@
'use strict';
const businessColumns = {
shopify_store_reference: { type: 'TEXT' },
shopify_connected: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
shopify_connected_at: { type: 'DATE' },
shopify_webhook_token: { type: 'TEXT' },
woocommerce_store_reference: { type: 'TEXT' },
woocommerce_connected: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
woocommerce_connected_at: { type: 'DATE' },
woocommerce_webhook_token: { type: 'TEXT' },
};
const customerColumns = {
shopify_customer_reference: { type: 'TEXT' },
woocommerce_customer_reference: { type: 'TEXT' },
};
const transactionColumns = {
shopify_order_reference: { type: 'TEXT' },
woocommerce_order_reference: { type: 'TEXT' },
};
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 === 'DATE') {
normalized.type = Sequelize.DataTypes.DATE;
}
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 addColumnsIfMissing(queryInterface, Sequelize, transaction, 'customers', customerColumns);
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'transactions', transactionColumns);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
async down(queryInterface) {
const transaction = await queryInterface.sequelize.transaction();
try {
await removeColumnsIfPresent(queryInterface, transaction, 'transactions', transactionColumns);
await removeColumnsIfPresent(queryInterface, transaction, 'customers', customerColumns);
await removeColumnsIfPresent(queryInterface, transaction, 'businesses', businessColumns);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
};

View File

@ -0,0 +1,96 @@
'use strict';
const businessColumns = {
review_destination: { type: 'TEXT' },
trustpilot_review_link: { type: 'TEXT' },
angi_review_link: { type: 'TEXT' },
opentable_review_link: { type: 'TEXT' },
shopify_hosted_reviews_enabled: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
};
const reviewRequestColumns = {
review_platform: { type: 'TEXT' },
review_rating: { type: 'INTEGER' },
review_title: { type: 'TEXT' },
review_content: { type: 'TEXT' },
reviewer_display_name: { type: 'TEXT' },
review_payload_json: { type: 'TEXT' },
submitted_at: { type: 'DATE' },
};
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 === 'DATE') {
normalized.type = Sequelize.DataTypes.DATE;
}
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 addColumnsIfMissing(queryInterface, Sequelize, transaction, 'review_requests', reviewRequestColumns);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
async down(queryInterface) {
const transaction = await queryInterface.sequelize.transaction();
try {
await removeColumnsIfPresent(queryInterface, transaction, 'review_requests', reviewRequestColumns);
await removeColumnsIfPresent(queryInterface, transaction, 'businesses', businessColumns);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
};

View File

@ -0,0 +1,87 @@
'use strict';
const userColumns = {
subscriptionPlanId: { type: 'TEXT', allowNull: false, defaultValue: 'starter' },
subscriptionStatus: { type: 'TEXT', allowNull: false, defaultValue: 'trialing' },
trialStartedAt: { type: 'DATE' },
trialEndsAt: { type: 'DATE' },
subscriptionStartedAt: { type: 'DATE' },
subscriptionEndsAt: { type: 'DATE' },
subscriptionCanceledAt: { type: 'DATE' },
};
function normalizeColumnDefinition(Sequelize, definition) {
const normalized = { ...definition };
if (definition.type === 'TEXT') {
normalized.type = Sequelize.DataTypes.TEXT;
}
if (definition.type === 'DATE') {
normalized.type = Sequelize.DataTypes.DATE;
}
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, 'users', userColumns);
await queryInterface.sequelize.query(
`UPDATE "users"
SET "subscriptionPlanId" = COALESCE("subscriptionPlanId", 'starter'),
"subscriptionStatus" = COALESCE("subscriptionStatus", 'trialing'),
"trialStartedAt" = COALESCE("trialStartedAt", NOW()),
"trialEndsAt" = COALESCE("trialEndsAt", NOW() + INTERVAL '14 days')
WHERE "deletedAt" IS NULL`,
{ transaction },
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
async down(queryInterface) {
const transaction = await queryInterface.sequelize.transaction();
try {
await removeColumnsIfPresent(queryInterface, transaction, 'users', userColumns);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
};

View File

@ -0,0 +1,74 @@
'use strict';
const userColumns = {
stripeCustomerId: { type: 'TEXT' },
stripeSubscriptionId: { type: 'TEXT' },
stripePriceId: { type: 'TEXT' },
stripeCheckoutSessionId: { type: 'TEXT' },
stripeCurrentPeriodEndAt: { type: 'DATE' },
};
function normalizeColumnDefinition(Sequelize, definition) {
const normalized = { ...definition };
if (definition.type === 'TEXT') {
normalized.type = Sequelize.DataTypes.TEXT;
}
if (definition.type === 'DATE') {
normalized.type = Sequelize.DataTypes.DATE;
}
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, 'users', userColumns);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
async down(queryInterface) {
const transaction = await queryInterface.sequelize.transaction();
try {
await removeColumnsIfPresent(queryInterface, transaction, 'users', userColumns);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
};

View File

@ -0,0 +1,89 @@
'use strict';
const businessColumns = {
business_type: { type: 'TEXT', defaultValue: 'hybrid', allowNull: false },
automation_mode: { type: 'TEXT', defaultValue: 'set_and_forget', allowNull: false },
followup_enabled: { type: 'BOOLEAN', defaultValue: true, allowNull: false },
followup_delay_days: { type: 'INTEGER', defaultValue: 3, allowNull: false },
max_followups: { type: 'INTEGER', defaultValue: 1, allowNull: false },
ai_reply_enabled: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
referral_enabled: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
referral_offer: { type: 'TEXT' },
nps_enabled: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
nps_question: { type: 'TEXT' },
social_widget_enabled: { type: 'BOOLEAN', defaultValue: true, allowNull: false },
broadcast_enabled: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
rebooking_enabled: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
competitor_insights_enabled: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
competitor_urls: { type: 'TEXT' },
review_widget_theme: { type: 'TEXT', defaultValue: 'light', allowNull: false },
};
function normalizeColumnDefinition(Sequelize, definition) {
const normalized = { ...definition };
if (definition.type === 'TEXT') {
normalized.type = Sequelize.DataTypes.TEXT;
}
if (definition.type === 'BOOLEAN') {
normalized.type = Sequelize.DataTypes.BOOLEAN;
}
if (definition.type === 'INTEGER') {
normalized.type = Sequelize.DataTypes.INTEGER;
}
return normalized;
}
async function addColumnsIfMissing(queryInterface, Sequelize, transaction, tableName, columns) {
const table = await queryInterface.describeTable(tableName);
for (const [columnName, definition] of Object.entries(columns)) {
if (!table[columnName]) {
await queryInterface.addColumn(
tableName,
columnName,
normalizeColumnDefinition(Sequelize, definition),
{ transaction },
);
}
}
}
async function removeColumnsIfPresent(queryInterface, transaction, tableName, columns) {
const table = await queryInterface.describeTable(tableName);
for (const columnName of Object.keys(columns).reverse()) {
if (table[columnName]) {
await queryInterface.removeColumn(tableName, columnName, { transaction });
}
}
}
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'businesses', businessColumns);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
async down(queryInterface) {
const transaction = await queryInterface.sequelize.transaction();
try {
await removeColumnsIfPresent(queryInterface, transaction, 'businesses', businessColumns);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
};

View File

@ -1,9 +1,3 @@
const config = require('../../config');
const providers = config.providers;
const crypto = require('crypto');
const bcrypt = require('bcrypt');
const moment = require('moment');
module.exports = function(sequelize, DataTypes) {
const businesses = sequelize.define(
'businesses',
@ -95,6 +89,138 @@ stripe_connected_at: {
},
stripe_webhook_token: {
type: DataTypes.TEXT,
},
square_account_reference: {
type: DataTypes.TEXT,
},
square_connected: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
square_connected_at: {
type: DataTypes.DATE,
},
square_webhook_token: {
type: DataTypes.TEXT,
},
paypal_merchant_reference: {
type: DataTypes.TEXT,
},
paypal_connected: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
paypal_connected_at: {
type: DataTypes.DATE,
},
paypal_webhook_token: {
type: DataTypes.TEXT,
},
shopify_store_reference: {
type: DataTypes.TEXT,
},
shopify_connected: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
shopify_connected_at: {
type: DataTypes.DATE,
},
shopify_webhook_token: {
type: DataTypes.TEXT,
},
woocommerce_store_reference: {
type: DataTypes.TEXT,
},
woocommerce_connected: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
woocommerce_connected_at: {
type: DataTypes.DATE,
},
woocommerce_webhook_token: {
type: DataTypes.TEXT,
},
default_review_platform: {
@ -124,6 +250,135 @@ custom_review_link: {
},
review_destination: {
type: DataTypes.TEXT,
},
trustpilot_review_link: {
type: DataTypes.TEXT,
},
angi_review_link: {
type: DataTypes.TEXT,
},
opentable_review_link: {
type: DataTypes.TEXT,
},
shopify_hosted_reviews_enabled: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
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: {
@ -176,6 +431,14 @@ custom_review_link: {
constraints: false,
});
db.businesses.hasMany(db.transactions, {
as: 'transactions_business',
foreignKey: {
name: 'businessId',
},
constraints: false,
});

View File

@ -1,9 +1,3 @@
const config = require('../../config');
const providers = config.providers;
const crypto = require('crypto');
const bcrypt = require('bcrypt');
const moment = require('moment');
module.exports = function(sequelize, DataTypes) {
const customers = sequelize.define(
'customers',
@ -40,6 +34,35 @@ stripe_customer_reference: {
},
square_customer_reference: {
type: DataTypes.TEXT,
},
paypal_customer_reference: {
type: DataTypes.TEXT,
},
shopify_customer_reference: {
type: DataTypes.TEXT,
},
woocommerce_customer_reference: {
type: DataTypes.TEXT,
},
contact_status: {

View File

@ -1,9 +1,3 @@
const config = require('../../config');
const providers = config.providers;
const crypto = require('crypto');
const bcrypt = require('bcrypt');
const moment = require('moment');
module.exports = function(sequelize, DataTypes) {
const review_requests = sequelize.define(
'review_requests',
@ -113,6 +107,55 @@ tracking_token: {
},
review_platform: {
type: DataTypes.TEXT,
},
review_rating: {
type: DataTypes.INTEGER,
},
review_title: {
type: DataTypes.TEXT,
},
review_content: {
type: DataTypes.TEXT,
},
reviewer_display_name: {
type: DataTypes.TEXT,
},
review_payload_json: {
type: DataTypes.TEXT,
},
submitted_at: {
type: DataTypes.DATE,
},
importHash: {

View File

@ -1,9 +1,3 @@
const config = require('../../config');
const providers = config.providers;
const crypto = require('crypto');
const bcrypt = require('bcrypt');
const moment = require('moment');
module.exports = function(sequelize, DataTypes) {
const stripe_events = sequelize.define(
'stripe_events',
@ -19,6 +13,20 @@ stripe_event_reference: {
},
provider: {
type: DataTypes.TEXT,
},
provider_event_type: {
type: DataTypes.TEXT,
},
event_type: {

View File

@ -1,9 +1,3 @@
const config = require('../../config');
const providers = config.providers;
const crypto = require('crypto');
const bcrypt = require('bcrypt');
const moment = require('moment');
module.exports = function(sequelize, DataTypes) {
const transactions = sequelize.define(
'transactions',
@ -19,6 +13,49 @@ stripe_payment_reference: {
},
payment_provider: {
type: DataTypes.TEXT,
},
square_payment_reference: {
type: DataTypes.TEXT,
},
paypal_payment_reference: {
type: DataTypes.TEXT,
},
shopify_order_reference: {
type: DataTypes.TEXT,
},
woocommerce_order_reference: {
type: DataTypes.TEXT,
},
provider_event_reference: {
type: DataTypes.TEXT,
},
amount: {
@ -131,6 +168,14 @@ receipt_email: {
constraints: false,
});
db.transactions.belongsTo(db.businesses, {
as: 'business',
foreignKey: {
name: 'businessId',
},
constraints: false,
});

View File

@ -2,7 +2,6 @@ const config = require('../../config');
const providers = config.providers;
const crypto = require('crypto');
const bcrypt = require('bcrypt');
const moment = require('moment');
module.exports = function(sequelize, DataTypes) {
const users = sequelize.define(
@ -104,6 +103,72 @@ provider: {
},
subscriptionPlanId: {
type: DataTypes.TEXT,
allowNull: false,
defaultValue: 'starter',
},
subscriptionStatus: {
type: DataTypes.TEXT,
allowNull: false,
defaultValue: 'trialing',
},
trialStartedAt: {
type: DataTypes.DATE,
},
trialEndsAt: {
type: DataTypes.DATE,
},
subscriptionStartedAt: {
type: DataTypes.DATE,
},
subscriptionEndsAt: {
type: DataTypes.DATE,
},
subscriptionCanceledAt: {
type: DataTypes.DATE,
},
stripeCustomerId: {
type: DataTypes.TEXT,
},
stripeSubscriptionId: {
type: DataTypes.TEXT,
},
stripePriceId: {
type: DataTypes.TEXT,
},
stripeCheckoutSessionId: {
type: DataTypes.TEXT,
},
stripeCurrentPeriodEndAt: {
type: DataTypes.DATE,
},
importHash: {
type: DataTypes.STRING(255),
allowNull: true,
@ -195,8 +260,8 @@ provider: {
};
users.beforeCreate((users, options) => {
users = trimStringFields(users);
users.beforeCreate((users) => {
trimStringFields(users);
if (users.provider !== providers.LOCAL && Object.values(providers).indexOf(users.provider) > -1) {
users.emailVerified = true;
@ -216,8 +281,8 @@ provider: {
}
});
users.beforeUpdate((users, options) => {
users = trimStringFields(users);
users.beforeUpdate((users) => {
trimStringFields(users);
});

View File

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

View File

@ -6,7 +6,6 @@ const passport = require('passport');
const path = require('path');
const fs = require('fs');
const bodyParser = require('body-parser');
const db = require('./db/models');
const config = require('./config');
const swaggerUI = require('swagger-ui-express');
const swaggerJsDoc = require('swagger-jsdoc');
@ -16,6 +15,9 @@ const fileRoutes = require('./routes/file');
const searchRoutes = require('./routes/search');
const sqlRoutes = require('./routes/sql');
const pexelsRoutes = require('./routes/pexels');
const plansRoutes = require('./routes/plans');
const subscriptionRoutes = require('./routes/subscription');
const subscriptionWebhookRoutes = require('./routes/subscription-webhooks');
const openaiRoutes = require('./routes/openai');
@ -35,6 +37,10 @@ const transactionsRoutes = require('./routes/transactions');
const review_requestsRoutes = require('./routes/review_requests');
const reviewflowRoutes = require('./routes/reviewflow');
const reviewflowWebhooksRoutes = require('./routes/reviewflow-webhooks');
const reviewflowPublicRoutes = require('./routes/reviewflow-public');
const stripe_eventsRoutes = require('./routes/stripe_events');
const email_delivery_logsRoutes = require('./routes/email_delivery_logs');
@ -91,11 +97,15 @@ app.use('/api-docs', function (req, res, next) {
app.use(cors({origin: true}));
require('./auth/auth');
app.use('/api/subscription/stripe-webhook', bodyParser.raw({type: 'application/json'}), subscriptionWebhookRoutes);
app.use(bodyParser.json());
app.use('/api/auth', authRoutes);
app.use('/api/file', fileRoutes);
app.use('/api/pexels', pexelsRoutes);
app.use('/api/plans', plansRoutes);
app.use('/api/subscription', passport.authenticate('jwt', {session: false}), subscriptionRoutes);
app.enable('trust proxy');
@ -113,6 +123,12 @@ app.use('/api/transactions', passport.authenticate('jwt', {session: false}), tra
app.use('/api/review_requests', passport.authenticate('jwt', {session: false}), review_requestsRoutes);
app.use('/api/reviewflow', passport.authenticate('jwt', {session: false}), reviewflowRoutes);
app.use('/api/reviewflow-webhooks', reviewflowWebhooksRoutes);
app.use('/api/reviewflow-public', reviewflowPublicRoutes);
app.use('/api/stripe_events', passport.authenticate('jwt', {session: false}), stripe_eventsRoutes);
app.use('/api/email_delivery_logs', passport.authenticate('jwt', {session: false}), email_delivery_logsRoutes);

View File

@ -0,0 +1,12 @@
const express = require('express');
const { getSubscriptionPlans } = require('../services/subscriptionPlans');
const router = express.Router();
router.get('/', (req, res) => {
res.status(200).send({
plans: getSubscriptionPlans(),
});
});
module.exports = router;

View File

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

View File

@ -0,0 +1,30 @@
const express = require('express');
const ReviewFlowService = require('../services/reviewflow');
const wrapAsync = require('../helpers').wrapAsync;
const router = express.Router();
router.post('/:provider/:businessId/:secretToken', wrapAsync(async (req, res) => {
const result = await ReviewFlowService.processPaymentWebhook(
req.params.provider,
req.params.businessId,
req.params.secretToken,
req.body,
req.headers,
);
res.status(200).send({ received: true, ...result });
}));
router.get('/:provider/:businessId/:secretToken', wrapAsync(async (req, res) => {
ReviewFlowService.getProviderConfig(req.params.provider);
res.status(200).send({
ok: true,
message: 'ReviewFlow webhook URL is reachable. Configure your payment provider to POST JSON events to this same URL.',
});
}));
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;

View File

@ -0,0 +1,482 @@
const express = require('express');
const crypto = require('crypto');
const db = require('../db/models');
const ReviewFlowService = require('../services/reviewflow');
const SubscriptionService = require('../services/subscription');
const wrapAsync = require('../helpers').wrapAsync;
const router = express.Router();
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function normalizeString(value) {
return typeof value === 'string' ? value.trim() : '';
}
function requireField(value, message) {
if (!normalizeString(value)) {
const error = new Error(message);
error.code = 400;
throw error;
}
}
function validateUrl(value, message) {
try {
const parsed = new URL(value);
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error(message);
}
} catch {
const validationError = new Error(message);
validationError.code = 400;
throw validationError;
}
}
const REVIEW_LINK_FIELDS = {
google: 'google_review_link',
yelp: 'yelp_review_link',
facebook: 'facebook_review_link',
trustpilot: 'trustpilot_review_link',
angi: 'angi_review_link',
opentable: 'opentable_review_link',
custom: 'custom_review_link',
};
function normalizeReviewDestination(value) {
const destination = normalizeString(value).toLowerCase();
if (ReviewFlowService.REVIEW_CHANNELS[destination]) {
return destination;
}
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;
}
function buildEmailBody(customerName, businessName, reviewLink) {
const greetingName = customerName || 'there';
return [
`Hi ${greetingName},`,
'',
`Thank you for choosing ${businessName}. We would love to hear about your experience.`,
'',
`Leave a review: ${reviewLink}`,
'',
`Thank you,`,
businessName,
].join('\n');
}
router.get('/review-channels', wrapAsync(async (req, res) => {
res.status(200).send({ channels: ReviewFlowService.serializeReviewChannels() });
}));
router.get('/connectors', wrapAsync(async (req, res) => {
const businesses = await ReviewFlowService.listConnectorBusinesses(req.currentUser, req);
res.status(200).send({ businesses });
}));
router.post('/connectors', wrapAsync(async (req, res) => {
const business = await ReviewFlowService.connectProvider(req.currentUser, req.body || {}, req);
res.status(200).send({ business });
}));
router.post('/connectors/:businessId/:provider/rotate', wrapAsync(async (req, res) => {
const business = await ReviewFlowService.rotateWebhookToken(
req.currentUser,
req.params.businessId,
req.params.provider,
req,
);
res.status(200).send({ business });
}));
router.get('/summary', wrapAsync(async (req, res) => {
const currentUser = req.currentUser;
const limit = Math.min(Number(req.query.limit) || 8, 25);
const requests = await db.review_requests.findAll({
where: { createdById: currentUser.id },
include: [
{ model: db.businesses, as: 'business' },
{ model: db.customers, as: 'customer' },
{ model: db.transactions, as: 'transaction' },
],
order: [['createdAt', 'DESC']],
limit,
});
const [
pending,
sent,
clicked,
reviewed,
customers,
transactions,
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' } }),
db.review_requests.count({ where: { createdById: currentUser.id, status: 'clicked' } }),
db.review_requests.count({ where: { createdById: currentUser.id, status: 'reviewed' } }),
db.customers.count({ where: { createdById: currentUser.id } }),
db.transactions.count({ where: { createdById: currentUser.id } }),
db.stripe_events.count({ where: { createdById: currentUser.id } }),
db.transactions.findAll({
where: { createdById: currentUser.id },
include: [
{ model: db.businesses, as: 'business' },
{ model: db.customers, as: 'customer' },
],
order: [['createdAt', 'DESC']],
limit: 6,
}),
db.stripe_events.findAll({
where: { createdById: currentUser.id },
include: [
{ model: db.businesses, as: 'business' },
],
order: [['createdAt', 'DESC']],
limit: 6,
}),
db.businesses.findAll({
where: { createdById: currentUser.id },
order: [['updatedAt', 'DESC']],
limit: 25,
}),
]);
res.status(200).send({
stats: { pending, sent, clicked, reviewed, customers, transactions, paymentEvents },
requests,
recentTransactions,
recentEvents,
businesses: businesses.map((business) => ReviewFlowService.serializeBusiness(req, business)),
primaryBusiness: businesses[0] ? ReviewFlowService.serializeBusiness(req, businesses[0]) : null,
});
}));
router.post('/request', wrapAsync(async (req, res) => {
const currentUser = req.currentUser;
const body = req.body || {};
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();
const customerName = normalizeString(body.customerName);
const phone = normalizeString(body.phone);
const delayDays = Math.max(0, Math.min(Number(body.delayDays) || 0, 30));
requireField(businessName, 'Business name is required.');
if (!isHostedReviewDestination) {
requireField(reviewLink, 'Review link is required.');
}
requireField(customerEmail, 'Customer email is required.');
if (!EMAIL_PATTERN.test(customerEmail)) {
const error = new Error('Enter a valid customer email address.');
error.code = 400;
throw error;
}
if (reviewLink) {
validateUrl(reviewLink, 'Enter a valid review destination URL.');
}
await SubscriptionService.assertCanCreateReviewRequests(currentUser, 1);
const existingBusiness = await db.businesses.findOne({
where: { name: businessName, createdById: currentUser.id },
});
if (!existingBusiness) {
await SubscriptionService.assertCanCreateBusinesses(currentUser, 1);
}
const scheduledFor = new Date(Date.now() + delayDays * 24 * 60 * 60 * 1000);
const trackingToken = crypto.randomBytes(18).toString('hex');
const effectiveReviewLink = isHostedReviewDestination
? 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,
email_subject_template: `How was your experience with ${businessName}?`,
email_body_template: buildEmailBody('{customerName}', businessName, '{reviewLink}'),
is_active: true,
createdById: currentUser.id,
updatedById: currentUser.id,
ownerId: currentUser.id,
};
if (reviewLink && reviewLinkField) {
businessDefaults[reviewLinkField] = reviewLink;
}
const [business] = await db.businesses.findOrCreate({
where: { name: businessName, createdById: currentUser.id },
defaults: businessDefaults,
transaction,
});
const businessUpdates = {
business_type: businessType,
review_destination: reviewDestination,
shopify_hosted_reviews_enabled: business.shopify_hosted_reviews_enabled || isHostedReviewDestination,
delay_days: delayDays,
is_active: true,
updatedById: currentUser.id,
};
if (reviewLink && reviewLinkField) {
businessUpdates[reviewLinkField] = reviewLink;
}
await business.update(businessUpdates, { transaction });
const [customer] = await db.customers.findOrCreate({
where: { email: customerEmail, createdById: currentUser.id },
defaults: {
email: customerEmail,
name: customerName || null,
phone: phone || null,
contact_status: 'active',
businessId: business.id,
createdById: currentUser.id,
updatedById: currentUser.id,
},
transaction,
});
await customer.update({
name: customerName || customer.name,
phone: phone || customer.phone,
contact_status: customer.contact_status || 'active',
businessId: business.id,
updatedById: currentUser.id,
}, { transaction });
const emailSubject = `How was your experience with ${businessName}?`;
const reviewRequest = await db.review_requests.create({
status: 'pending',
scheduled_for: scheduledFor,
email_subject: emailSubject,
email_body: buildEmailBody(customerName, businessName, effectiveReviewLink),
review_link: effectiveReviewLink,
tracking_token: trackingToken,
review_platform: reviewDestination,
businessId: business.id,
customerId: customer.id,
createdById: currentUser.id,
updatedById: currentUser.id,
}, { 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: [
{ model: db.businesses, as: 'business' },
{ model: db.customers, as: 'customer' },
],
});
res.status(201).send({ request: createdRequest, delivery });
} catch (error) {
if (!transactionCommitted) {
await transaction.rollback();
}
throw error;
}
}));
router.post('/automation/run-due', wrapAsync(async (req, res) => {
await SubscriptionService.assertFeatureAccess(req.currentUser, 'set_and_forget_automation');
const result = await ReviewFlowService.processDueReviewRequests(req.currentUser, req.body || {});
res.status(200).send(result);
}));
router.put('/growth-tools/business', wrapAsync(async (req, res) => {
const currentUser = req.currentUser;
const body = req.body || {};
const businessId = normalizeString(body.businessId || body.id);
const businessName = normalizeString(body.businessName || body.name || 'Review Flow Business');
let business = businessId
? await db.businesses.findOne({ where: { id: businessId, createdById: currentUser.id } })
: await db.businesses.findOne({ where: { name: businessName, createdById: currentUser.id } });
if (businessId && !business) {
const error = new Error('Business not found for this account.');
error.code = 404;
throw error;
}
if (!business) {
await SubscriptionService.assertCanCreateBusinesses(currentUser, 1);
business = await db.businesses.create({
name: businessName,
business_type: normalizeBusinessType(body.businessType || body.business_type, 'hybrid'),
automation_mode: 'set_and_forget',
is_active: true,
createdById: currentUser.id,
updatedById: currentUser.id,
ownerId: currentUser.id,
});
}
const businessType = normalizeBusinessType(body.businessType || body.business_type, business.business_type || 'hybrid');
const reviewDestination = normalizeReviewDestination(body.reviewDestination || body.review_destination || business.review_destination || 'google');
if (!ReviewFlowService.isReviewDestinationAllowedForBusinessType(businessType, reviewDestination)) {
const error = new Error('This review destination does not match the selected business type. Choose Hybrid if this business needs both local and online options.');
error.code = 400;
throw error;
}
const aiReplyEnabled = parseBoolean(body.aiReplyEnabled ?? body.ai_reply_enabled, business.ai_reply_enabled);
const referralEnabled = parseBoolean(body.referralEnabled ?? body.referral_enabled, business.referral_enabled);
const npsEnabled = parseBoolean(body.npsEnabled ?? body.nps_enabled, business.nps_enabled);
const broadcastEnabled = parseBoolean(body.broadcastEnabled ?? body.broadcast_enabled, business.broadcast_enabled);
const rebookingEnabled = parseBoolean(body.rebookingEnabled ?? body.rebooking_enabled, business.rebooking_enabled);
const competitorInsightsEnabled = parseBoolean(body.competitorInsightsEnabled ?? body.competitor_insights_enabled, business.competitor_insights_enabled);
await assertProFeatureForEnabledFlag(currentUser, aiReplyEnabled, 'ai_review_replies');
await assertProFeatureForEnabledFlag(currentUser, referralEnabled, 'referral_campaigns');
await assertProFeatureForEnabledFlag(currentUser, npsEnabled, 'nps_surveys');
await assertProFeatureForEnabledFlag(currentUser, broadcastEnabled, 'marketing_broadcasts');
await assertProFeatureForEnabledFlag(currentUser, rebookingEnabled, 'rebooking_campaigns');
await assertProFeatureForEnabledFlag(currentUser, competitorInsightsEnabled, 'competitor_insights');
const updatePayload = {
name: businessName || business.name,
business_type: businessType,
automation_mode: normalizeString(body.automationMode || body.automation_mode) || 'set_and_forget',
review_destination: reviewDestination,
delay_days: parseInteger(body.delayDays ?? body.delay_days, business.delay_days || 7, 0, 30),
followup_enabled: parseBoolean(body.followupEnabled ?? body.followup_enabled, business.followup_enabled !== false),
followup_delay_days: parseInteger(body.followupDelayDays ?? body.followup_delay_days, business.followup_delay_days || 3, 1, 30),
max_followups: parseInteger(body.maxFollowups ?? body.max_followups, business.max_followups || 1, 0, 5),
ai_reply_enabled: aiReplyEnabled,
referral_enabled: referralEnabled,
referral_offer: normalizeString(body.referralOffer ?? body.referral_offer) || business.referral_offer || '',
nps_enabled: npsEnabled,
nps_question: normalizeString(body.npsQuestion ?? body.nps_question) || business.nps_question || 'How likely are you to recommend us to a friend?',
social_widget_enabled: parseBoolean(body.socialWidgetEnabled ?? body.social_widget_enabled, business.social_widget_enabled !== false),
broadcast_enabled: broadcastEnabled,
rebooking_enabled: rebookingEnabled,
competitor_insights_enabled: competitorInsightsEnabled,
competitor_urls: normalizeString(body.competitorUrls ?? body.competitor_urls) || business.competitor_urls || '',
review_widget_theme: normalizeString(body.reviewWidgetTheme ?? body.review_widget_theme) || business.review_widget_theme || 'light',
is_active: true,
updatedById: currentUser.id,
};
await business.update(updatePayload);
const refreshedBusiness = await db.businesses.findByPk(business.id);
res.status(200).send({ business: ReviewFlowService.serializeBusiness(req, refreshedBusiness) });
}));
router.post('/growth-tools/broadcast', wrapAsync(async (req, res) => {
const campaignType = normalizeString(req.body?.campaignType || 'broadcast');
const featureByCampaign = {
broadcast: 'marketing_broadcasts',
referral: 'referral_campaigns',
nps: 'nps_surveys',
rebooking: 'rebooking_campaigns',
};
await SubscriptionService.assertFeatureAccess(req.currentUser, featureByCampaign[campaignType] || 'marketing_broadcasts');
const result = await ReviewFlowService.queueCustomerCampaign(req.currentUser, req.body || {});
res.status(200).send(result);
}));
router.post('/growth-tools/competitor-insights', wrapAsync(async (req, res) => {
await SubscriptionService.assertFeatureAccess(req.currentUser, 'competitor_insights');
const result = await ReviewFlowService.buildCompetitorInsights(req.currentUser, req.body || {});
res.status(200).send(result);
}));
router.get('/social-widget/:businessId', wrapAsync(async (req, res) => {
await SubscriptionService.assertFeatureAccess(req.currentUser, 'social_proof_widgets');
const result = await ReviewFlowService.getSocialWidgetReviews(req.params.businessId, req.query || {});
const origin = `${req.protocol}://${req.get('host')}`;
res.status(200).send({
...result,
embedCode: `<iframe src="${origin}/api/reviewflow-public/widgets/${req.params.businessId}" style="width:100%;border:0;min-height:320px;border-radius:16px;" loading="lazy"></iframe>`,
});
}));
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;

View File

@ -0,0 +1,18 @@
const express = require('express');
const SubscriptionService = require('../services/subscription');
const wrapAsync = require('../helpers').wrapAsync;
const router = express.Router();
router.post('/', wrapAsync(async (req, res) => {
const result = await SubscriptionService.handleStripeWebhook(
req.body,
req.headers['stripe-signature'],
);
res.status(200).send(result);
}));
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;

View File

@ -0,0 +1,58 @@
const express = require('express');
const SubscriptionService = require('../services/subscription');
const wrapAsync = require('../helpers').wrapAsync;
const router = express.Router();
function getRequestBaseUrl(req) {
const origin = req.get('origin');
if (origin) {
return origin;
}
const forwardedProto = req.get('x-forwarded-proto') || req.protocol;
const forwardedHost = req.get('x-forwarded-host') || req.get('host');
if (forwardedHost) {
return `${forwardedProto}://${forwardedHost}`;
}
return '';
}
router.get('/me', wrapAsync(async (req, res) => {
const status = await SubscriptionService.getStatus(req.currentUser);
res.status(200).send(status);
}));
router.post('/select-plan', wrapAsync(async (req, res) => {
const status = await SubscriptionService.selectPlan(req.currentUser, req.body?.planId || req.body?.plan);
res.status(200).send(status);
}));
router.post('/create-checkout-session', wrapAsync(async (req, res) => {
const session = await SubscriptionService.createCheckoutSession(
req.currentUser,
req.body?.planId || req.body?.plan,
getRequestBaseUrl(req),
);
res.status(200).send(session);
}));
router.post('/create-portal-session', wrapAsync(async (req, res) => {
const session = await SubscriptionService.createPortalSession(
req.currentUser,
getRequestBaseUrl(req),
);
res.status(200).send(session);
}));
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;

View File

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

View File

@ -1,4 +1,5 @@
const UsersDBApi = require('../db/api/users');
const db = require('../db/models');
const ValidationError = require('./notifications/errors/validation');
const ForbiddenError = require('./notifications/errors/forbidden');
const bcrypt = require('bcrypt');
@ -8,6 +9,7 @@ const PasswordResetEmail = require('./email/list/passwordReset');
const EmailSender = require('./email');
const config = require('../config');
const helpers = require('../helpers');
const SubscriptionService = require('./subscription');
class Auth {
static async signup(email, password, options = {}, host) {
@ -54,11 +56,16 @@ class Auth {
return helpers.jwtSign(data);
}
const subscriptionPayload = SubscriptionService.getSignupSubscriptionPayload(
options?.body?.planId || options?.body?.plan,
);
const newUser = await UsersDBApi.createFromAuth(
{
firstName: email.split('@')[0],
password: hashedPassword,
email: email,
...subscriptionPayload,
},
options,

View File

@ -6,6 +6,7 @@ const csv = require('csv-parser');
const axios = require('axios');
const config = require('../config');
const stream = require('stream');
const SubscriptionService = require('./subscription');
@ -15,6 +16,8 @@ module.exports = class BusinessesService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await SubscriptionService.assertCanCreateBusinesses(currentUser, 1, { transaction });
await BusinessesDBApi.create(
data,
{
@ -51,6 +54,8 @@ module.exports = class BusinessesService {
.on('error', (error) => reject(error));
})
await SubscriptionService.assertCanCreateBusinesses(req.currentUser, results.length, { transaction });
await BusinessesDBApi.bulkImport(results, {
transaction,
ignoreDuplicates: true,

View File

@ -6,6 +6,7 @@ const csv = require('csv-parser');
const axios = require('axios');
const config = require('../config');
const stream = require('stream');
const SubscriptionService = require('./subscription');
@ -15,6 +16,8 @@ module.exports = class Review_requestsService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await SubscriptionService.assertCanCreateReviewRequests(currentUser, 1, { transaction });
await Review_requestsDBApi.create(
data,
{
@ -51,6 +54,8 @@ module.exports = class Review_requestsService {
.on('error', (error) => reject(error));
})
await SubscriptionService.assertCanCreateReviewRequests(req.currentUser, results.length, { transaction });
await Review_requestsDBApi.bulkImport(results, {
transaction,
ignoreDuplicates: true,

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,222 @@
const Stripe = require('stripe');
const config = require('../config');
const PLAN_PRICE_ENV = {
starter: 'STRIPE_STARTER_PRICE_ID',
pro: 'STRIPE_PRO_PRICE_ID',
};
let cachedStripeClient = null;
let cachedSecretKey = null;
function httpError(message, code = 400) {
const error = new Error(message);
error.code = code;
return error;
}
function compactMissing(items) {
return items.filter(Boolean);
}
function getPriceIdForPlan(planId) {
if (planId === 'pro') {
return config.stripe.proPriceId;
}
if (planId === 'starter') {
return config.stripe.starterPriceId;
}
return '';
}
function getPlanIdForPriceId(priceId) {
if (!priceId) {
return null;
}
if (priceId === config.stripe.proPriceId) {
return 'pro';
}
if (priceId === config.stripe.starterPriceId) {
return 'starter';
}
return null;
}
function getStripeClient() {
if (!config.stripe.secretKey) {
throw httpError('Stripe billing is not configured yet. Add STRIPE_SECRET_KEY in the backend environment.', 400);
}
if (!cachedStripeClient || cachedSecretKey !== config.stripe.secretKey) {
cachedStripeClient = Stripe(config.stripe.secretKey);
cachedSecretKey = config.stripe.secretKey;
}
return cachedStripeClient;
}
function formatMissingConfigurationMessage(missing) {
return `Stripe billing is not configured yet. Add ${missing.join(', ')} in the backend environment, then reload the backend service.`;
}
module.exports = class StripeBillingService {
static getPriceIdForPlan(planId) {
return getPriceIdForPlan(planId);
}
static getPlanIdForPriceId(priceId) {
return getPlanIdForPriceId(priceId);
}
static getMissingCheckoutConfiguration(planId) {
const priceEnvName = PLAN_PRICE_ENV[planId] || PLAN_PRICE_ENV.starter;
return compactMissing([
config.stripe.secretKey ? null : 'STRIPE_SECRET_KEY',
getPriceIdForPlan(planId) ? null : priceEnvName,
]);
}
static getMissingPortalConfiguration() {
return compactMissing([
config.stripe.secretKey ? null : 'STRIPE_SECRET_KEY',
]);
}
static getMissingWebhookConfiguration() {
return compactMissing([
config.stripe.secretKey ? null : 'STRIPE_SECRET_KEY',
config.stripe.webhookSecret ? null : 'STRIPE_WEBHOOK_SECRET',
]);
}
static getSetupStatus(planId = 'starter') {
const allMissing = new Set([
...this.getMissingCheckoutConfiguration('starter'),
...this.getMissingCheckoutConfiguration('pro'),
...this.getMissingWebhookConfiguration(),
]);
return {
checkoutReady: this.getMissingCheckoutConfiguration(planId).length === 0,
portalReady: this.getMissingPortalConfiguration().length === 0,
webhookReady: this.getMissingWebhookConfiguration().length === 0,
missingConfiguration: Array.from(allMissing),
};
}
static assertCheckoutConfigured(planId) {
const missing = this.getMissingCheckoutConfiguration(planId);
if (missing.length) {
throw httpError(formatMissingConfigurationMessage(missing), 400);
}
}
static assertPortalConfigured() {
const missing = this.getMissingPortalConfiguration();
if (missing.length) {
throw httpError(formatMissingConfigurationMessage(missing), 400);
}
}
static assertWebhookConfigured() {
const missing = this.getMissingWebhookConfiguration();
if (missing.length) {
throw httpError(formatMissingConfigurationMessage(missing), 400);
}
}
static async createCheckoutSession(params) {
const {
user,
plan,
baseUrl,
trialPeriodDays,
} = params;
const priceId = getPriceIdForPlan(plan.id);
const subscriptionData = {
metadata: {
userId: user.id,
planId: plan.id,
},
};
this.assertCheckoutConfigured(plan.id);
const stripe = getStripeClient();
if (trialPeriodDays && trialPeriodDays > 0) {
subscriptionData.trial_period_days = trialPeriodDays;
}
return stripe.checkout.sessions.create({
mode: 'subscription',
customer: user.stripeCustomerId || undefined,
customer_email: user.stripeCustomerId ? undefined : user.email,
client_reference_id: user.id,
line_items: [
{
price: priceId,
quantity: 1,
},
],
allow_promotion_codes: true,
billing_address_collection: 'auto',
success_url: `${baseUrl}/subscription?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${baseUrl}/subscription?checkout=cancelled`,
metadata: {
userId: user.id,
planId: plan.id,
},
subscription_data: subscriptionData,
});
}
static async createPortalSession(params) {
const { customerId, baseUrl } = params;
this.assertPortalConfigured();
const stripe = getStripeClient();
return stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${baseUrl}/subscription`,
});
}
static constructWebhookEvent(rawBody, signature) {
this.assertWebhookConfigured();
if (!signature) {
throw httpError('Missing Stripe webhook signature.', 400);
}
const stripe = getStripeClient();
return stripe.webhooks.constructEvent(
rawBody,
signature,
config.stripe.webhookSecret,
);
}
static async retrieveSubscription(subscriptionId) {
if (!subscriptionId || typeof subscriptionId !== 'string') {
return null;
}
const stripe = getStripeClient();
return stripe.subscriptions.retrieve(subscriptionId, {
expand: ['items.data.price'],
});
}
};

View File

@ -0,0 +1,830 @@
const db = require('../db/models');
const StripeBillingService = require('./stripeBilling');
const {
TRIAL_DAYS,
getSubscriptionPlanById,
getSubscriptionPlans,
} = require('./subscriptionPlans');
const DEFAULT_PLAN_ID = 'starter';
const DEFAULT_STATUS = 'trialing';
const INTERNAL_ADMIN_ROLE_NAMES = ['Administrator'];
const DAY_IN_MS = 24 * 60 * 60 * 1000;
const PAYMENT_CONNECTOR_FIELDS = [
'stripe_connected',
'square_connected',
'paypal_connected',
'shopify_connected',
'woocommerce_connected',
];
function httpError(message, code = 403) {
const error = new Error(message);
error.code = code;
return error;
}
function normalizePlanId(planId) {
const normalized = typeof planId === 'string' ? planId.trim().toLowerCase() : '';
return getSubscriptionPlanById(normalized) ? normalized : DEFAULT_PLAN_ID;
}
function getPlan(planId) {
return getSubscriptionPlanById(normalizePlanId(planId)) || getSubscriptionPlanById(DEFAULT_PLAN_ID);
}
function addDays(date, days) {
return new Date(date.getTime() + days * DAY_IN_MS);
}
function buildTrialWindow(referenceDate = new Date()) {
const trialStartedAt = new Date(referenceDate);
return {
trialStartedAt,
trialEndsAt: addDays(trialStartedAt, TRIAL_DAYS),
};
}
function getCurrentMonthRange(referenceDate = new Date()) {
const periodStart = new Date(Date.UTC(
referenceDate.getUTCFullYear(),
referenceDate.getUTCMonth(),
1,
0,
0,
0,
0,
));
const periodEnd = new Date(Date.UTC(
referenceDate.getUTCFullYear(),
referenceDate.getUTCMonth() + 1,
1,
0,
0,
0,
0,
));
return { periodStart, periodEnd };
}
function toDateOrNull(value) {
if (!value) {
return null;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function getEffectiveSubscription(user, referenceDate = new Date()) {
const plan = getPlan(user?.subscriptionPlanId);
const status = user?.subscriptionStatus || DEFAULT_STATUS;
const trialStartedAt = toDateOrNull(user?.trialStartedAt);
const trialEndsAt = toDateOrNull(user?.trialEndsAt);
const isTrialActive = status === 'trialing' && (!trialEndsAt || trialEndsAt.getTime() >= referenceDate.getTime());
const isActive = status === 'active' || isTrialActive;
const effectiveStatus = status === 'trialing' && !isTrialActive ? 'expired' : status;
const trialDaysLeft = trialEndsAt
? Math.max(0, Math.ceil((trialEndsAt.getTime() - referenceDate.getTime()) / DAY_IN_MS))
: null;
return {
plan,
planId: plan.id,
status,
effectiveStatus,
isActive,
trialStartedAt,
trialEndsAt,
trialDaysLeft,
};
}
function getUserRoleName(user) {
return user?.app_role?.name || user?.app_role?.dataValues?.name || '';
}
async function isSubscriptionLimitExemptUser(user, options = {}) {
if (!user) {
return false;
}
if (user.email === 'admin@flatlogic.com' || user.dataValues?.email === 'admin@flatlogic.com') {
return true;
}
const roleName = getUserRoleName(user);
if (INTERNAL_ADMIN_ROLE_NAMES.includes(roleName)) {
return true;
}
const roleId = user.app_roleId || user.dataValues?.app_roleId;
if (!roleId) {
return false;
}
const role = await db.roles.findByPk(roleId, {
transaction: options.transaction || undefined,
});
return INTERNAL_ADMIN_ROLE_NAMES.includes(role?.name);
}
function getLimitMessage(plan, usageCount, limit, unit, options = {}) {
const baseMessage = `${plan.name} includes ${limit.toLocaleString()} ${unit}. You have already used ${usageCount.toLocaleString()}.`;
const upgradePrefix = plan.id === 'starter' ? 'Upgrade to Pro or ' : '';
if (options.resetDate) {
return `${baseMessage} ${upgradePrefix}wait until ${options.resetDate.toISOString().slice(0, 10)} for the monthly limit to reset.`;
}
return `${baseMessage} ${upgradePrefix}${options.remediation || 'remove an existing item before adding another.'}`;
}
function getUnixDate(value) {
if (!value) {
return null;
}
const timestamp = Number(value);
if (!timestamp) {
return null;
}
return new Date(timestamp * 1000);
}
function getPrimarySubscriptionItem(subscription) {
return subscription?.items?.data?.[0] || null;
}
function getSubscriptionPriceId(subscription) {
const item = getPrimarySubscriptionItem(subscription);
return item?.price?.id || subscription?.plan?.id || null;
}
function getCurrentPeriodEnd(subscription) {
const item = getPrimarySubscriptionItem(subscription);
return getUnixDate(subscription?.current_period_end || item?.current_period_end);
}
function getPlanIdFromStripeSubscription(subscription, fallbackPlanId) {
const pricePlanId = StripeBillingService.getPlanIdForPriceId(getSubscriptionPriceId(subscription));
if (pricePlanId) {
return pricePlanId;
}
return normalizePlanId(subscription?.metadata?.planId || fallbackPlanId);
}
function getStripeCustomerId(value) {
if (!value) {
return null;
}
if (typeof value === 'string') {
return value;
}
return value.id || null;
}
function getStripeSubscriptionId(value) {
if (!value) {
return null;
}
if (typeof value === 'string') {
return value;
}
return value.id || null;
}
function getTrialDaysLeftForCheckout(subscription) {
if (!subscription.isActive || subscription.status !== DEFAULT_STATUS || !subscription.trialEndsAt) {
return 0;
}
return Math.max(0, Math.ceil((subscription.trialEndsAt.getTime() - Date.now()) / DAY_IN_MS));
}
async function getTeamUsageScope(userId, transaction) {
const user = await db.users.findByPk(userId, {
attributes: ['id', 'createdById'],
transaction,
});
const teamOwnerId = user?.createdById || userId;
const teamMembers = await db.users.findAll({
attributes: ['id'],
where: {
[db.Sequelize.Op.or]: [
{ id: teamOwnerId },
{ createdById: teamOwnerId },
],
disabled: false,
},
transaction,
});
const teamMemberIds = teamMembers.map((teamMember) => teamMember.id);
if (!teamMemberIds.includes(userId)) {
teamMemberIds.push(userId);
}
return {
teamOwnerId,
teamMemberIds,
teamMembers: teamMemberIds.length,
};
}
async function getUserRecord(currentUserOrId, options = {}) {
const transaction = options.transaction || undefined;
const userId = typeof currentUserOrId === 'string' ? currentUserOrId : currentUserOrId?.id;
if (!userId) {
throw httpError('A signed-in user is required to check subscription limits.', 403);
}
const shouldLoad = options.forceReload || typeof currentUserOrId === 'string' || currentUserOrId.subscriptionPlanId === undefined;
if (!shouldLoad) {
return currentUserOrId;
}
const user = await db.users.findByPk(userId, { transaction });
if (!user) {
throw httpError('Subscription user was not found.', 404);
}
return user;
}
module.exports = class SubscriptionService {
static normalizePlanId(planId) {
return normalizePlanId(planId);
}
static getSignupSubscriptionPayload(planId) {
return {
subscriptionPlanId: normalizePlanId(planId),
subscriptionStatus: DEFAULT_STATUS,
...buildTrialWindow(),
};
}
static getEffectiveSubscription(user, referenceDate = new Date()) {
return getEffectiveSubscription(user, referenceDate);
}
static async getUsageForUserId(userId, options = {}) {
const transaction = options.transaction || undefined;
const { periodStart, periodEnd } = getCurrentMonthRange();
const teamScope = await getTeamUsageScope(userId, transaction);
const teamMemberFilter = { [db.Sequelize.Op.in]: teamScope.teamMemberIds };
const businesses = await db.businesses.findAll({
where: { createdById: teamMemberFilter },
attributes: ['id', ...PAYMENT_CONNECTOR_FIELDS],
transaction,
});
const monthlyReviewRequests = await db.review_requests.count({
where: {
createdById: teamMemberFilter,
createdAt: {
[db.Sequelize.Op.gte]: periodStart,
[db.Sequelize.Op.lt]: periodEnd,
},
},
transaction,
});
const paymentConnectors = businesses.reduce((total, business) => {
return total + PAYMENT_CONNECTOR_FIELDS.filter((field) => Boolean(business[field])).length;
}, 0);
return {
monthlyReviewRequests,
businesses: businesses.length,
teamMembers: teamScope.teamMembers,
paymentConnectors,
periodStart,
periodEnd,
};
}
static async getStatus(currentUserOrId, options = {}) {
const user = await getUserRecord(currentUserOrId, { ...options, forceReload: true });
const subscription = getEffectiveSubscription(user);
const usage = await this.getUsageForUserId(user.id, options);
const plans = getSubscriptionPlans();
return {
subscription: {
planId: subscription.planId,
planName: subscription.plan.name,
status: subscription.status,
effectiveStatus: subscription.effectiveStatus,
isActive: subscription.isActive,
trialStartedAt: subscription.trialStartedAt,
trialEndsAt: subscription.trialEndsAt,
trialDaysLeft: subscription.trialDaysLeft,
priceMonthly: subscription.plan.priceMonthly,
currency: subscription.plan.currency,
stripeCustomerLinked: Boolean(user.stripeCustomerId),
stripeSubscriptionLinked: Boolean(user.stripeSubscriptionId),
currentPeriodEndsAt: user.stripeCurrentPeriodEndAt || null,
},
billing: {
...StripeBillingService.getSetupStatus(subscription.planId),
hasStripeCustomer: Boolean(user.stripeCustomerId),
hasStripeSubscription: Boolean(user.stripeSubscriptionId),
},
plan: subscription.plan,
usage,
limits: subscription.plan.limits,
plans,
};
}
static async selectPlan(currentUser, planId) {
const user = await db.users.findByPk(currentUser?.id);
if (!user) {
throw httpError('Subscription user was not found.', 404);
}
const now = new Date();
const targetPlanId = normalizePlanId(planId);
const existingSubscription = getEffectiveSubscription(user, now);
if (targetPlanId === existingSubscription.planId) {
return this.getStatus(user.id);
}
if (user.stripeCustomerId || user.stripeSubscriptionId || user.subscriptionStatus === 'active') {
throw httpError('This account is managed by Stripe. Use Checkout or Manage billing to change plans.', 403);
}
if (existingSubscription.effectiveStatus !== DEFAULT_STATUS) {
throw httpError('Your trial is not active. Start Stripe Checkout to choose a paid plan.', 403);
}
const trialWindow = user.trialStartedAt && user.trialEndsAt ? {
trialStartedAt: user.trialStartedAt,
trialEndsAt: user.trialEndsAt,
} : buildTrialWindow(now);
await user.update({
subscriptionPlanId: targetPlanId,
subscriptionStatus: DEFAULT_STATUS,
trialStartedAt: trialWindow.trialStartedAt,
trialEndsAt: trialWindow.trialEndsAt,
updatedById: currentUser.id,
});
return this.getStatus(user.id);
}
static async createCheckoutSession(currentUser, planId, baseUrl) {
const user = await db.users.findByPk(currentUser?.id);
if (!user) {
throw httpError('Subscription user was not found.', 404);
}
const plan = getPlan(planId);
const subscription = getEffectiveSubscription(user);
const trialPeriodDays = user.stripeSubscriptionId ? 0 : getTrialDaysLeftForCheckout(subscription);
const session = await StripeBillingService.createCheckoutSession({
user,
plan,
baseUrl,
trialPeriodDays,
});
await user.update({
subscriptionPlanId: plan.id,
stripeCheckoutSessionId: session.id,
updatedById: currentUser.id,
});
return {
sessionId: session.id,
url: session.url,
};
}
static async createPortalSession(currentUser, baseUrl) {
const user = await db.users.findByPk(currentUser?.id);
if (!user) {
throw httpError('Subscription user was not found.', 404);
}
if (!user.stripeCustomerId) {
throw httpError('No Stripe customer is linked to this account yet. Start Checkout first, then use Manage billing.', 400);
}
const portalSession = await StripeBillingService.createPortalSession({
customerId: user.stripeCustomerId,
baseUrl,
});
return {
url: portalSession.url,
};
}
static async syncStripeSubscription(subscription, options = {}) {
if (!subscription) {
return null;
}
const stripeSubscriptionId = getStripeSubscriptionId(subscription.id);
const stripeCustomerId = getStripeCustomerId(subscription.customer || options.customerId);
const whereClauses = [];
if (options.userId) {
whereClauses.push({ id: options.userId });
}
if (stripeSubscriptionId) {
whereClauses.push({ stripeSubscriptionId });
}
if (stripeCustomerId) {
whereClauses.push({ stripeCustomerId });
}
if (!whereClauses.length) {
return null;
}
const user = await db.users.findOne({
where: {
[db.Sequelize.Op.or]: whereClauses,
},
});
if (!user) {
return null;
}
const planId = getPlanIdFromStripeSubscription(subscription, options.planId || user.subscriptionPlanId);
const status = subscription.status || user.subscriptionStatus || DEFAULT_STATUS;
const trialStartedAt = getUnixDate(subscription.trial_start) || user.trialStartedAt;
const trialEndsAt = getUnixDate(subscription.trial_end) || user.trialEndsAt;
const subscriptionStartedAt = getUnixDate(subscription.start_date) || user.subscriptionStartedAt || new Date();
const subscriptionEndsAt = getUnixDate(subscription.cancel_at) || getCurrentPeriodEnd(subscription) || user.subscriptionEndsAt;
const subscriptionCanceledAt = getUnixDate(subscription.canceled_at) || (status === 'canceled' ? new Date() : user.subscriptionCanceledAt);
await user.update({
subscriptionPlanId: planId,
subscriptionStatus: status,
trialStartedAt,
trialEndsAt,
subscriptionStartedAt,
subscriptionEndsAt,
subscriptionCanceledAt,
stripeCustomerId: stripeCustomerId || user.stripeCustomerId,
stripeSubscriptionId: stripeSubscriptionId || user.stripeSubscriptionId,
stripePriceId: getSubscriptionPriceId(subscription) || user.stripePriceId,
stripeCheckoutSessionId: options.checkoutSessionId || user.stripeCheckoutSessionId,
stripeCurrentPeriodEndAt: getCurrentPeriodEnd(subscription) || user.stripeCurrentPeriodEndAt,
});
return user;
}
static async updateStripeSubscriptionStatusByReference(reference, status) {
const whereClauses = [];
if (reference.subscriptionId) {
whereClauses.push({ stripeSubscriptionId: reference.subscriptionId });
}
if (reference.customerId) {
whereClauses.push({ stripeCustomerId: reference.customerId });
}
if (!whereClauses.length) {
return null;
}
const user = await db.users.findOne({
where: {
[db.Sequelize.Op.or]: whereClauses,
},
});
if (!user) {
return null;
}
await user.update({ subscriptionStatus: status });
return user;
}
static async handleStripeWebhook(rawBody, signature) {
const event = StripeBillingService.constructWebhookEvent(rawBody, signature);
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
const subscription = await StripeBillingService.retrieveSubscription(getStripeSubscriptionId(session.subscription));
if (subscription) {
await this.syncStripeSubscription(subscription, {
userId: session.client_reference_id || session.metadata?.userId,
planId: session.metadata?.planId,
customerId: getStripeCustomerId(session.customer),
checkoutSessionId: session.id,
});
}
break;
}
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted':
await this.syncStripeSubscription(event.data.object);
break;
case 'invoice.payment_succeeded':
case 'invoice.payment_failed': {
const invoice = event.data.object;
const subscriptionId = getStripeSubscriptionId(invoice.subscription);
const subscription = await StripeBillingService.retrieveSubscription(subscriptionId);
if (subscription) {
await this.syncStripeSubscription(subscription, {
customerId: getStripeCustomerId(invoice.customer),
});
} else if (event.type === 'invoice.payment_failed') {
await this.updateStripeSubscriptionStatusByReference({
subscriptionId,
customerId: getStripeCustomerId(invoice.customer),
}, 'past_due');
}
break;
}
default:
break;
}
return {
received: true,
type: event.type,
};
}
static async canCreateReviewRequests(currentUserOrId, quantity = 1, options = {}) {
const user = await getUserRecord(currentUserOrId, options);
const subscription = getEffectiveSubscription(user);
if (await isSubscriptionLimitExemptUser(user, options)) {
return {
allowed: true,
usage: null,
subscription,
subscriptionExempt: true,
};
}
if (!subscription.isActive) {
return {
allowed: false,
code: 403,
message: 'Your Review Flow trial has ended. Choose a plan to keep creating review requests.',
};
}
const usage = await this.getUsageForUserId(user.id, options);
const limit = subscription.plan.limits.monthlyReviewRequests;
if (usage.monthlyReviewRequests + quantity > limit) {
return {
allowed: false,
code: 403,
message: getLimitMessage(
subscription.plan,
usage.monthlyReviewRequests,
limit,
'review requests per month',
{ resetDate: usage.periodEnd },
),
};
}
return { allowed: true, usage, subscription };
}
static async assertCanCreateReviewRequests(currentUserOrId, quantity = 1, options = {}) {
const result = await this.canCreateReviewRequests(currentUserOrId, quantity, options);
if (!result.allowed) {
throw httpError(result.message, result.code);
}
return result;
}
static async canCreateBusinesses(currentUserOrId, quantity = 1, options = {}) {
const user = await getUserRecord(currentUserOrId, options);
const subscription = getEffectiveSubscription(user);
if (await isSubscriptionLimitExemptUser(user, options)) {
return {
allowed: true,
usage: null,
subscription,
subscriptionExempt: true,
};
}
if (!subscription.isActive) {
return {
allowed: false,
code: 403,
message: 'Your Review Flow trial has ended. Choose a plan to keep adding business profiles.',
};
}
const usage = await this.getUsageForUserId(user.id, options);
const limit = subscription.plan.limits.businesses;
if (usage.businesses + quantity > limit) {
return {
allowed: false,
code: 403,
message: getLimitMessage(
subscription.plan,
usage.businesses,
limit,
limit === 1 ? 'business profile' : 'business profiles',
{
remediation: limit === 1
? 'remove your existing business profile before adding another.'
: 'remove an existing business profile before adding another.',
},
),
};
}
return { allowed: true, usage, subscription };
}
static async assertCanCreateBusinesses(currentUserOrId, quantity = 1, options = {}) {
const result = await this.canCreateBusinesses(currentUserOrId, quantity, options);
if (!result.allowed) {
throw httpError(result.message, result.code);
}
return result;
}
static async canCreateTeamMembers(currentUserOrId, quantity = 1, options = {}) {
const user = await getUserRecord(currentUserOrId, options);
const subscription = getEffectiveSubscription(user);
if (await isSubscriptionLimitExemptUser(user, options)) {
return {
allowed: true,
usage: null,
subscription,
subscriptionExempt: true,
};
}
if (!subscription.isActive) {
return {
allowed: false,
code: 403,
message: 'Your Review Flow trial has ended. Choose a plan to keep inviting team members.',
};
}
const usage = await this.getUsageForUserId(user.id, options);
const limit = subscription.plan.limits.teamMembers;
if (usage.teamMembers + quantity > limit) {
return {
allowed: false,
code: 403,
message: getLimitMessage(
subscription.plan,
usage.teamMembers,
limit,
'team members',
{ remediation: 'remove or disable a team member before inviting another.' },
),
};
}
return { allowed: true, usage, subscription };
}
static async assertCanCreateTeamMembers(currentUserOrId, quantity = 1, options = {}) {
const result = await this.canCreateTeamMembers(currentUserOrId, quantity, options);
if (!result.allowed) {
throw httpError(result.message, result.code);
}
return result;
}
static async canConnectPaymentProvider(currentUserOrId, connectedField, options = {}) {
const user = await getUserRecord(currentUserOrId, options);
const subscription = getEffectiveSubscription(user);
if (await isSubscriptionLimitExemptUser(user, options)) {
return {
allowed: true,
usage: null,
subscription,
subscriptionExempt: true,
};
}
if (!subscription.isActive) {
return {
allowed: false,
code: 403,
message: 'Your Review Flow trial has ended. Choose a plan to keep connecting payment providers.',
};
}
if (!PAYMENT_CONNECTOR_FIELDS.includes(connectedField)) {
throw httpError('Unknown payment provider connector.', 400);
}
const usage = await this.getUsageForUserId(user.id, options);
const limit = subscription.plan.limits.paymentConnectors;
if (usage.paymentConnectors + 1 > limit) {
return {
allowed: false,
code: 403,
message: getLimitMessage(
subscription.plan,
usage.paymentConnectors,
limit,
'connected payment providers',
{ remediation: 'disconnect a payment provider before connecting another.' },
),
};
}
return { allowed: true, usage, subscription };
}
static async assertCanConnectPaymentProvider(currentUserOrId, connectedField, options = {}) {
const result = await this.canConnectPaymentProvider(currentUserOrId, connectedField, options);
if (!result.allowed) {
throw httpError(result.message, result.code);
}
return result;
}
static async assertFeatureAccess(currentUserOrId, featureKey, options = {}) {
const user = await getUserRecord(currentUserOrId, options);
const subscription = getEffectiveSubscription(user);
if (await isSubscriptionLimitExemptUser(user, options)) {
return true;
}
if (!subscription.isActive) {
throw httpError('Your Review Flow trial has ended. Choose a plan to keep using this feature.', 403);
}
if (!subscription.plan.includedFeatureKeys.includes(featureKey)) {
throw httpError(`${subscription.plan.name} does not include this feature. Upgrade to Pro to unlock it.`, 403);
}
return true;
}
};

View File

@ -0,0 +1,116 @@
const TRIAL_DAYS = 14;
const subscriptionPlans = [
{
id: 'starter',
name: 'Grow',
priceMonthly: 49,
currency: 'USD',
trialDays: TRIAL_DAYS,
tagline: 'For review automation that runs after setup: requests, reminders, widgets, and clean local/online routing.',
limits: {
monthlyReviewRequests: 250,
businesses: 1,
teamMembers: 2,
paymentConnectors: 5,
},
features: [
'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',
'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',
'social_proof_widgets',
],
},
{
id: 'pro',
name: 'Pro',
priceMonthly: 99,
currency: 'USD',
trialDays: TRIAL_DAYS,
tagline: 'For teams that want AI replies, referrals, NPS, broadcasts, rebooking campaigns, and competitor insight tools.',
limits: {
monthlyReviewRequests: 2500,
businesses: 10,
teamMembers: 10,
paymentConnectors: 5,
},
features: [
'Everything in Grow',
'AI review reply assistant',
'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',
'social_proof_widgets',
'higher_review_request_limit',
'higher_business_limit',
'higher_team_member_limit',
'subscription_usage_dashboard',
'separate_admin_view',
'ai_review_replies',
'referral_campaigns',
'nps_surveys',
'marketing_broadcasts',
'rebooking_campaigns',
'competitor_insights',
],
},
];
const getSubscriptionPlans = () => subscriptionPlans.map((plan) => ({
...plan,
limits: { ...plan.limits },
features: [...plan.features],
includedFeatureKeys: [...plan.includedFeatureKeys],
}));
const getSubscriptionPlanById = (planId) => getSubscriptionPlans().find((plan) => plan.id === planId);
module.exports = {
TRIAL_DAYS,
getSubscriptionPlanById,
getSubscriptionPlans,
};

View File

@ -3,14 +3,12 @@ const UsersDBApi = require('../db/api/users');
const processFile = require("../middlewares/upload");
const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser');
const axios = require('axios');
const config = require('../config');
const stream = require('stream');
const InvitationEmail = require('./email/list/invitation');
const EmailSender = require('./email');
const AuthService = require('./auth');
const SubscriptionService = require('./subscription');
module.exports = class UsersService {
static async create(data, currentUser, sendInvitationEmails = true, host) {
@ -26,6 +24,7 @@ module.exports = class UsersService {
'iam.errors.userAlreadyExists',
);
} else {
await SubscriptionService.assertCanCreateTeamMembers(currentUser, 1, { transaction });
await UsersDBApi.create(
{data},
@ -79,6 +78,8 @@ module.exports = class UsersService {
throw new ValidationError('importer.errors.userEmailMissing');
}
await SubscriptionService.assertCanCreateTeamMembers(req.currentUser, results.length, { transaction });
await UsersDBApi.bulkImport(results, {
transaction,
ignoreDuplicates: true,
@ -108,7 +109,7 @@ module.exports = class UsersService {
try {
let users = await UsersDBApi.findBy(
{id},
{transaction},
{transaction, currentUser},
);
if (!users) {
@ -134,7 +135,7 @@ module.exports = class UsersService {
await transaction.rollback();
throw error;
}
};
}
static async remove(id, currentUser) {
const transaction = await db.sequelize.transaction();

File diff suppressed because it is too large Load Diff

View File

@ -39,7 +39,7 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
>
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0">
<b className="font-black">ReviewFlow</b>
<b className="font-black">Review Flow</b>
</div>

View File

@ -90,7 +90,7 @@ const CardBusinesses = ({
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>BusinessName</dt>
<dt className=' text-gray-500 dark:text-dark-600'>Business name</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.name }
@ -102,7 +102,7 @@ const CardBusinesses = ({
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>GoogleReviewLink</dt>
<dt className=' text-gray-500 dark:text-dark-600'>Google review link</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.google_review_link }
@ -114,7 +114,7 @@ const CardBusinesses = ({
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>YelpReviewLink</dt>
<dt className=' text-gray-500 dark:text-dark-600'>Yelp review link</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.yelp_review_link }
@ -126,7 +126,7 @@ const CardBusinesses = ({
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>FacebookReviewLink</dt>
<dt className=' text-gray-500 dark:text-dark-600'>Facebook review link</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.facebook_review_link }
@ -138,7 +138,7 @@ const CardBusinesses = ({
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>DelayDays</dt>
<dt className=' text-gray-500 dark:text-dark-600'>Review delay days</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.delay_days }
@ -150,7 +150,7 @@ const CardBusinesses = ({
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>EmailSubjectTemplate</dt>
<dt className=' text-gray-500 dark:text-dark-600'>Email subject template</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.email_subject_template }
@ -162,7 +162,7 @@ const CardBusinesses = ({
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>EmailBodyTemplate</dt>
<dt className=' text-gray-500 dark:text-dark-600'>Email body template</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.email_body_template }
@ -174,7 +174,7 @@ const CardBusinesses = ({
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>IsActive</dt>
<dt className=' text-gray-500 dark:text-dark-600'>Active</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ dataFormatter.booleanFormatter(item.is_active) }
@ -186,7 +186,7 @@ const CardBusinesses = ({
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>StripeAccountReference</dt>
<dt className=' text-gray-500 dark:text-dark-600'>Stripe account reference</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.stripe_account_reference }
@ -198,7 +198,7 @@ const CardBusinesses = ({
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>StripeConnected</dt>
<dt className=' text-gray-500 dark:text-dark-600'>Stripe connected</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ dataFormatter.booleanFormatter(item.stripe_connected) }
@ -210,7 +210,7 @@ const CardBusinesses = ({
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>StripeConnectedAt</dt>
<dt className=' text-gray-500 dark:text-dark-600'>Stripe connected at</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ dataFormatter.dateTimeFormatter(item.stripe_connected_at) }
@ -222,7 +222,7 @@ const CardBusinesses = ({
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>DefaultReviewPlatform</dt>
<dt className=' text-gray-500 dark:text-dark-600'>Default review platform</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.default_review_platform }
@ -234,7 +234,7 @@ const CardBusinesses = ({
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>CustomReviewLink</dt>
<dt className=' text-gray-500 dark:text-dark-600'>Custom review link</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.custom_review_link }

View File

@ -56,7 +56,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>BusinessName</p>
<p className={'text-xs text-gray-500 '}>Business name</p>
<p className={'line-clamp-2'}>{ item.name }</p>
</div>
@ -64,7 +64,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>GoogleReviewLink</p>
<p className={'text-xs text-gray-500 '}>Google review link</p>
<p className={'line-clamp-2'}>{ item.google_review_link }</p>
</div>
@ -72,7 +72,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>YelpReviewLink</p>
<p className={'text-xs text-gray-500 '}>Yelp review link</p>
<p className={'line-clamp-2'}>{ item.yelp_review_link }</p>
</div>
@ -80,7 +80,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>FacebookReviewLink</p>
<p className={'text-xs text-gray-500 '}>Facebook review link</p>
<p className={'line-clamp-2'}>{ item.facebook_review_link }</p>
</div>
@ -88,7 +88,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>DelayDays</p>
<p className={'text-xs text-gray-500 '}>Review delay days</p>
<p className={'line-clamp-2'}>{ item.delay_days }</p>
</div>
@ -96,7 +96,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>EmailSubjectTemplate</p>
<p className={'text-xs text-gray-500 '}>Email subject template</p>
<p className={'line-clamp-2'}>{ item.email_subject_template }</p>
</div>
@ -104,7 +104,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>EmailBodyTemplate</p>
<p className={'text-xs text-gray-500 '}>Email body template</p>
<p className={'line-clamp-2'}>{ item.email_body_template }</p>
</div>
@ -112,7 +112,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>IsActive</p>
<p className={'text-xs text-gray-500 '}>Active</p>
<p className={'line-clamp-2'}>{ dataFormatter.booleanFormatter(item.is_active) }</p>
</div>
@ -120,7 +120,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>StripeAccountReference</p>
<p className={'text-xs text-gray-500 '}>Stripe account reference</p>
<p className={'line-clamp-2'}>{ item.stripe_account_reference }</p>
</div>
@ -128,7 +128,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>StripeConnected</p>
<p className={'text-xs text-gray-500 '}>Stripe connected</p>
<p className={'line-clamp-2'}>{ dataFormatter.booleanFormatter(item.stripe_connected) }</p>
</div>
@ -136,7 +136,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>StripeConnectedAt</p>
<p className={'text-xs text-gray-500 '}>Stripe connected at</p>
<p className={'line-clamp-2'}>{ dataFormatter.dateTimeFormatter(item.stripe_connected_at) }</p>
</div>
@ -144,7 +144,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>DefaultReviewPlatform</p>
<p className={'text-xs text-gray-500 '}>Default review platform</p>
<p className={'line-clamp-2'}>{ item.default_review_platform }</p>
</div>
@ -152,7 +152,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>CustomReviewLink</p>
<p className={'text-xs text-gray-500 '}>Custom review link</p>
<p className={'line-clamp-2'}>{ item.custom_review_link }</p>
</div>

View File

@ -65,7 +65,7 @@ export const loadColumns = async (
{
field: 'name',
headerName: 'BusinessName',
headerName: 'Business name',
flex: 1,
minWidth: 120,
filterable: false,
@ -80,7 +80,7 @@ export const loadColumns = async (
{
field: 'google_review_link',
headerName: 'GoogleReviewLink',
headerName: 'Google review link',
flex: 1,
minWidth: 120,
filterable: false,
@ -95,7 +95,7 @@ export const loadColumns = async (
{
field: 'yelp_review_link',
headerName: 'YelpReviewLink',
headerName: 'Yelp review link',
flex: 1,
minWidth: 120,
filterable: false,
@ -110,7 +110,7 @@ export const loadColumns = async (
{
field: 'facebook_review_link',
headerName: 'FacebookReviewLink',
headerName: 'Facebook review link',
flex: 1,
minWidth: 120,
filterable: false,
@ -125,7 +125,7 @@ export const loadColumns = async (
{
field: 'delay_days',
headerName: 'DelayDays',
headerName: 'Review delay days',
flex: 1,
minWidth: 120,
filterable: false,
@ -141,7 +141,7 @@ export const loadColumns = async (
{
field: 'email_subject_template',
headerName: 'EmailSubjectTemplate',
headerName: 'Email subject template',
flex: 1,
minWidth: 120,
filterable: false,
@ -156,7 +156,7 @@ export const loadColumns = async (
{
field: 'email_body_template',
headerName: 'EmailBodyTemplate',
headerName: 'Email body template',
flex: 1,
minWidth: 120,
filterable: false,
@ -171,7 +171,7 @@ export const loadColumns = async (
{
field: 'is_active',
headerName: 'IsActive',
headerName: 'Active',
flex: 1,
minWidth: 120,
filterable: false,
@ -187,7 +187,7 @@ export const loadColumns = async (
{
field: 'stripe_account_reference',
headerName: 'StripeAccountReference',
headerName: 'Stripe account reference',
flex: 1,
minWidth: 120,
filterable: false,
@ -202,7 +202,7 @@ export const loadColumns = async (
{
field: 'stripe_connected',
headerName: 'StripeConnected',
headerName: 'Stripe connected',
flex: 1,
minWidth: 120,
filterable: false,
@ -218,7 +218,7 @@ export const loadColumns = async (
{
field: 'stripe_connected_at',
headerName: 'StripeConnectedAt',
headerName: 'Stripe connected at',
flex: 1,
minWidth: 120,
filterable: false,
@ -236,7 +236,7 @@ export const loadColumns = async (
{
field: 'default_review_platform',
headerName: 'DefaultReviewPlatform',
headerName: 'Default review platform',
flex: 1,
minWidth: 120,
filterable: false,
@ -251,7 +251,7 @@ export const loadColumns = async (
{
field: 'custom_review_link',
headerName: 'CustomReviewLink',
headerName: 'Custom review link',
flex: 1,
minWidth: 120,
filterable: false,

View File

@ -152,7 +152,7 @@ export const loadColumns = async (
type: 'dateTime',
valueGetter: (params: GridValueGetterParams) =>
new Date(params.row.last_transaction_at),
params.row.last_transaction_at ? new Date(params.row.last_transaction_at) : null,
},

View File

@ -1,6 +1,5 @@
import React, {useEffect, useRef} from 'react'
import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'
@ -12,6 +11,7 @@ import { setDarkMode } from '../stores/styleSlice'
import { logoutUser } from '../stores/authSlice'
import { useRouter } from 'next/router';
import ClickOutside from "./ClickOutside";
import { getPortalLabel } from '../helpers/portalRoles';
type Props = {
item: MenuNavBarItem
@ -30,7 +30,9 @@ export default function NavBarItem({ item }: Props) {
const currentUser = useAppSelector((state) => state.auth.currentUser);
const userName = `${currentUser?.firstName ? currentUser?.firstName : ""} ${currentUser?.lastName ? currentUser?.lastName : ""}`;
const userName = `${currentUser?.firstName ? currentUser?.firstName : ""} ${currentUser?.lastName ? currentUser?.lastName : ""}`.trim();
const userDisplayName = userName || currentUser?.email || '';
const portalLabel = currentUser ? getPortalLabel(currentUser) : '';
const [isDropdownActive, setIsDropdownActive] = useState(false)
@ -47,7 +49,7 @@ export default function NavBarItem({ item }: Props) {
item.isDesktopNoLabel ? 'lg:w-16 lg:justify-center' : '',
].join(' ')
const itemLabel = item.isCurrentUser ? userName : item.label
const itemLabel = item.isCurrentUser ? userDisplayName : item.label
const handleMenuClick = () => {
if (item.menu) {
@ -92,7 +94,12 @@ export default function NavBarItem({ item }: Props) {
item.isDesktopNoLabel && item.icon ? 'lg:hidden' : ''
}`}
>
{itemLabel}
{item.isCurrentUser ? (
<span className='flex flex-col leading-tight'>
<span>{itemLabel}</span>
<span className='text-[10px] uppercase tracking-wider opacity-70'>{portalLabel}</span>
</span>
) : itemLabel}
</span>
{item.isCurrentUser && <UserAvatarCurrentUser className="w-6 h-6 mr-3 inline-flex" />}
{item.menu && (

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -63,9 +63,39 @@ export const loadColumns = async (
},
{
field: 'provider',
headerName: 'Provider',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'provider_event_type',
headerName: 'ProviderEventType',
flex: 1,
minWidth: 160,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'stripe_event_reference',
headerName: 'StripeEventReference',
headerName: 'EventReference',
flex: 1,
minWidth: 120,
filterable: false,

View File

@ -0,0 +1,146 @@
import { mdiCreditCardOutline } from '@mdi/js'
import axios from 'axios'
import React, { useEffect, useState } from 'react'
import BaseButton from './BaseButton'
import CardBox from './CardBox'
import { useAppSelector } from '../stores/hooks'
import { isInternalAdmin } from '../helpers/portalRoles'
type LimitKey = 'monthlyReviewRequests' | 'businesses' | 'teamMembers' | 'paymentConnectors'
type SubscriptionLimitStatus = {
subscription: {
planId: string
planName: string
effectiveStatus: string
isActive: boolean
}
usage: Record<LimitKey, number>
limits: Record<LimitKey, number>
}
type Props = {
limitKey: LimitKey
actionLabel: string
label?: string
className?: string
nearLimitPercent?: number
}
const defaultLabels: Record<LimitKey, string> = {
monthlyReviewRequests: 'review requests this month',
businesses: 'business profiles',
teamMembers: 'team members',
paymentConnectors: 'connected payment providers',
}
function formatNumber(value: number) {
return value.toLocaleString()
}
export default function SubscriptionLimitGate({
limitKey,
actionLabel,
label,
className = 'mb-6',
nearLimitPercent = 80,
}: Props) {
const { currentUser } = useAppSelector((state) => state.auth)
const [status, setStatus] = useState<SubscriptionLimitStatus | null>(null)
const [error, setError] = useState('')
useEffect(() => {
let isMounted = true
const loadStatus = async () => {
try {
const response = await axios.get('/subscription/me')
if (isMounted) {
setStatus(response.data)
setError('')
}
} catch (requestError) {
console.error('Failed to load subscription limit status:', requestError)
if (isMounted) {
setError('Could not check plan limits right now. The backend will still enforce them when you submit.')
}
}
}
if (!currentUser || isInternalAdmin(currentUser)) {
setStatus(null)
setError('')
return () => {
isMounted = false
}
}
loadStatus()
return () => {
isMounted = false
}
}, [currentUser])
if (error) {
return (
<CardBox className={`${className} border-0 bg-amber-50 text-amber-950 ring-1 ring-amber-200 dark:bg-amber-950 dark:text-amber-50 dark:ring-amber-800`}>
<p className='text-sm font-black uppercase tracking-[0.25em]'>Plan check unavailable</p>
<p className='mt-2 text-sm leading-6'>{error}</p>
</CardBox>
)
}
if (!status) {
return null
}
const used = Number(status.usage[limitKey]) || 0
const limit = Number(status.limits[limitKey]) || 0
const limitLabel = label || (limit === 1 && limitKey === 'businesses'
? 'business profile'
: defaultLabels[limitKey])
const percent = limit > 0 ? Math.round((used / limit) * 100) : 0
const isInactive = !status.subscription.isActive
const isBlocked = isInactive || (limit > 0 && used >= limit)
const isNearLimit = !isBlocked && percent >= nearLimitPercent
if (!isBlocked && !isNearLimit) {
return null
}
const cardClass = isBlocked
? 'border-0 bg-rose-50 text-rose-950 ring-1 ring-rose-200 dark:bg-rose-950 dark:text-rose-50 dark:ring-rose-800'
: 'border-0 bg-amber-50 text-amber-950 ring-1 ring-amber-200 dark:bg-amber-950 dark:text-amber-50 dark:ring-amber-800'
const buttonLabel = status.subscription.planId === 'starter' ? 'Upgrade to Pro' : 'Manage plan'
return (
<CardBox className={`${className} ${cardClass}`}>
<div className='flex flex-col gap-4 md:flex-row md:items-center md:justify-between'>
<div>
<p className='text-sm font-black uppercase tracking-[0.25em]'>
{isBlocked ? 'Plan limit reached' : 'Plan limit almost reached'}
</p>
<h3 className='mt-2 text-xl font-black'>
{actionLabel} {isBlocked ? 'may be blocked' : 'is getting close to the limit'}
</h3>
<p className='mt-2 text-sm leading-6'>
{isInactive
? `Your ${status.subscription.planName} plan is ${status.subscription.effectiveStatus}. Reactivate or choose a plan before continuing.`
: `${status.subscription.planName} includes ${formatNumber(limit)} ${limitLabel}. This account is using ${formatNumber(used)}.`}
{' '}Existing data stays available.
</p>
</div>
<BaseButton
href='/subscription'
icon={mdiCreditCardOutline}
label={buttonLabel}
color={isBlocked ? 'danger' : 'warning'}
className='self-start md:self-center'
/>
</div>
</CardBox>
)
}

View File

@ -63,9 +63,24 @@ export const loadColumns = async (
},
{
field: 'payment_provider',
headerName: 'Provider',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'stripe_payment_reference',
headerName: 'StripePaymentReference',
headerName: 'PaymentReference',
flex: 1,
minWidth: 120,
filterable: false,

View File

@ -0,0 +1,26 @@
const starterPlanId = 'starter'
export function isStarterPlan(planId?: string | null) {
return planId === starterPlanId
}
export function getBusinessMenuLabel(planId?: string | null, businessLimit?: number | null) {
return isStarterPlan(planId) || Number(businessLimit) === 1 ? 'Business' : 'Businesses'
}
export function getBusinessProfileNoun(count?: number | null) {
return Number(count) === 1 ? 'business profile' : 'business profiles'
}
export function getBusinessProfileLimitLabel(limit?: number | null) {
const numericLimit = Number(limit) || 0
return `${numericLimit.toLocaleString()} ${getBusinessProfileNoun(numericLimit)}`
}
export function getBusinessProfileUsageLabel(used?: number | null, limit?: number | null) {
const numericUsed = Number(used) || 0
const numericLimit = Number(limit) || 0
return `${numericUsed.toLocaleString()} / ${getBusinessProfileLimitLabel(numericLimit)}`
}

View File

@ -0,0 +1,15 @@
const internalAdminRoleNames = ['Administrator']
export function getRoleName(user?: any) {
return user?.app_role?.name || ''
}
export function isInternalAdmin(user?: any) {
const roleName = getRoleName(user)
return internalAdminRoleNames.includes(roleName) || user?.email === 'admin@flatlogic.com'
}
export function getPortalLabel(user?: any) {
return isInternalAdmin(user) ? 'Internal Admin Portal' : 'Customer Workspace'
}

View File

@ -1,18 +1,21 @@
export function hasPermission(user, permission_name: string | string[]) {
if (!user?.app_role?.name) return false;
export function hasPermission(user, permission_name?: string | string[]) {
if (!user?.app_role?.name) return false
if (!permission_name) {
return true;
return true
}
if (user.app_role.name === 'Administrator') return true
const permissions = new Set<string>([
...(user?.custom_permissions ?? []).map((p) => p.name),
...(user?.app_role_permissions ?? []).map((p) => p.name),
]);
])
if (typeof permission_name === 'string') {
return permissions.has(permission_name) || user.app_role.name === 'Administrator'
} else {
return permission_name.some((permission) => permissions.has(permission));
return permissions.has(permission_name)
}
return permission_name.some((permission) => permissions.has(permission))
}

View File

@ -1,8 +1,7 @@
import React, { ReactNode, useEffect } from 'react'
import { useState } from 'react'
import React, { ReactNode, useEffect, useMemo, useState } from 'react'
import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside'
import { getMenuAsideForUser } from '../menuAside'
import menuNavBar from '../menuNavBar'
import BaseIcon from '../components/BaseIcon'
import NavBar from '../components/NavBar'
@ -15,19 +14,23 @@ import { useRouter } from 'next/router'
import {findMe, logoutUser} from "../stores/authSlice";
import {hasPermission} from "../helpers/userPermissions";
import { getBusinessMenuLabel } from '../helpers/businessPlanLabels';
import { getPortalLabel, isInternalAdmin } from '../helpers/portalRoles';
type Props = {
children: ReactNode
permission?: string
portal?: 'admin' | 'customer'
}
export default function LayoutAuthenticated({
children,
permission
permission,
portal
}: Props) {
const dispatch = useAppDispatch()
@ -64,11 +67,38 @@ export default function LayoutAuthenticated({
if (!hasPermission(currentUser, permission)) router.push('/error');
}, [currentUser, permission]);
useEffect(() => {
if (!portal || !currentUser) return;
const isAdminPortal = isInternalAdmin(currentUser);
if ((portal === 'admin' && !isAdminPortal) || (portal === 'customer' && isAdminPortal)) {
router.push('/dashboard');
}
}, [currentUser, portal, router]);
const darkMode = useAppSelector((state) => state.style.darkMode)
const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false)
const [isAsideLgActive, setIsAsideLgActive] = useState(false)
const businessMenuLabel = isInternalAdmin(currentUser)
? 'Business profiles'
: getBusinessMenuLabel(currentUser?.subscriptionPlanId)
const portalLabel = getPortalLabel(currentUser)
const planAwareMenuAside = useMemo(() => getMenuAsideForUser(currentUser).map((item) => {
const children = item.menu?.map((child) => (
child.href === '/businesses/businesses-list'
? { ...child, label: businessMenuLabel }
: child
))
if (item.href === '/businesses/businesses-list') {
return { ...item, label: businessMenuLabel }
}
return children ? { ...item, menu: children } : item
}), [businessMenuLabel, currentUser])
useEffect(() => {
const handleRouteChangeStart = () => {
@ -118,11 +148,11 @@ export default function LayoutAuthenticated({
<AsideMenu
isAsideMobileExpanded={isAsideMobileExpanded}
isAsideLgActive={isAsideLgActive}
menu={menuAside}
menu={planAwareMenuAside}
onAsideLgClose={() => setIsAsideLgActive(false)}
/>
{children}
<FooterBar>Hand-crafted & Made with </FooterBar>
<FooterBar>{portalLabel} · ReviewFlow</FooterBar>
</div>
</div>
)

View File

@ -1,107 +1,204 @@
import * as icon from '@mdi/js';
import * as icon from '@mdi/js'
import { MenuAsideItem } from './interfaces'
import { isInternalAdmin } from './helpers/portalRoles'
const menuAside: MenuAsideItem[] = [
const storeIcon =
'mdiStore' in icon
? icon['mdiStore' as keyof typeof icon]
: icon.mdiTable
const accountMultipleIcon =
'mdiAccountMultiple' in icon
? icon['mdiAccountMultiple' as keyof typeof icon]
: icon.mdiTable
const emailFastIcon =
'mdiEmailFastOutline' in icon
? icon['mdiEmailFastOutline' as keyof typeof icon]
: icon.mdiTable
const webhookIcon =
'mdiWebhook' in icon
? icon['mdiWebhook' as keyof typeof icon]
: icon.mdiTable
const emailCheckIcon =
'mdiEmailCheckOutline' in icon
? icon['mdiEmailCheckOutline' as keyof typeof icon]
: icon.mdiTable
const clockIcon =
'mdiClockOutline' in icon
? icon['mdiClockOutline' as keyof typeof icon]
: icon.mdiTable
export const customerMenuAside: MenuAsideItem[] = [
{
href: '/dashboard',
icon: icon.mdiViewDashboardOutline,
label: 'Dashboard',
},
{
href: '/users/users-list',
label: 'Users',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiAccountGroup ?? icon.mdiTable,
permissions: 'READ_USERS'
label: 'Workspace dashboard',
},
{
href: '/roles/roles-list',
label: 'Roles',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable,
permissions: 'READ_ROLES'
href: '/reviewflow',
icon: icon.mdiStarOutline,
label: 'Review Flow',
},
{
href: '/permissions/permissions-list',
label: 'Permissions',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiShieldAccountOutline ?? icon.mdiTable,
permissions: 'READ_PERMISSIONS'
href: '/growth-tools',
icon: icon.mdiStarCircleOutline,
label: 'Growth Tools',
},
{
href: '/businesses/businesses-list',
label: 'Businesses',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiStore' in icon ? icon['mdiStore' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_BUSINESSES'
icon: storeIcon,
permissions: 'READ_BUSINESSES',
},
{
href: '/customers/customers-list',
label: 'Customers',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiAccountMultiple' in icon ? icon['mdiAccountMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_CUSTOMERS'
icon: accountMultipleIcon,
permissions: 'READ_CUSTOMERS',
},
{
href: '/transactions/transactions-list',
label: 'Transactions',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiCreditCardOutline' in icon ? icon['mdiCreditCardOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_TRANSACTIONS'
icon: icon.mdiCreditCardOutline,
permissions: 'READ_TRANSACTIONS',
},
{
href: '/review_requests/review_requests-list',
label: 'Review requests',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiEmailFastOutline' in icon ? icon['mdiEmailFastOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_REVIEW_REQUESTS'
},
{
href: '/stripe_events/stripe_events-list',
label: 'Stripe events',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiWebhook' in icon ? icon['mdiWebhook' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_STRIPE_EVENTS'
icon: emailFastIcon,
permissions: 'READ_REVIEW_REQUESTS',
},
{
href: '/email_delivery_logs/email_delivery_logs-list',
label: 'Email delivery logs',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiEmailCheckOutline' in icon ? icon['mdiEmailCheckOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_EMAIL_DELIVERY_LOGS'
label: 'Email delivery',
icon: emailCheckIcon,
permissions: 'READ_EMAIL_DELIVERY_LOGS',
},
{
href: '/cron_runs/cron_runs-list',
label: 'Cron runs',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiClockOutline' in icon ? icon['mdiClockOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_CRON_RUNS'
href: '/users/users-list',
label: 'Team members',
icon: icon.mdiAccountGroup,
permissions: 'READ_USERS',
},
{
href: '/subscription',
icon: icon.mdiCreditCardOutline,
label: 'Subscription',
},
{
href: '/profile',
label: 'Profile',
icon: icon.mdiAccountCircle,
},
]
export const internalAdminMenuAside: MenuAsideItem[] = [
{
href: '/api-docs',
target: '_blank',
label: 'Swagger API',
icon: icon.mdiFileCode,
permissions: 'READ_API_DOCS'
href: '/dashboard',
icon: icon.mdiViewDashboardOutline,
label: 'Admin dashboard',
},
{
label: 'Customer operations',
icon: icon.mdiAccountGroup,
permissions: ['READ_USERS', 'READ_BUSINESSES', 'READ_CUSTOMERS'],
menu: [
{
href: '/users/users-list',
label: 'Customer accounts',
icon: icon.mdiAccountGroup,
permissions: 'READ_USERS',
},
{
href: '/businesses/businesses-list',
label: 'Business profiles',
icon: storeIcon,
permissions: 'READ_BUSINESSES',
},
{
href: '/customers/customers-list',
label: 'End customers',
icon: accountMultipleIcon,
permissions: 'READ_CUSTOMERS',
},
],
},
{
label: 'Review operations',
icon: icon.mdiStarOutline,
permissions: ['READ_REVIEW_REQUESTS', 'READ_EMAIL_DELIVERY_LOGS', 'READ_CRON_RUNS'],
menu: [
{
href: '/review_requests/review_requests-list',
label: 'Review requests',
icon: emailFastIcon,
permissions: 'READ_REVIEW_REQUESTS',
},
{
href: '/email_delivery_logs/email_delivery_logs-list',
label: 'Email delivery logs',
icon: emailCheckIcon,
permissions: 'READ_EMAIL_DELIVERY_LOGS',
},
{
href: '/cron_runs/cron_runs-list',
label: 'Automation runs',
icon: clockIcon,
permissions: 'READ_CRON_RUNS',
},
],
},
{
label: 'Billing & payments',
icon: icon.mdiCreditCardOutline,
permissions: ['READ_TRANSACTIONS', 'READ_STRIPE_EVENTS'],
menu: [
{
href: '/transactions/transactions-list',
label: 'Transactions',
icon: icon.mdiCreditCardOutline,
permissions: 'READ_TRANSACTIONS',
},
{
href: '/stripe_events/stripe_events-list',
label: 'Payment events',
icon: webhookIcon,
permissions: 'READ_STRIPE_EVENTS',
},
],
},
{
label: 'Access control',
icon: icon.mdiShieldAccountVariantOutline,
permissions: ['READ_ROLES', 'READ_PERMISSIONS'],
menu: [
{
href: '/roles/roles-list',
label: 'Roles',
icon: icon.mdiShieldAccountVariantOutline,
permissions: 'READ_ROLES',
},
{
href: '/permissions/permissions-list',
label: 'Permissions',
icon: icon.mdiKeyVariant,
permissions: 'READ_PERMISSIONS',
},
],
},
{
href: '/profile',
label: 'Profile',
icon: icon.mdiAccountCircle,
},
]
export default menuAside
export function getMenuAsideForUser(user?: any) {
return isInternalAdmin(user) ? internalAdminMenuAside : customerMenuAside
}
export default customerMenuAside

View File

@ -149,7 +149,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
setStepsEnabled(false);
};
const title = 'ReviewFlow'
const title = 'Review Flow'
const description = "Automate review-request emails after Stripe payments with templates, scheduling, and dashboard analytics."
const url = "https://flatlogic.com/"
const image = "https://project-screens.s3.amazonaws.com/screenshots/40346/app-hero-20260629-021915.png"

View File

@ -70,6 +70,8 @@ const EditBusinesses = () => {
'name': '',
'business_type': 'hybrid',
@ -469,10 +471,10 @@ const EditBusinesses = () => {
return (
<>
<Head>
<title>{getPageTitle('Edit businesses')}</title>
<title>{getPageTitle('Edit Business')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit businesses'} main>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='Edit Business' main>
{''}
</SectionTitleLineWithButton>
<CardBox>
@ -550,11 +552,11 @@ const EditBusinesses = () => {
<FormField
label="BusinessName"
label="Business name"
>
<Field
name="name"
placeholder="BusinessName"
placeholder="Business name"
/>
</FormField>
@ -586,12 +588,25 @@ const EditBusinesses = () => {
<FormField
label="GoogleReviewLink"
<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
name="google_review_link"
placeholder="GoogleReviewLink"
placeholder="Google review link"
/>
</FormField>
@ -624,11 +639,11 @@ const EditBusinesses = () => {
<FormField
label="YelpReviewLink"
label="Yelp review link"
>
<Field
name="yelp_review_link"
placeholder="YelpReviewLink"
placeholder="Yelp review link"
/>
</FormField>
@ -661,11 +676,11 @@ const EditBusinesses = () => {
<FormField
label="FacebookReviewLink"
label="Facebook review link"
>
<Field
name="facebook_review_link"
placeholder="FacebookReviewLink"
placeholder="Facebook review link"
/>
</FormField>
@ -704,12 +719,12 @@ const EditBusinesses = () => {
<FormField
label="DelayDays"
label="Review delay days"
>
<Field
type="number"
name="delay_days"
placeholder="DelayDays"
placeholder="Review delay days"
/>
</FormField>
@ -736,11 +751,11 @@ const EditBusinesses = () => {
<FormField
label="EmailSubjectTemplate"
label="Email subject template"
>
<Field
name="email_subject_template"
placeholder="EmailSubjectTemplate"
placeholder="Email subject template"
/>
</FormField>
@ -776,7 +791,7 @@ const EditBusinesses = () => {
<FormField label='EmailBodyTemplate' hasTextareaHeight>
<FormField label='Email body template' hasTextareaHeight>
<Field
name='email_body_template'
id='email_body_template'
@ -824,7 +839,7 @@ const EditBusinesses = () => {
<FormField label='IsActive' labelFor='is_active'>
<FormField label='Active' labelFor='is_active'>
<Field
name='is_active'
id='is_active'
@ -845,11 +860,11 @@ const EditBusinesses = () => {
<FormField
label="StripeAccountReference"
label="Stripe account reference"
>
<Field
name="stripe_account_reference"
placeholder="StripeAccountReference"
placeholder="Stripe account reference"
/>
</FormField>
@ -897,7 +912,7 @@ const EditBusinesses = () => {
<FormField label='StripeConnected' labelFor='stripe_connected'>
<FormField label='Stripe connected' labelFor='stripe_connected'>
<Field
name='stripe_connected'
id='stripe_connected'
@ -928,7 +943,7 @@ const EditBusinesses = () => {
<FormField
label="StripeConnectedAt"
label="Stripe connected at"
>
<DatePicker
dateFormat="yyyy-MM-dd hh:mm"
@ -974,7 +989,7 @@ const EditBusinesses = () => {
<FormField label="DefaultReviewPlatform" labelFor="default_review_platform">
<FormField label="Default review platform" labelFor="default_review_platform">
<Field name="default_review_platform" id="default_review_platform" component="select">
<option value="google">google</option>
@ -1003,11 +1018,11 @@ const EditBusinesses = () => {
<FormField
label="CustomReviewLink"
label="Custom review link"
>
<Field
name="custom_review_link"
placeholder="CustomReviewLink"
placeholder="Custom review link"
/>
</FormField>

View File

@ -70,6 +70,8 @@ const EditBusinessesPage = () => {
'name': '',
'business_type': 'hybrid',
@ -466,10 +468,10 @@ const EditBusinessesPage = () => {
return (
<>
<Head>
<title>{getPageTitle('Edit businesses')}</title>
<title>{getPageTitle('Edit Business')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit businesses'} main>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='Edit Business' main>
{''}
</SectionTitleLineWithButton>
<CardBox>
@ -547,11 +549,11 @@ const EditBusinessesPage = () => {
<FormField
label="BusinessName"
label="Business name"
>
<Field
name="name"
placeholder="BusinessName"
placeholder="Business name"
/>
</FormField>
@ -583,12 +585,25 @@ const EditBusinessesPage = () => {
<FormField
label="GoogleReviewLink"
<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
name="google_review_link"
placeholder="GoogleReviewLink"
placeholder="Google review link"
/>
</FormField>
@ -621,11 +636,11 @@ const EditBusinessesPage = () => {
<FormField
label="YelpReviewLink"
label="Yelp review link"
>
<Field
name="yelp_review_link"
placeholder="YelpReviewLink"
placeholder="Yelp review link"
/>
</FormField>
@ -658,11 +673,11 @@ const EditBusinessesPage = () => {
<FormField
label="FacebookReviewLink"
label="Facebook review link"
>
<Field
name="facebook_review_link"
placeholder="FacebookReviewLink"
placeholder="Facebook review link"
/>
</FormField>
@ -701,12 +716,12 @@ const EditBusinessesPage = () => {
<FormField
label="DelayDays"
label="Review delay days"
>
<Field
type="number"
name="delay_days"
placeholder="DelayDays"
placeholder="Review delay days"
/>
</FormField>
@ -733,11 +748,11 @@ const EditBusinessesPage = () => {
<FormField
label="EmailSubjectTemplate"
label="Email subject template"
>
<Field
name="email_subject_template"
placeholder="EmailSubjectTemplate"
placeholder="Email subject template"
/>
</FormField>
@ -773,7 +788,7 @@ const EditBusinessesPage = () => {
<FormField label='EmailBodyTemplate' hasTextareaHeight>
<FormField label='Email body template' hasTextareaHeight>
<Field
name='email_body_template'
id='email_body_template'
@ -821,7 +836,7 @@ const EditBusinessesPage = () => {
<FormField label='IsActive' labelFor='is_active'>
<FormField label='Active' labelFor='is_active'>
<Field
name='is_active'
id='is_active'
@ -842,11 +857,11 @@ const EditBusinessesPage = () => {
<FormField
label="StripeAccountReference"
label="Stripe account reference"
>
<Field
name="stripe_account_reference"
placeholder="StripeAccountReference"
placeholder="Stripe account reference"
/>
</FormField>
@ -894,7 +909,7 @@ const EditBusinessesPage = () => {
<FormField label='StripeConnected' labelFor='stripe_connected'>
<FormField label='Stripe connected' labelFor='stripe_connected'>
<Field
name='stripe_connected'
id='stripe_connected'
@ -925,7 +940,7 @@ const EditBusinessesPage = () => {
<FormField
label="StripeConnectedAt"
label="Stripe connected at"
>
<DatePicker
dateFormat="yyyy-MM-dd hh:mm"
@ -971,7 +986,7 @@ const EditBusinessesPage = () => {
<FormField label="DefaultReviewPlatform" labelFor="default_review_platform">
<FormField label="Default review platform" labelFor="default_review_platform">
<Field name="default_review_platform" id="default_review_platform" component="select">
<option value="google">google</option>
@ -1000,11 +1015,11 @@ const EditBusinessesPage = () => {
<FormField
label="CustomReviewLink"
label="Custom review link"
>
<Field
name="custom_review_link"
placeholder="CustomReviewLink"
placeholder="Custom review link"
/>
</FormField>

View File

@ -14,10 +14,13 @@ import Link from "next/link";
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import CardBoxModal from "../../components/CardBoxModal";
import DragDropFilePicker from "../../components/DragDropFilePicker";
import SubscriptionLimitGate from '../../components/SubscriptionLimitGate';
import {setRefetch, uploadCsv} from '../../stores/businesses/businessesSlice';
import {hasPermission} from "../../helpers/userPermissions";
import { getBusinessMenuLabel } from '../../helpers/businessPlanLabels';
import { isInternalAdmin } from '../../helpers/portalRoles';
@ -34,20 +37,22 @@ const BusinessesTablesPage = () => {
const dispatch = useAppDispatch();
const [filters] = useState([{label: 'BusinessName', title: 'name'},{label: 'GoogleReviewLink', title: 'google_review_link'},{label: 'YelpReviewLink', title: 'yelp_review_link'},{label: 'FacebookReviewLink', title: 'facebook_review_link'},{label: 'EmailSubjectTemplate', title: 'email_subject_template'},{label: 'EmailBodyTemplate', title: 'email_body_template'},{label: 'StripeAccountReference', title: 'stripe_account_reference'},{label: 'CustomReviewLink', title: 'custom_review_link'},
{label: 'DelayDays', title: 'delay_days', number: 'true'},
const [filters] = useState([{label: 'Business name', title: 'name'},{label: 'Google review link', title: 'google_review_link'},{label: 'Yelp review link', title: 'yelp_review_link'},{label: 'Facebook review link', title: 'facebook_review_link'},{label: 'Email subject template', title: 'email_subject_template'},{label: 'Email body template', title: 'email_body_template'},{label: 'Stripe account reference', title: 'stripe_account_reference'},{label: 'Custom review link', title: 'custom_review_link'},
{label: 'Review delay days', title: 'delay_days', number: 'true'},
{label: 'StripeConnectedAt', title: 'stripe_connected_at', date: 'true'},
{label: 'Stripe connected at', title: 'stripe_connected_at', date: 'true'},
{label: 'Owner', title: 'owner'},
{label: 'DefaultReviewPlatform', title: 'default_review_platform', type: 'enum', options: ['google','yelp','facebook','custom']},
{label: 'Default review platform', title: 'default_review_platform', type: 'enum', options: ['google','yelp','facebook','custom']},
]);
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_BUSINESSES');
const isAdminPortal = isInternalAdmin(currentUser);
const businessPageTitle = isAdminPortal ? 'Business profiles' : getBusinessMenuLabel(currentUser?.subscriptionPlanId);
const addFilter = () => {
@ -90,15 +95,27 @@ const BusinessesTablesPage = () => {
return (
<>
<Head>
<title>{getPageTitle('Businesses')}</title>
<title>{getPageTitle(businessPageTitle)}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Businesses" main>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={businessPageTitle} main>
{''}
</SectionTitleLineWithButton>
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
<CardBox className='mb-6 border-0 bg-indigo-50 text-indigo-950 ring-1 ring-indigo-100 dark:bg-indigo-950 dark:text-indigo-50 dark:ring-indigo-900'>
<p className='font-black'>{businessPageTitle} setup</p>
<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. Grow accounts manage one business profile; Pro accounts can manage up to ten.'}
</p>
</CardBox>
<SubscriptionLimitGate
limitKey='businesses'
actionLabel='Adding another business profile'
/>
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/businesses/businesses-new'} color='info' label='New Item'/>}
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/businesses/businesses-new'} color='info' label='Add Business'/>}
<BaseButton
className={'mr-3'}

View File

@ -2,6 +2,7 @@ import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js
import Head from 'next/head'
import React, { ReactElement } from 'react'
import CardBox from '../../components/CardBox'
import SubscriptionLimitGate from '../../components/SubscriptionLimitGate'
import LayoutAuthenticated from '../../layouts/Authenticated'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
@ -48,6 +49,8 @@ const initialValues = {
name: '',
business_type: 'hybrid',
@ -271,12 +274,16 @@ const BusinessesNew = () => {
return (
<>
<Head>
<title>{getPageTitle('New Item')}</title>
<title>{getPageTitle('Add Business')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='Add Business' main>
{''}
</SectionTitleLineWithButton>
<SubscriptionLimitGate
limitKey='businesses'
actionLabel='Adding another business profile'
/>
<CardBox>
<Formik
initialValues={
@ -321,11 +328,11 @@ const BusinessesNew = () => {
<FormField
label="BusinessName"
label="Business name"
>
<Field
name="name"
placeholder="BusinessName"
placeholder="Business name"
/>
</FormField>
@ -352,15 +359,28 @@ const BusinessesNew = () => {
<FormField
label="GoogleReviewLink"
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
name="google_review_link"
placeholder="GoogleReviewLink"
placeholder="Google review link"
/>
</FormField>
@ -391,11 +411,11 @@ const BusinessesNew = () => {
<FormField
label="YelpReviewLink"
label="Yelp review link"
>
<Field
name="yelp_review_link"
placeholder="YelpReviewLink"
placeholder="Yelp review link"
/>
</FormField>
@ -426,11 +446,11 @@ const BusinessesNew = () => {
<FormField
label="FacebookReviewLink"
label="Facebook review link"
>
<Field
name="facebook_review_link"
placeholder="FacebookReviewLink"
placeholder="Facebook review link"
/>
</FormField>
@ -467,12 +487,12 @@ const BusinessesNew = () => {
<FormField
label="DelayDays"
label="Review delay days"
>
<Field
type="number"
name="delay_days"
placeholder="DelayDays"
placeholder="Review delay days"
/>
</FormField>
@ -497,11 +517,11 @@ const BusinessesNew = () => {
<FormField
label="EmailSubjectTemplate"
label="Email subject template"
>
<Field
name="email_subject_template"
placeholder="EmailSubjectTemplate"
placeholder="Email subject template"
/>
</FormField>
@ -535,7 +555,7 @@ const BusinessesNew = () => {
<FormField label='EmailBodyTemplate' hasTextareaHeight>
<FormField label='Email body template' hasTextareaHeight>
<Field
name='email_body_template'
id='email_body_template'
@ -581,7 +601,7 @@ const BusinessesNew = () => {
<FormField label='IsActive' labelFor='is_active'>
<FormField label='Active' labelFor='is_active'>
<Field
name='is_active'
id='is_active'
@ -600,11 +620,11 @@ const BusinessesNew = () => {
<FormField
label="StripeAccountReference"
label="Stripe account reference"
>
<Field
name="stripe_account_reference"
placeholder="StripeAccountReference"
placeholder="Stripe account reference"
/>
</FormField>
@ -650,7 +670,7 @@ const BusinessesNew = () => {
<FormField label='StripeConnected' labelFor='stripe_connected'>
<FormField label='Stripe connected' labelFor='stripe_connected'>
<Field
name='stripe_connected'
id='stripe_connected'
@ -679,12 +699,12 @@ const BusinessesNew = () => {
<FormField
label="StripeConnectedAt"
label="Stripe connected at"
>
<Field
type="datetime-local"
name="stripe_connected_at"
placeholder="StripeConnectedAt"
placeholder="Stripe connected at"
/>
</FormField>
@ -718,7 +738,7 @@ const BusinessesNew = () => {
<FormField label="DefaultReviewPlatform" labelFor="default_review_platform">
<FormField label="Default review platform" labelFor="default_review_platform">
<Field name="default_review_platform" id="default_review_platform" component="select">
<option value="google">google</option>
@ -745,11 +765,11 @@ const BusinessesNew = () => {
<FormField
label="CustomReviewLink"
label="Custom review link"
>
<Field
name="custom_review_link"
placeholder="CustomReviewLink"
placeholder="Custom review link"
/>
</FormField>

View File

@ -14,10 +14,13 @@ import Link from "next/link";
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import CardBoxModal from "../../components/CardBoxModal";
import DragDropFilePicker from "../../components/DragDropFilePicker";
import SubscriptionLimitGate from '../../components/SubscriptionLimitGate';
import {setRefetch, uploadCsv} from '../../stores/businesses/businessesSlice';
import {hasPermission} from "../../helpers/userPermissions";
import { getBusinessMenuLabel } from '../../helpers/businessPlanLabels';
import { isInternalAdmin } from '../../helpers/portalRoles';
@ -34,20 +37,22 @@ const BusinessesTablesPage = () => {
const dispatch = useAppDispatch();
const [filters] = useState([{label: 'BusinessName', title: 'name'},{label: 'GoogleReviewLink', title: 'google_review_link'},{label: 'YelpReviewLink', title: 'yelp_review_link'},{label: 'FacebookReviewLink', title: 'facebook_review_link'},{label: 'EmailSubjectTemplate', title: 'email_subject_template'},{label: 'EmailBodyTemplate', title: 'email_body_template'},{label: 'StripeAccountReference', title: 'stripe_account_reference'},{label: 'CustomReviewLink', title: 'custom_review_link'},
{label: 'DelayDays', title: 'delay_days', number: 'true'},
const [filters] = useState([{label: 'Business name', title: 'name'},{label: 'Google review link', title: 'google_review_link'},{label: 'Yelp review link', title: 'yelp_review_link'},{label: 'Facebook review link', title: 'facebook_review_link'},{label: 'Email subject template', title: 'email_subject_template'},{label: 'Email body template', title: 'email_body_template'},{label: 'Stripe account reference', title: 'stripe_account_reference'},{label: 'Custom review link', title: 'custom_review_link'},
{label: 'Review delay days', title: 'delay_days', number: 'true'},
{label: 'StripeConnectedAt', title: 'stripe_connected_at', date: 'true'},
{label: 'Stripe connected at', title: 'stripe_connected_at', date: 'true'},
{label: 'Owner', title: 'owner'},
{label: 'DefaultReviewPlatform', title: 'default_review_platform', type: 'enum', options: ['google','yelp','facebook','custom']},
{label: 'Default review platform', title: 'default_review_platform', type: 'enum', options: ['google','yelp','facebook','custom']},
]);
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_BUSINESSES');
const isAdminPortal = isInternalAdmin(currentUser);
const businessPageTitle = isAdminPortal ? 'Business profiles' : getBusinessMenuLabel(currentUser?.subscriptionPlanId);
const addFilter = () => {
@ -90,15 +95,27 @@ const BusinessesTablesPage = () => {
return (
<>
<Head>
<title>{getPageTitle('Businesses')}</title>
<title>{getPageTitle(businessPageTitle)}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Businesses" main>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={businessPageTitle} main>
{''}
</SectionTitleLineWithButton>
<CardBox className='mb-6 border-0 bg-indigo-50 text-indigo-950 ring-1 ring-indigo-100 dark:bg-indigo-950 dark:text-indigo-50 dark:ring-indigo-900'>
<p className='font-black'>{businessPageTitle} setup</p>
<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. Grow accounts manage one business profile; Pro accounts can manage up to ten.'}
</p>
</CardBox>
<SubscriptionLimitGate
limitKey='businesses'
actionLabel='Adding another business profile'
/>
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/businesses/businesses-new'} color='info' label='New Item'/>}
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/businesses/businesses-new'} color='info' label='Add Business'/>}
<BaseButton
className={'mr-3'}

View File

@ -29,11 +29,6 @@ const BusinessesView = () => {
const { id } = router.query;
function removeLastCharacter(str) {
console.log(str,`str`)
return str.slice(0, -1);
}
useEffect(() => {
dispatch(fetch({ id }));
}, [dispatch, id]);
@ -42,10 +37,10 @@ const BusinessesView = () => {
return (
<>
<Head>
<title>{getPageTitle('View businesses')}</title>
<title>{getPageTitle('View Business')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={removeLastCharacter('View businesses')} main>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='View Business' main>
<BaseButton
color='info'
label='Edit'
@ -113,7 +108,7 @@ const BusinessesView = () => {
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>BusinessName</p>
<p className={'block font-bold mb-2'}>Business name</p>
<p>{businesses?.name}</p>
</div>
@ -145,7 +140,7 @@ const BusinessesView = () => {
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>GoogleReviewLink</p>
<p className={'block font-bold mb-2'}>Google review link</p>
<p>{businesses?.google_review_link}</p>
</div>
@ -177,7 +172,7 @@ const BusinessesView = () => {
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>YelpReviewLink</p>
<p className={'block font-bold mb-2'}>Yelp review link</p>
<p>{businesses?.yelp_review_link}</p>
</div>
@ -209,7 +204,7 @@ const BusinessesView = () => {
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>FacebookReviewLink</p>
<p className={'block font-bold mb-2'}>Facebook review link</p>
<p>{businesses?.facebook_review_link}</p>
</div>
@ -247,7 +242,7 @@ const BusinessesView = () => {
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>DelayDays</p>
<p className={'block font-bold mb-2'}>Review delay days</p>
<p>{businesses?.delay_days || 'No data'}</p>
</div>
@ -273,7 +268,7 @@ const BusinessesView = () => {
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>EmailSubjectTemplate</p>
<p className={'block font-bold mb-2'}>Email subject template</p>
<p>{businesses?.email_subject_template}</p>
</div>
@ -309,7 +304,7 @@ const BusinessesView = () => {
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>EmailBodyTemplate</p>
<p className={'block font-bold mb-2'}>Email body template</p>
{businesses.email_body_template
? <p dangerouslySetInnerHTML={{__html: businesses.email_body_template}}/>
: <p>No data</p>
@ -355,7 +350,7 @@ const BusinessesView = () => {
<FormField label='IsActive'>
<FormField label='Active'>
<SwitchField
field={{name: 'is_active', value: businesses?.is_active}}
form={{setFieldValue: () => null}}
@ -375,7 +370,7 @@ const BusinessesView = () => {
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>StripeAccountReference</p>
<p className={'block font-bold mb-2'}>Stripe account reference</p>
<p>{businesses?.stripe_account_reference}</p>
</div>
@ -422,7 +417,7 @@ const BusinessesView = () => {
<FormField label='StripeConnected'>
<FormField label='Stripe connected'>
<SwitchField
field={{name: 'stripe_connected', value: businesses?.stripe_connected}}
form={{setFieldValue: () => null}}
@ -451,7 +446,7 @@ const BusinessesView = () => {
<FormField label='StripeConnectedAt'>
<FormField label='Stripe connected at'>
{businesses.stripe_connected_at ? <DatePicker
dateFormat="yyyy-MM-dd hh:mm"
showTimeSelect
@ -461,7 +456,7 @@ const BusinessesView = () => {
) : null
}
disabled
/> : <p>No StripeConnectedAt</p>}
/> : <p>No Stripe connection date</p>}
</FormField>
@ -496,7 +491,7 @@ const BusinessesView = () => {
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>DefaultReviewPlatform</p>
<p className={'block font-bold mb-2'}>Default review platform</p>
<p>{businesses?.default_review_platform ?? 'No data'}</p>
</div>
@ -514,7 +509,7 @@ const BusinessesView = () => {
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>CustomReviewLink</p>
<p className={'block font-bold mb-2'}>Custom review link</p>
<p>{businesses?.custom_review_link}</p>
</div>
@ -550,7 +545,7 @@ const BusinessesView = () => {
<>
<p className={'block font-bold mb-2'}>Customers Business</p>
<p className={'block font-bold mb-2'}>Customers for this business</p>
<CardBox
className='mb-6 border border-gray-300 rounded overflow-hidden'
hasTable

View File

@ -0,0 +1,64 @@
import { mdiConnection, mdiOpenInNew, mdiWebhook } from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement } from 'react';
import BaseButton from '../components/BaseButton';
import PaymentProviderConnectors from '../components/ReviewFlow/PaymentProviderConnectors';
import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import { getPageTitle } from '../config';
import LayoutAuthenticated from '../layouts/Authenticated';
export default function ConnectPage() {
return (
<>
<Head>
<title>{getPageTitle('Connect')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiConnection} title='Connect' main>
<BaseButton
href='/reviewflow'
icon={mdiOpenInNew}
label='Review Flow'
color='whiteDark'
/>
</SectionTitleLineWithButton>
<div className='mb-6 overflow-hidden rounded-3xl bg-gradient-to-br from-slate-950 via-blue-950 to-indigo-950 p-6 text-white shadow-2xl'>
<div className='grid gap-6 lg:grid-cols-[1.15fr_0.85fr] lg:items-center'>
<div>
<p className='mb-3 inline-flex rounded-full bg-white/10 px-4 py-1 text-sm font-semibold text-sky-200 ring-1 ring-white/20'>
Trigger setup · destination clarity
</p>
<h2 className='max-w-3xl text-4xl font-black tracking-tight md:text-5xl'>
Connect order/payment triggers without confusing local review channels.
</h2>
<p className='mt-4 max-w-2xl text-base text-slate-200 md:text-lg'>
Use this page to generate secure webhook URLs for payment and ecommerce triggers. Review destinations stay separate: local businesses use Google, Facebook, Yelp, Angi, or OpenTable links, ecommerce brands can use Trustpilot, and Shopify can use a hosted product-review form.
</p>
</div>
<div className='rounded-3xl bg-white/10 p-5 ring-1 ring-white/15 backdrop-blur'>
<div className='mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-sky-400/20 text-sky-100'>
<BaseButton icon={mdiWebhook} color='info' roundedFull />
</div>
<h3 className='text-xl font-black'>How connection works</h3>
<p className='mt-2 text-sm leading-6 text-slate-200'>
Pick the order/payment trigger first, then choose where reviews should land. Shopify is both an ecommerce trigger and a hosted Review Flow product-review destination.
</p>
</div>
</div>
</div>
<PaymentProviderConnectors
eyebrow='Provider connections'
title='Connect triggers and choose review destinations'
description='Choose Stripe, PayPal, Square, Shopify, or WooCommerce as the order/payment trigger. Then choose a separate review destination so local and ecommerce customers see the right experience.'
/>
</SectionMain>
</>
);
}
ConnectPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated portal='customer'>{page}</LayoutAuthenticated>;
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,431 +1,546 @@
import * as icon from '@mdi/js';
import * as icon from '@mdi/js'
import Head from 'next/head'
import React from 'react'
import axios from 'axios';
import axios from 'axios'
import type { ReactElement } from 'react'
import Link from 'next/link'
import LayoutAuthenticated from '../layouts/Authenticated'
import SectionMain from '../components/SectionMain'
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
import BaseIcon from "../components/BaseIcon";
import BaseIcon from '../components/BaseIcon'
import BaseButton from '../components/BaseButton'
import CardBox from '../components/CardBox'
import { getPageTitle } from '../config'
import Link from "next/link";
import { hasPermission } from "../helpers/userPermissions";
import { fetchWidgets } from '../stores/roles/rolesSlice';
import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator';
import { SmartWidget } from '../components/SmartWidget/SmartWidget';
import { hasPermission } from '../helpers/userPermissions'
import { getBusinessMenuLabel } from '../helpers/businessPlanLabels'
import { getPortalLabel, isInternalAdmin } from '../helpers/portalRoles'
import { fetchWidgets } from '../stores/roles/rolesSlice'
import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator'
import { SmartWidget } from '../components/SmartWidget/SmartWidget'
import { useAppDispatch, useAppSelector } from '../stores/hooks'
type EntityKey =
| 'users'
| 'roles'
| 'permissions'
| 'businesses'
| 'customers'
| 'transactions'
| 'review_requests'
| 'stripe_events'
| 'email_delivery_logs'
| 'cron_runs'
type CountValue = string | number | null
type CountState = Record<EntityKey, CountValue>
type DashboardCard = {
key: EntityKey
label: string
description: string
href: string
iconPath: string
permission: string
}
type DashboardAction = {
label: string
href: string
permission?: string | string[]
}
type DashboardActionGroup = {
title: string
description: string
actions: DashboardAction[]
}
const loadingMessage = 'Loading...'
const entityKeys: EntityKey[] = [
'users',
'roles',
'permissions',
'businesses',
'customers',
'transactions',
'review_requests',
'stripe_events',
'email_delivery_logs',
'cron_runs',
]
const entityConfig: Record<EntityKey, { endpoint: string; permission: string }> = {
users: { endpoint: 'users', permission: 'READ_USERS' },
roles: { endpoint: 'roles', permission: 'READ_ROLES' },
permissions: { endpoint: 'permissions', permission: 'READ_PERMISSIONS' },
businesses: { endpoint: 'businesses', permission: 'READ_BUSINESSES' },
customers: { endpoint: 'customers', permission: 'READ_CUSTOMERS' },
transactions: { endpoint: 'transactions', permission: 'READ_TRANSACTIONS' },
review_requests: { endpoint: 'review_requests', permission: 'READ_REVIEW_REQUESTS' },
stripe_events: { endpoint: 'stripe_events', permission: 'READ_STRIPE_EVENTS' },
email_delivery_logs: { endpoint: 'email_delivery_logs', permission: 'READ_EMAIL_DELIVERY_LOGS' },
cron_runs: { endpoint: 'cron_runs', permission: 'READ_CRON_RUNS' },
}
const initialCounts = entityKeys.reduce((counts, key) => {
counts[key] = loadingMessage
return counts
}, {} as CountState)
const storeIcon =
'mdiStore' in icon
? icon['mdiStore' as keyof typeof icon]
: icon.mdiTable
const accountMultipleIcon =
'mdiAccountMultiple' in icon
? icon['mdiAccountMultiple' as keyof typeof icon]
: icon.mdiTable
const emailFastIcon =
'mdiEmailFastOutline' in icon
? icon['mdiEmailFastOutline' as keyof typeof icon]
: icon.mdiTable
const webhookIcon =
'mdiWebhook' in icon
? icon['mdiWebhook' as keyof typeof icon]
: icon.mdiTable
const emailCheckIcon =
'mdiEmailCheckOutline' in icon
? icon['mdiEmailCheckOutline' as keyof typeof icon]
: icon.mdiTable
const clockIcon =
'mdiClockOutline' in icon
? icon['mdiClockOutline' as keyof typeof icon]
: icon.mdiTable
function formatCount(value: CountValue) {
if (value === null || value === undefined) return '—'
if (typeof value === 'number') return value.toLocaleString()
return value
}
function StatCard({
card,
value,
corners,
cardsStyle,
iconsColor,
}: {
card: DashboardCard
value: CountValue
corners: string
cardsStyle: string
iconsColor: string
}) {
return (
<Link href={card.href}>
<div
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6 h-full hover:shadow-lg transition-shadow`}
>
<div className='flex justify-between gap-4'>
<div>
<div className='text-sm font-black uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400'>
{card.label}
</div>
<div className='mt-2 text-3xl leading-tight font-semibold'>
{formatCount(value)}
</div>
<p className='mt-3 text-sm text-gray-500 dark:text-gray-400'>
{card.description}
</p>
</div>
<BaseIcon
className={`${iconsColor} flex-none`}
w='w-16'
h='h-16'
size={48}
path={card.iconPath}
/>
</div>
</div>
</Link>
)
}
function ActionGroupCard({ group, currentUser }: { group: DashboardActionGroup; currentUser: any }) {
const visibleActions = group.actions.filter(
(action) => !action.permission || hasPermission(currentUser, action.permission),
)
if (!visibleActions.length) return null
return (
<CardBox className='h-full'>
<div className='flex h-full flex-col'>
<div>
<h2 className='text-xl font-semibold'>{group.title}</h2>
<p className='mt-2 text-sm text-gray-500 dark:text-gray-400'>{group.description}</p>
</div>
<div className='mt-6 grid gap-3'>
{visibleActions.map((action) => (
<BaseButton
key={`${group.title}-${action.href}`}
href={action.href}
label={action.label}
color='whiteDark'
className='w-full justify-start'
/>
))}
</div>
</div>
</CardBox>
)
}
function PortalIntroCard({ currentUser, adminPortal }: { currentUser: any; adminPortal: boolean }) {
const portalLabel = getPortalLabel(currentUser)
const roleName = currentUser?.app_role?.name || 'User'
const name = currentUser?.firstName || currentUser?.email || 'there'
return (
<CardBox className='mb-6 overflow-hidden'>
<div className='grid gap-6 lg:grid-cols-[1.4fr_0.8fr] lg:items-center'>
<div>
<p className='text-xs font-black uppercase tracking-[0.25em] text-indigo-500 dark:text-indigo-300'>
{portalLabel}
</p>
<h2 className='mt-3 text-2xl font-bold'>Welcome, {name}</h2>
<p className='mt-3 text-gray-600 dark:text-gray-300'>
{adminPortal
? 'This internal area is for running the SaaS business: customer accounts, business profiles, billing events, review operations, and access control.'
: 'This customer workspace is for setting up your business profile, connecting review automation, managing customers, tracking transactions, and handling your subscription.'}
</p>
</div>
<div className='rounded-3xl bg-slate-950 p-6 text-white'>
<p className='text-sm text-slate-300'>Signed in as</p>
<p className='mt-2 text-2xl font-bold'>{roleName}</p>
<p className='mt-4 text-sm text-slate-300'>
{adminPortal
? 'Customer workspace setup links are intentionally hidden from this portal.'
: 'Internal platform administration links are intentionally hidden from this workspace.'}
</p>
</div>
</div>
</CardBox>
)
}
import { useAppDispatch, useAppSelector } from '../stores/hooks';
const Dashboard = () => {
const dispatch = useAppDispatch();
const iconsColor = useAppSelector((state) => state.style.iconsColor);
const corners = useAppSelector((state) => state.style.corners);
const cardsStyle = useAppSelector((state) => state.style.cardsStyle);
const dispatch = useAppDispatch()
const iconsColor = useAppSelector((state) => state.style.iconsColor)
const corners = useAppSelector((state) => state.style.corners)
const cardsStyle = useAppSelector((state) => state.style.cardsStyle)
const { currentUser } = useAppSelector((state) => state.auth)
const { isFetchingQuery } = useAppSelector((state) => state.openAi)
const { rolesWidgets, loading } = useAppSelector((state) => state.roles)
const [counts, setCounts] = React.useState<CountState>(initialCounts)
const [widgetsRole, setWidgetsRole] = React.useState({
role: { value: '', label: '' },
})
const loadingMessage = 'Loading...';
const adminPortal = isInternalAdmin(currentUser)
const businessLabel = getBusinessMenuLabel(currentUser?.subscriptionPlanId)
const businessProfilesLabel = adminPortal ? 'Business profiles' : businessLabel
const loadData = React.useCallback(async () => {
if (!currentUser) return
const [users, setUsers] = React.useState(loadingMessage);
const [roles, setRoles] = React.useState(loadingMessage);
const [permissions, setPermissions] = React.useState(loadingMessage);
const [businesses, setBusinesses] = React.useState(loadingMessage);
const [customers, setCustomers] = React.useState(loadingMessage);
const [transactions, setTransactions] = React.useState(loadingMessage);
const [review_requests, setReview_requests] = React.useState(loadingMessage);
const [stripe_events, setStripe_events] = React.useState(loadingMessage);
const [email_delivery_logs, setEmail_delivery_logs] = React.useState(loadingMessage);
const [cron_runs, setCron_runs] = React.useState(loadingMessage);
const requests = entityKeys.map(async (key) => {
const config = entityConfig[key]
if (!hasPermission(currentUser, config.permission)) {
return { key, count: null }
}
const [widgetsRole, setWidgetsRole] = React.useState({
role: { value: '', label: '' },
});
const { currentUser } = useAppSelector((state) => state.auth);
const { isFetchingQuery } = useAppSelector((state) => state.openAi);
const response = await axios.get(`/${config.endpoint}/count`)
const { rolesWidgets, loading } = useAppSelector((state) => state.roles);
return { key, count: response.data.count as CountValue }
})
const results = await Promise.allSettled(requests)
async function loadData() {
const entities = ['users','roles','permissions','businesses','customers','transactions','review_requests','stripe_events','email_delivery_logs','cron_runs',];
const fns = [setUsers,setRoles,setPermissions,setBusinesses,setCustomers,setTransactions,setReview_requests,setStripe_events,setEmail_delivery_logs,setCron_runs,];
setCounts((previousCounts) => {
const nextCounts = { ...previousCounts }
const requests = entities.map((entity, index) => {
results.forEach((result, index) => {
const key = entityKeys[index]
if(hasPermission(currentUser, `READ_${entity.toUpperCase()}`)) {
return axios.get(`/${entity.toLowerCase()}/count`);
} else {
fns[index](null);
return Promise.resolve({data: {count: null}});
}
if (result.status === 'fulfilled') {
nextCounts[result.value.key] = result.value.count
} else {
console.error(`Failed to load ${key} dashboard count:`, result.reason)
nextCounts[key] = 'Error'
}
})
});
return nextCounts
})
}, [currentUser])
Promise.allSettled(requests).then((results) => {
results.forEach((result, i) => {
if (result.status === 'fulfilled') {
fns[i](result.value.data.count);
} else {
fns[i](result.reason.message);
}
});
});
}
const getWidgets = React.useCallback(async (roleId: string) => {
await dispatch(fetchWidgets(roleId))
}, [dispatch])
async function getWidgets(roleId) {
await dispatch(fetchWidgets(roleId));
}
React.useEffect(() => {
if (!currentUser) return;
loadData().then();
setWidgetsRole({ role: { value: currentUser?.app_role?.id, label: currentUser?.app_role?.name } });
}, [currentUser]);
React.useEffect(() => {
if (!currentUser) return
React.useEffect(() => {
if (!currentUser || !widgetsRole?.role?.value) return;
getWidgets(widgetsRole?.role?.value || '').then();
}, [widgetsRole?.role?.value]);
loadData().then()
setWidgetsRole({
role: {
value: currentUser?.app_role?.id,
label: currentUser?.app_role?.name,
},
})
}, [currentUser, loadData])
React.useEffect(() => {
if (!currentUser || !widgetsRole?.role?.value || adminPortal) return
getWidgets(widgetsRole?.role?.value || '').then()
}, [adminPortal, currentUser, getWidgets, widgetsRole?.role?.value])
const adminCards: DashboardCard[] = [
{
key: 'users',
label: 'Customer accounts',
description: 'Owners and team users across the platform.',
href: '/users/users-list',
iconPath: icon.mdiAccountGroup,
permission: 'READ_USERS',
},
{
key: 'businesses',
label: 'Business profiles',
description: 'Business locations connected to review flows.',
href: '/businesses/businesses-list',
iconPath: storeIcon,
permission: 'READ_BUSINESSES',
},
{
key: 'transactions',
label: 'Transactions',
description: 'Payment records feeding review requests.',
href: '/transactions/transactions-list',
iconPath: icon.mdiCreditCardOutline,
permission: 'READ_TRANSACTIONS',
},
{
key: 'review_requests',
label: 'Review requests',
description: 'Review invitations generated by the system.',
href: '/review_requests/review_requests-list',
iconPath: emailFastIcon,
permission: 'READ_REVIEW_REQUESTS',
},
{
key: 'stripe_events',
label: 'Payment events',
description: 'Stripe webhook events and processing status.',
href: '/stripe_events/stripe_events-list',
iconPath: webhookIcon,
permission: 'READ_STRIPE_EVENTS',
},
{
key: 'cron_runs',
label: 'Automation runs',
description: 'Scheduled background job execution history.',
href: '/cron_runs/cron_runs-list',
iconPath: clockIcon,
permission: 'READ_CRON_RUNS',
},
]
const customerCards: DashboardCard[] = [
{
key: 'businesses',
label: businessProfilesLabel,
description: 'Your business profile and Google review destination.',
href: '/businesses/businesses-list',
iconPath: storeIcon,
permission: 'READ_BUSINESSES',
},
{
key: 'customers',
label: 'Customers',
description: 'Customer records created from payments or imports.',
href: '/customers/customers-list',
iconPath: accountMultipleIcon,
permission: 'READ_CUSTOMERS',
},
{
key: 'transactions',
label: 'Transactions',
description: 'Payments that can trigger review follow-up.',
href: '/transactions/transactions-list',
iconPath: icon.mdiCreditCardOutline,
permission: 'READ_TRANSACTIONS',
},
{
key: 'review_requests',
label: 'Review requests',
description: 'Messages scheduled or sent to customers.',
href: '/review_requests/review_requests-list',
iconPath: emailFastIcon,
permission: 'READ_REVIEW_REQUESTS',
},
{
key: 'email_delivery_logs',
label: 'Email delivery',
description: 'Delivery activity for review request emails.',
href: '/email_delivery_logs/email_delivery_logs-list',
iconPath: emailCheckIcon,
permission: 'READ_EMAIL_DELIVERY_LOGS',
},
]
const adminActionGroups: DashboardActionGroup[] = [
{
title: 'Customer operations',
description: 'Support customer accounts and the business profiles they manage.',
actions: [
{ label: 'Review customer accounts', href: '/users/users-list', permission: 'READ_USERS' },
{ label: 'Review business profiles', href: '/businesses/businesses-list', permission: 'READ_BUSINESSES' },
{ label: 'Review end customers', href: '/customers/customers-list', permission: 'READ_CUSTOMERS' },
],
},
{
title: 'Billing & review operations',
description: 'Monitor payment data, webhook events, review requests, and delivery health.',
actions: [
{ label: 'View transactions', href: '/transactions/transactions-list', permission: 'READ_TRANSACTIONS' },
{ label: 'View payment events', href: '/stripe_events/stripe_events-list', permission: 'READ_STRIPE_EVENTS' },
{ label: 'View review requests', href: '/review_requests/review_requests-list', permission: 'READ_REVIEW_REQUESTS' },
{ label: 'View email delivery logs', href: '/email_delivery_logs/email_delivery_logs-list', permission: 'READ_EMAIL_DELIVERY_LOGS' },
{ label: 'View automation runs', href: '/cron_runs/cron_runs-list', permission: 'READ_CRON_RUNS' },
],
},
{
title: 'Platform access control',
description: 'Manage internal roles and permissions for the platform.',
actions: [
{ label: 'Manage roles', href: '/roles/roles-list', permission: 'READ_ROLES' },
{ label: 'Manage permissions', href: '/permissions/permissions-list', permission: 'READ_PERMISSIONS' },
],
},
]
const customerActionGroups: DashboardActionGroup[] = [
{
title: 'Review automation setup',
description: 'Configure the business profile, review request templates, and payment triggers.',
actions: [
{ label: 'Open Review Flow', href: '/reviewflow' },
{ label: `Manage ${businessLabel}`, href: '/businesses/businesses-list', permission: 'READ_BUSINESSES' },
{ label: 'Manage review requests', href: '/review_requests/review_requests-list', permission: 'READ_REVIEW_REQUESTS' },
],
},
{
title: 'Customer records',
description: 'Track customers and transactions that power follow-up messages.',
actions: [
{ label: 'Manage customers', href: '/customers/customers-list', permission: 'READ_CUSTOMERS' },
{ label: 'View transactions', href: '/transactions/transactions-list', permission: 'READ_TRANSACTIONS' },
{ label: 'View email delivery', href: '/email_delivery_logs/email_delivery_logs-list', permission: 'READ_EMAIL_DELIVERY_LOGS' },
],
},
{
title: 'Plan & billing',
description: 'Review plan limits, usage, and billing status for this workspace.',
actions: [
{ label: 'Open subscription', href: '/subscription' },
],
},
]
const cards = adminPortal ? adminCards : customerCards
const actionGroups = adminPortal ? adminActionGroups : customerActionGroups
const visibleCards = cards.filter((card) => hasPermission(currentUser, card.permission))
const title = adminPortal ? 'Internal admin portal' : 'Customer workspace'
const sectionIcon = adminPortal ? icon.mdiShieldAccountVariantOutline : icon.mdiChartTimelineVariant
const showCustomerWidgets = !adminPortal && hasPermission(currentUser, 'CREATE_ROLES')
return (
<>
<Head>
<title>
{getPageTitle('Overview')}
</title>
<title>{getPageTitle(title)}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={icon.mdiChartTimelineVariant}
title='Overview'
main>
<SectionTitleLineWithButton icon={sectionIcon} title={title} main>
{''}
</SectionTitleLineWithButton>
{hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator
<PortalIntroCard currentUser={currentUser} adminPortal={adminPortal} />
{showCustomerWidgets && (
<WidgetCreator
currentUser={currentUser}
isFetchingQuery={isFetchingQuery}
setWidgetsRole={setWidgetsRole}
widgetsRole={widgetsRole}
/>}
{!!rolesWidgets.length &&
hasPermission(currentUser, 'CREATE_ROLES') && (
<p className=' text-gray-500 dark:text-gray-400 mb-4'>
{`${widgetsRole?.role?.label || 'Users'}'s widgets`}
</p>
)}
/>
)}
{!!rolesWidgets.length && showCustomerWidgets && (
<p className='mb-4 text-gray-500 dark:text-gray-400'>
{`${widgetsRole?.role?.label || 'Users'}'s widgets`}
</p>
)}
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-6 grid-flow-dense'>
{!adminPortal && (
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-6 grid-flow-dense'>
{(isFetchingQuery || loading) && (
<div className={` ${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 text-lg leading-tight text-gray-500 flex items-center ${cardsStyle} dark:border-dark-700 p-6`}>
<BaseIcon
className={`${iconsColor} animate-spin mr-5`}
w='w-16'
h='h-16'
size={48}
path={icon.mdiLoading}
/>{' '}
Loading widgets...
</div>
<div className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 text-lg leading-tight text-gray-500 flex items-center ${cardsStyle} dark:border-dark-700 p-6`}>
<BaseIcon
className={`${iconsColor} animate-spin mr-5`}
w='w-16'
h='h-16'
size={48}
path={icon.mdiLoading}
/>{' '}
Loading widgets...
</div>
)}
{ rolesWidgets &&
rolesWidgets.map((widget) => (
<SmartWidget
key={widget.id}
userId={currentUser?.id}
widget={widget}
roleId={widgetsRole?.role?.value || ''}
admin={hasPermission(currentUser, 'CREATE_ROLES')}
/>
{rolesWidgets.map((widget) => (
<SmartWidget
key={widget.id}
userId={currentUser?.id}
widget={widget}
roleId={widgetsRole?.role?.value || ''}
admin={false}
/>
))}
</div>
)}
{!adminPortal && !!rolesWidgets.length && <hr className='my-6' />}
<div id='dashboard' className='grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6'>
{visibleCards.map((card) => (
<StatCard
key={`${card.key}-${card.label}`}
card={card}
value={counts[card.key]}
corners={corners}
cardsStyle={cardsStyle}
iconsColor={iconsColor}
/>
))}
</div>
{!!rolesWidgets.length && <hr className='my-6 ' />}
<div id="dashboard" className='grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6'>
{hasPermission(currentUser, 'READ_USERS') && <Link href={'/users/users-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Users
</div>
<div className="text-3xl leading-tight font-semibold">
{users}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={icon.mdiAccountGroup || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_ROLES') && <Link href={'/roles/roles-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Roles
</div>
<div className="text-3xl leading-tight font-semibold">
{roles}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={icon.mdiShieldAccountVariantOutline || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_PERMISSIONS') && <Link href={'/permissions/permissions-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Permissions
</div>
<div className="text-3xl leading-tight font-semibold">
{permissions}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={icon.mdiShieldAccountOutline || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_BUSINESSES') && <Link href={'/businesses/businesses-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Businesses
</div>
<div className="text-3xl leading-tight font-semibold">
{businesses}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiStore' in icon ? icon['mdiStore' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_CUSTOMERS') && <Link href={'/customers/customers-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Customers
</div>
<div className="text-3xl leading-tight font-semibold">
{customers}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiAccountMultiple' in icon ? icon['mdiAccountMultiple' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_TRANSACTIONS') && <Link href={'/transactions/transactions-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Transactions
</div>
<div className="text-3xl leading-tight font-semibold">
{transactions}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiCreditCardOutline' in icon ? icon['mdiCreditCardOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_REVIEW_REQUESTS') && <Link href={'/review_requests/review_requests-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Review requests
</div>
<div className="text-3xl leading-tight font-semibold">
{review_requests}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiEmailFastOutline' in icon ? icon['mdiEmailFastOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_STRIPE_EVENTS') && <Link href={'/stripe_events/stripe_events-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Stripe events
</div>
<div className="text-3xl leading-tight font-semibold">
{stripe_events}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiWebhook' in icon ? icon['mdiWebhook' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_EMAIL_DELIVERY_LOGS') && <Link href={'/email_delivery_logs/email_delivery_logs-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Email delivery logs
</div>
<div className="text-3xl leading-tight font-semibold">
{email_delivery_logs}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiEmailCheckOutline' in icon ? icon['mdiEmailCheckOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_CRON_RUNS') && <Link href={'/cron_runs/cron_runs-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Cron runs
</div>
<div className="text-3xl leading-tight font-semibold">
{cron_runs}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiClockOutline' in icon ? icon['mdiClockOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
<div className='grid grid-cols-1 gap-6 lg:grid-cols-3'>
{actionGroups.map((group) => (
<ActionGroupCard key={group.title} group={group} currentUser={currentUser} />
))}
</div>
</SectionMain>
</>

View File

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

View File

@ -1,161 +1,261 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import { mdiArrowRight, mdiCheckCircleOutline, mdiLogin, mdiShieldCheckOutline, mdiStarCircleOutline } from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
import React, { ReactElement } from 'react';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
import { subscriptionPlans, trialDays } from '../subscriptionPlans';
import { getBusinessProfileNoun } from '../helpers/businessPlanLabels';
const metrics = [
['7 days', 'default review delay'],
['5 sources', 'Stripe, Square, PayPal, Shopify, WooCommerce'],
['4 states', 'pending, sent, clicked, reviewed'],
];
const steps = [
['Capture', 'Receive Stripe, Square, PayPal, Shopify, or WooCommerce webhooks as soon as checkout happens.'],
['Schedule', 'Create the customer, transaction, and review request automatically with your preferred delay.'],
['Track', 'Follow pending, sent, clicked, and reviewed requests from one workspace.'],
];
const features = [
'Business review links and templates',
'Webhook-created customers and transactions',
'Readable queue with message preview',
'Internal admin controls stay separate from customer workspaces',
];
export default function Starter() {
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('video');
const [contentPosition, setContentPosition] = useState('left');
const textColor = useAppSelector((state) => state.style.linkColor);
const title = 'ReviewFlow'
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<div className="min-h-screen bg-[#F7F8FC] text-slate-950">
<Head>
<title>{getPageTitle('Starter Page')}</title>
<title>{getPageTitle('Review Flow')}</title>
<meta
name="description"
content="Review Flow helps businesses queue and track review requests after customer purchases or visits."
/>
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your ReviewFlow app!"/>
<header className="sticky top-0 z-20 border-b border-white/70 bg-white/80 backdrop-blur-xl">
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
<Link href="/" className="flex items-center gap-3 font-black tracking-tight">
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#101828] text-white shadow-lg shadow-indigo-950/20">
</span>
<span className="text-xl">Review Flow</span>
</Link>
<nav className="flex items-center gap-3">
<BaseButton href="/#pricing" label="Pricing" color="whiteDark" />
<BaseButton href="/login" icon={mdiLogin} label="Login" color="whiteDark" />
<BaseButton href="/reviewflow" icon={mdiArrowRight} label="Review Flow workspace" color="info" />
</nav>
</div>
</header>
<div className="space-y-3">
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center text-gray-500'>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
<main>
<section className="relative isolate overflow-hidden px-6 py-20 md:py-28">
<div className="absolute left-1/2 top-0 -z-10 h-[640px] w-[920px] -translate-x-1/2 rounded-full bg-[radial-gradient(circle_at_center,#7C3AED_0%,#10B981_38%,transparent_68%)] opacity-20 blur-3xl" />
<div className="mx-auto grid max-w-7xl gap-12 lg:grid-cols-[1.05fr_0.95fr] lg:items-center">
<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" />
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 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" />
<BaseButton href="/login" icon={mdiShieldCheckOutline} label="Log in to workspace" color="whiteDark" />
</div>
<div className="mt-10 grid max-w-2xl gap-3 sm:grid-cols-3">
{metrics.map(([value, label]) => (
<div key={label} className="rounded-3xl border border-white bg-white/80 p-5 shadow-xl shadow-slate-200/60">
<p className="text-3xl font-black text-slate-950">{value}</p>
<p className="mt-1 text-sm text-slate-500">{label}</p>
</div>
))}
</div>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
<CardBox className="border-0 bg-white/90 shadow-2xl shadow-indigo-950/10 ring-1 ring-slate-200/70" cardBoxClassName="p-0">
<div className="rounded-t-3xl bg-[#101828] p-5 text-white">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-bold uppercase tracking-[0.25em] text-emerald-300">Live workflow</p>
<h2 className="mt-2 text-2xl font-black">Review request queued</h2>
</div>
<span className="rounded-full bg-emerald-400/20 px-3 py-1 text-sm font-bold text-emerald-200 ring-1 ring-emerald-300/30">
pending
</span>
</div>
</div>
<div className="space-y-5 p-6">
<div className="rounded-3xl bg-slate-50 p-5">
<p className="text-xs font-black uppercase tracking-[0.25em] text-slate-400">Customer</p>
<p className="mt-2 text-lg font-black">Maya Chen</p>
<p className="text-slate-500">maya@example.com</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-3xl bg-indigo-50 p-5 text-indigo-950">
<p className="text-sm font-bold text-indigo-500">Scheduled</p>
<p className="text-2xl font-black">+7 days</p>
</div>
<div className="rounded-3xl bg-emerald-50 p-5 text-emerald-950">
<p className="text-sm font-bold text-emerald-600">Destination</p>
<p className="text-2xl font-black">Google</p>
</div>
</div>
<div className="rounded-3xl border border-dashed border-slate-200 p-5">
<p className="font-black">How was your experience with Review Flow Studio?</p>
<p className="mt-2 text-sm leading-6 text-slate-500">
Hi Maya, thank you for choosing us. We would love to hear about your experience.
</p>
</div>
</div>
</CardBox>
</div>
</section>
</BaseButtons>
</CardBox>
<section className="px-6 pb-20">
<div className="mx-auto grid max-w-7xl gap-6 lg:grid-cols-3">
{steps.map(([title, copy], index) => (
<div key={title} className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-xl shadow-slate-200/50">
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-950 text-lg font-black text-white">
{index + 1}
</div>
<h3 className="text-2xl font-black">{title}</h3>
<p className="mt-3 leading-7 text-slate-600">{copy}</p>
</div>
))}
</div>
</section>
<section id="pricing" className="px-6 pb-20">
<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 Grow or Pro.</h2>
<p className="mt-5 text-lg leading-8 text-slate-600">
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>
<div className="mt-12 grid gap-6 lg:grid-cols-2">
{subscriptionPlans.map((plan) => {
const isPro = plan.id === 'pro';
return (
<CardBox
key={plan.id}
className={`relative overflow-hidden border-0 bg-white shadow-2xl ${
isPro ? 'shadow-indigo-950/20 ring-2 ring-indigo-600' : 'shadow-slate-200/70 ring-1 ring-slate-200'
}`}
cardBoxClassName="p-0"
>
{plan.highlight && (
<div className="absolute right-6 top-6 rounded-full bg-indigo-600 px-4 py-1 text-sm font-black text-white shadow-lg shadow-indigo-600/30">
{plan.highlight}
</div>
)}
<div className="p-8">
<p className="text-sm font-black uppercase tracking-[0.3em] text-slate-400">Review Flow</p>
<h3 className="mt-3 text-3xl font-black text-slate-950">{plan.name}</h3>
<p className="mt-3 min-h-[56px] leading-7 text-slate-600">{plan.tagline}</p>
<div className="mt-8 flex items-end gap-2">
<span className="text-5xl font-black tracking-tight text-slate-950">${plan.priceMonthly}</span>
<span className="pb-2 font-bold text-slate-500">/month</span>
</div>
<p className="mt-2 text-sm font-bold text-emerald-600">{plan.trialDays}-day free trial included</p>
<div className="mt-8 grid gap-3 rounded-3xl bg-slate-50 p-5 sm:grid-cols-2">
<div>
<p className="text-2xl font-black text-slate-950">{plan.limits.monthlyReviewRequests.toLocaleString()}</p>
<p className="text-sm text-slate-500">review requests/month</p>
</div>
<div>
<p className="text-2xl font-black text-slate-950">{plan.limits.businesses}</p>
<p className="text-sm text-slate-500">{getBusinessProfileNoun(plan.limits.businesses)}</p>
</div>
<div>
<p className="text-2xl font-black text-slate-950">{plan.limits.teamMembers}</p>
<p className="text-sm text-slate-500">team members</p>
</div>
<div>
<p className="text-2xl font-black text-slate-950">{plan.limits.paymentConnectors}</p>
<p className="text-sm text-slate-500">payment connectors</p>
</div>
</div>
<BaseButton
href={`/register?plan=${plan.id}`}
icon={mdiArrowRight}
label={plan.ctaLabel}
color={isPro ? 'info' : 'whiteDark'}
className="mt-8 w-full shadow-xl shadow-indigo-600/10"
/>
</div>
<div className={isPro ? 'bg-indigo-950 p-8 text-white' : 'bg-slate-950 p-8 text-white'}>
<p className="mb-5 text-sm font-black uppercase tracking-[0.25em] text-emerald-300">Included features</p>
<div className="grid gap-3">
{plan.features.map((feature) => (
<div key={feature} className="flex items-start gap-3">
<span className="mt-1 text-emerald-300">
<svg className="h-5 w-5" viewBox="0 0 24 24">
<path fill="currentColor" d={mdiCheckCircleOutline} />
</svg>
</span>
<span className="font-semibold text-slate-100">{feature}</span>
</div>
))}
</div>
</div>
</CardBox>
);
})}
</div>
</div>
</section>
<section className="bg-[#101828] px-6 py-20 text-white">
<div className="mx-auto grid max-w-7xl gap-10 lg:grid-cols-[0.8fr_1.2fr] lg:items-center">
<div>
<p className="text-sm font-black uppercase tracking-[0.3em] text-emerald-300">First MVP slice</p>
<h2 className="mt-4 text-4xl font-black tracking-tight md:text-5xl">A complete thin workflow, not just a screen.</h2>
<p className="mt-5 leading-8 text-slate-300">
The customer workspace lets an account owner connect payment webhooks, receive events, create transactions and customers, queue review requests, browse recent activity, and inspect the generated message. Internal admin users stay separate for support and operations.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{features.map((feature) => (
<div key={feature} className="flex items-start gap-3 rounded-3xl bg-white/10 p-5 ring-1 ring-white/10">
<span className="mt-1 text-emerald-300"><svg className="h-5 w-5" viewBox="0 0 24 24"><path fill="currentColor" d={mdiCheckCircleOutline} /></svg></span>
<span className="font-semibold text-slate-100">{feature}</span>
</div>
))}
</div>
</div>
</section>
</main>
<footer className="bg-white px-6 py-8">
<div className="mx-auto flex max-w-7xl flex-col gap-4 text-sm text-slate-500 md:flex-row md:items-center md:justify-between">
<p>© 2026 Review Flow. All rights reserved.</p>
<div className="flex gap-5">
<Link href="/privacy-policy/" className="hover:text-slate-950">Privacy Policy</Link>
<Link href="/terms-of-use/" className="hover:text-slate-950">Terms of Use</Link>
<Link href="/login" className="font-bold text-slate-950">Login</Link>
</div>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
</footer>
</div>
);
}
@ -163,4 +263,3 @@ export default function Starter() {
Starter.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -20,7 +20,7 @@ import { findMe, loginUser, resetAction } from '../stores/authSlice';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
import Link from 'next/link';
import {toast, ToastContainer} from "react-toastify";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
import { getPexelsImage } from '../helpers/pexels'
export default function Login() {
const router = useRouter();
@ -33,26 +33,99 @@ export default function Login() {
photographer: undefined,
photographer_url: undefined,
})
const [ illustrationVideo, setIllustrationVideo ] = useState({video_files: []})
const [contentType, setContentType] = useState('video');
const [contentPosition, setContentPosition] = useState('left');
const [contentPosition] = useState<'left' | 'right' | 'background'>('left');
const [showPassword, setShowPassword] = useState(false);
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 = 'ReviewFlow'
const title = 'Review Flow'
// Fetch Pexels image/video
const appHighlights = [
'Automated review requests after payments, jobs, or service milestones.',
'Customer, business, transaction, and delivery follow-up data in one customer workspace.',
'Dashboards, CRM records, payment events, email logs, and separate internal admin controls already built in.',
];
const competitorAdvantages = [
{
title: 'Built around review operations',
description:
'Review Flow combines CRM records, payments, follow-up, review requests, and reputation workflows in one focused system.',
},
{
title: 'Designed for logistics teams',
description:
'Transportation teams can manage businesses, customers, transactions, payment events, and review requests without jumping tools.',
},
{
title: 'Clear Grow and Pro tiers',
description:
'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: 'Grow',
price: '$49',
description:
'Best for small teams that need the core Review Flow workflow and simple monthly limits.',
sections: [
{
title: 'Core review workflow',
features: [
'Review Flow workspace for creating, scheduling, and tracking review requests.',
'Manual review request creation and hosted public review forms.',
'Customer, business, transaction, and delivery follow-up records.',
],
},
{
title: 'Grow limits',
features: [
'250 review requests per month.',
'1 business profile.',
'2 team members.',
'Stripe, Square, PayPal, Shopify, and WooCommerce webhook intake.',
],
},
],
},
{
name: 'Pro',
price: '$99',
description:
'Best for growing teams that need higher Review Flow limits on the same working workflow.',
sections: [
{
title: 'Everything in Grow',
features: [
'2,500 review requests per month.',
'10 business profiles.',
'10 team members.',
'Subscription usage dashboard and upgrade controls.',
],
},
{
title: 'Working Pro upgrades',
features: [
'Higher monthly review-request limit.',
'More business profiles for multiple locations or brands.',
'Larger team-member limit with the same invitation workflow.',
],
},
],
},
];
// Fetch Pexels image
useEffect( () => {
async function fetchData() {
const image = await getPexelsImage()
const video = await getPexelsVideo()
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
@ -115,32 +188,7 @@ export default function Login() {
</div>
)
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video.user.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
return (
<div style={contentPosition === 'background' ? {
@ -159,8 +207,7 @@ export default function Login() {
<SectionFullScreen bg='violet'>
<div className={`flex ${contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'} min-h-screen w-full`}>
{contentType === 'image' && contentPosition !== 'background' ? imageBlock(illustrationImage) : null}
{contentType === 'video' && contentPosition !== 'background' ? videoBlock(illustrationVideo) : null}
{contentPosition !== 'background' ? imageBlock(illustrationImage) : null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'>
@ -171,17 +218,23 @@ export default function Login() {
<div>
<p className='mb-2'>Use{' '}
<code className={`cursor-pointer ${textColor} `}
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 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 Admin</p>
<p>Use <code
className={`cursor-pointer ${textColor} `}
data-password="874c3b951385"
onClick={(e) => setLogin(e.target)}>client@hello.com</code>{' / '}
<code className={`${textColor}`}>874c3b951385</code>{' / '}
to login as User</p>
to login as Internal Admin</p>
</div>
<div>
<BaseIcon
@ -257,6 +310,95 @@ export default function Login() {
</Form>
</Formik>
</CardBox>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<div className='space-y-8'>
<div>
<p className='text-sm font-semibold uppercase tracking-[0.2em] text-blue-600'>About Us</p>
<h3 className='mt-2 text-3xl font-semibold text-gray-900 dark:text-white'>
Review management built for transportation teams.
</h3>
<p className='mt-4 text-base leading-7 text-gray-600 dark:text-slate-300'>
Review Flow helps logistics and transportation businesses turn completed jobs, payments,
and customer interactions into organized review requests. Your team can manage customer
records, monitor follow-up, and keep reputation-building work moving from one secure
workspace.
</p>
</div>
<div className='grid gap-3 md:grid-cols-3'>
{appHighlights.map((highlight) => (
<div
key={highlight}
className='rounded-2xl border border-blue-100 bg-blue-50/70 p-4 text-sm leading-6 text-blue-900 dark:border-dark-700 dark:bg-dark-800 dark:text-slate-200'
>
{highlight}
</div>
))}
</div>
<div>
<h4 className='text-xl font-semibold text-gray-900 dark:text-white'>Why we&apos;re better</h4>
<div className='mt-4 grid gap-4 md:grid-cols-3'>
{competitorAdvantages.map((item) => (
<div key={item.title} className='rounded-2xl border border-gray-200 p-4 dark:border-dark-700'>
<h5 className='font-semibold text-gray-900 dark:text-white'>{item.title}</h5>
<p className='mt-2 text-sm leading-6 text-gray-600 dark:text-slate-300'>
{item.description}
</p>
</div>
))}
</div>
</div>
<div>
<div className='flex flex-col justify-between gap-2 md:flex-row md:items-end'>
<div>
<p className='text-sm font-semibold uppercase tracking-[0.2em] text-blue-600'>Pricing</p>
<h4 className='mt-2 text-2xl font-semibold text-gray-900 dark:text-white'>Simple monthly plans</h4>
</div>
<p className='text-sm text-gray-500 dark:text-slate-400'>Upgrade when your review workflow grows.</p>
</div>
<div className='mt-4 grid gap-4 md:grid-cols-2'>
{pricingPlans.map((plan) => (
<div
key={plan.name}
className='rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-dark-700 dark:bg-dark-900'
>
<div className='flex items-start justify-between gap-3'>
<div>
<h5 className='text-lg font-semibold text-gray-900 dark:text-white'>{plan.name}</h5>
<p className='mt-1 text-sm leading-6 text-gray-600 dark:text-slate-300'>{plan.description}</p>
</div>
<div className='text-right'>
<span className='text-3xl font-bold text-blue-600'>{plan.price}</span>
<span className='block text-xs text-gray-500 dark:text-slate-400'>/month</span>
</div>
</div>
<div className='mt-5 space-y-5'>
{plan.sections.map((section) => (
<div key={section.title}>
<h6 className='text-sm font-semibold uppercase tracking-wide text-gray-900 dark:text-white'>
{section.title}
</h6>
<ul className='mt-2 space-y-2 text-sm text-gray-700 dark:text-slate-300'>
{section.features.map((feature) => (
<li key={feature} className='flex gap-2'>
<span className='font-semibold text-blue-600'></span>
<span>{feature}</span>
</li>
))}
</ul>
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
</CardBox>
</div>
</div>
</SectionFullScreen>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,7 @@ import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
export default function PrivacyPolicy() {
const title = 'ReviewFlow'
const title = 'Review Flow'
const [projectUrl, setProjectUrl] = useState('');
useEffect(() => {

View File

@ -12,12 +12,15 @@ import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { useRouter } from 'next/router';
import { getPageTitle } from '../config';
import { subscriptionPlans } from '../subscriptionPlans';
import axios from "axios";
export default function Register() {
const [loading, setLoading] = React.useState(false);
const router = useRouter();
const selectedPlanId = typeof router.query.plan === 'string' ? router.query.plan : 'starter';
const selectedPlan = subscriptionPlans.find((plan) => plan.id === selectedPlanId) || subscriptionPlans[0];
const notify = (type, msg) => toast( msg, {type, position: "bottom-center"});
@ -25,7 +28,7 @@ export default function Register() {
setLoading(true)
try {
const { data: response } = await axios.post('/auth/signup',value);
const { data: response } = await axios.post('/auth/signup',{ ...value, planId: selectedPlan.id });
await router.push('/login')
setLoading(false)
notify('success', 'Please check your email for verification link')
@ -44,6 +47,10 @@ export default function Register() {
<SectionFullScreen bg='violet'>
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
<div className='mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-emerald-900'>
<p className='font-black'>{selectedPlan.name} trial</p>
<p className='text-sm'>${selectedPlan.priceMonthly}/month after the {selectedPlan.trialDays}-day free trial. You can manage billing from Subscription after signup.</p>
</div>
<Formik
initialValues={{
email: '',

View File

@ -0,0 +1,344 @@
import {
mdiArrowLeft,
mdiCheckCircleOutline,
mdiStar,
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import { useRouter } from 'next/router';
import React, { FormEvent, ReactElement, useEffect, useState } from 'react';
import BaseButton from '../../components/BaseButton';
import CardBox from '../../components/CardBox';
import LayoutGuest from '../../layouts/Guest';
import { getPageTitle } from '../../config';
interface HostedReviewProduct {
name?: string;
sku?: string;
quantity?: number | null;
}
interface HostedReviewPayload {
provider?: string;
order?: {
id?: string;
name?: string;
orderNumber?: string;
};
products?: HostedReviewProduct[];
}
interface HostedReviewRequest {
id: string;
status?: string;
review_platform?: string;
review_rating?: number | null;
review_title?: string | null;
review_content?: string | null;
reviewer_display_name?: string | null;
review_payload?: HostedReviewPayload | null;
business?: {
name?: string;
} | null;
customer?: {
name?: string;
email?: string;
} | null;
transaction?: {
payment_provider?: string;
description?: string;
amount?: string | number;
currency?: string;
} | null;
}
const ratingOptions = [1, 2, 3, 4, 5];
function getErrorMessage(error: unknown) {
if (axios.isAxiosError(error) && error.response?.data) {
const responseData = error.response.data;
if (typeof responseData === 'string') {
return responseData;
}
if (typeof responseData === 'object' && 'message' in responseData) {
return String(responseData.message);
}
}
return 'Something went wrong. Please try again.';
}
function formatAmount(amount?: string | number, currency?: string) {
const numericAmount = Number(amount);
if (!Number.isFinite(numericAmount)) {
return '';
}
return new Intl.NumberFormat('en', {
style: 'currency',
currency: currency || 'USD',
}).format(numericAmount);
}
export default function HostedReviewPage() {
const router = useRouter();
const trackingToken = Array.isArray(router.query.trackingToken)
? router.query.trackingToken[0]
: router.query.trackingToken;
const [review, setReview] = useState<HostedReviewRequest | null>(null);
const [rating, setRating] = useState(5);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [reviewerName, setReviewerName] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (!trackingToken) return;
const loadReview = async () => {
setIsLoading(true);
setError('');
try {
const response = await axios.get(
`/reviewflow-public/reviews/${trackingToken}`,
);
const loadedReview = response.data.review as HostedReviewRequest;
setReview(loadedReview);
setRating(loadedReview.review_rating || 5);
setTitle(loadedReview.review_title || '');
setContent(loadedReview.review_content || '');
setReviewerName(
loadedReview.reviewer_display_name || loadedReview.customer?.name || '',
);
setIsSubmitted(loadedReview.status === 'reviewed');
} catch (requestError) {
console.error('Failed to load hosted review request:', requestError);
setError(getErrorMessage(requestError));
} finally {
setIsLoading(false);
}
};
loadReview();
}, [trackingToken]);
const submitReview = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!trackingToken) return;
setIsSubmitting(true);
setError('');
try {
const response = await axios.post(
`/reviewflow-public/reviews/${trackingToken}`,
{
rating,
title,
content,
reviewerName,
},
);
setReview(response.data.review);
setIsSubmitted(true);
} catch (requestError) {
console.error('Failed to submit hosted review:', requestError);
setError(getErrorMessage(requestError));
} finally {
setIsSubmitting(false);
}
};
const businessName = review?.business?.name || 'this business';
const products = review?.review_payload?.products || [];
const orderName =
review?.review_payload?.order?.name || review?.transaction?.description || '';
const amount = formatAmount(
review?.transaction?.amount,
review?.transaction?.currency,
);
return (
<>
<Head>
<title>{getPageTitle(`Review ${businessName}`)}</title>
</Head>
<main className='min-h-screen bg-gradient-to-br from-slate-950 via-indigo-950 to-emerald-950 px-4 py-10 text-slate-100'>
<div className='mx-auto max-w-3xl'>
<div className='mb-6 text-center'>
<p className='text-sm font-bold uppercase tracking-[0.3em] text-emerald-300'>
Review Flow
</p>
<h1 className='mt-3 text-4xl font-black tracking-tight md:text-5xl'>
Share your experience with {businessName}
</h1>
<p className='mt-3 text-base text-slate-300'>
Your feedback helps the team improve and helps future customers know what to expect.
</p>
</div>
<CardBox className='border-0 bg-white text-slate-800 shadow-2xl dark:bg-slate-900 dark:text-slate-100'>
{isLoading ? (
<div className='rounded-2xl border border-dashed border-slate-200 p-8 text-center text-slate-500 dark:border-slate-700'>
Loading your review form...
</div>
) : error ? (
<div className='space-y-4 rounded-2xl border border-rose-200 bg-rose-50 p-6 text-rose-900'>
<p className='font-black'>We could not load this review request.</p>
<p>{error}</p>
<BaseButton
href='/'
icon={mdiArrowLeft}
label='Back to website'
color='whiteDark'
/>
</div>
) : isSubmitted ? (
<div className='rounded-3xl bg-emerald-50 p-8 text-center text-emerald-950'>
<BaseButton icon={mdiCheckCircleOutline} color='success' roundedFull />
<h2 className='mt-4 text-3xl font-black'>Thank you for your review!</h2>
<p className='mt-3 text-emerald-800'>
Your feedback was submitted successfully.
</p>
{review?.review_rating && (
<div className='mt-5 flex justify-center gap-1 text-amber-500'>
{ratingOptions.map((option) => (
<span key={option}>{option <= Number(review.review_rating || 0) ? '★' : '☆'}</span>
))}
</div>
)}
</div>
) : (
<form onSubmit={submitReview} className='space-y-6'>
<div className='rounded-2xl bg-slate-50 p-4 dark:bg-slate-800'>
<p className='text-xs font-bold uppercase tracking-widest text-slate-400'>
Review context
</p>
<h2 className='mt-1 text-2xl font-black text-slate-900 dark:text-white'>
{businessName}
</h2>
{(orderName || amount || review?.transaction?.payment_provider) && (
<p className='mt-1 text-sm text-slate-500 dark:text-slate-300'>
{review?.transaction?.payment_provider || 'Order'}
{orderName ? ` · ${orderName}` : ''}
{amount ? ` · ${amount}` : ''}
</p>
)}
{products.length > 0 && (
<div className='mt-4 grid gap-2'>
{products.map((product, index) => (
<div
key={`${product.name || 'product'}-${index}`}
className='rounded-xl bg-white p-3 text-sm ring-1 ring-slate-200 dark:bg-slate-900 dark:ring-slate-700'
>
<p className='font-bold text-slate-900 dark:text-white'>
{product.name || 'Purchased item'}
</p>
<p className='text-slate-500'>
{product.sku ? `SKU ${product.sku}` : 'Shopify product'}
{product.quantity ? ` · Qty ${product.quantity}` : ''}
</p>
</div>
))}
</div>
)}
</div>
<div>
<label className='mb-2 block text-sm font-black text-slate-900 dark:text-white'>
Your rating
</label>
<div className='flex flex-wrap gap-2'>
{ratingOptions.map((option) => (
<button
key={option}
type='button'
onClick={() => setRating(option)}
className={`inline-flex h-12 w-12 items-center justify-center rounded-2xl border text-lg font-black transition ${
option <= rating
? 'border-amber-300 bg-amber-100 text-amber-600'
: 'border-slate-200 bg-white text-slate-400 dark:border-slate-700 dark:bg-slate-900'
}`}
aria-label={`${option} star rating`}
>
<svg viewBox='0 0 24 24' className='h-5 w-5' aria-hidden='true'>
<path fill='currentColor' d={mdiStar} />
</svg>
</button>
))}
</div>
</div>
<div>
<label className='mb-2 block text-sm font-black text-slate-900 dark:text-white'>
Review title <span className='font-normal text-slate-400'>(optional)</span>
</label>
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
className='h-11 w-full rounded-xl border border-slate-300 px-3 py-2 text-slate-900 outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 dark:border-slate-700 dark:bg-slate-900 dark:text-white'
placeholder='What stood out?'
maxLength={200}
/>
</div>
<div>
<label className='mb-2 block text-sm font-black text-slate-900 dark:text-white'>
Your review
</label>
<textarea
required
value={content}
onChange={(event) => setContent(event.target.value)}
className='min-h-36 w-full rounded-xl border border-slate-300 px-3 py-2 text-slate-900 outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 dark:border-slate-700 dark:bg-slate-900 dark:text-white'
placeholder='Tell us about your experience...'
maxLength={5000}
/>
</div>
<div>
<label className='mb-2 block text-sm font-black text-slate-900 dark:text-white'>
Display name <span className='font-normal text-slate-400'>(optional)</span>
</label>
<input
value={reviewerName}
onChange={(event) => setReviewerName(event.target.value)}
className='h-11 w-full rounded-xl border border-slate-300 px-3 py-2 text-slate-900 outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 dark:border-slate-700 dark:bg-slate-900 dark:text-white'
placeholder='Your name'
maxLength={120}
/>
</div>
{error && (
<div className='rounded-2xl border border-rose-200 bg-rose-50 p-4 text-rose-900'>
{error}
</div>
)}
<BaseButton
type='submit'
icon={mdiCheckCircleOutline}
label={isSubmitting ? 'Submitting...' : 'Submit review'}
color='success'
disabled={isSubmitting}
/>
</form>
)}
</CardBox>
</div>
</main>
</>
);
}
HostedReviewPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,412 @@
import {
mdiArrowUpBoldCircleOutline,
mdiCheckCircleOutline,
mdiCreditCardOutline,
mdiRefresh,
} from '@mdi/js'
import axios from 'axios'
import Head from 'next/head'
import { useRouter } from 'next/router'
import React, { ReactElement, useEffect, useState } from 'react'
import BaseButton from '../components/BaseButton'
import CardBox from '../components/CardBox'
import SectionMain from '../components/SectionMain'
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
import LayoutAuthenticated from '../layouts/Authenticated'
import { getPageTitle } from '../config'
import { SubscriptionPlan } from '../subscriptionPlans'
import { getBusinessProfileNoun, getBusinessProfileUsageLabel } from '../helpers/businessPlanLabels'
type SubscriptionStatusResponse = {
subscription: {
planId: string
planName: string
status: string
effectiveStatus: string
isActive: boolean
trialEndsAt?: string | null
trialDaysLeft?: number | null
priceMonthly: number
currency: string
stripeCustomerLinked?: boolean
stripeSubscriptionLinked?: boolean
currentPeriodEndsAt?: string | null
}
billing?: {
checkoutReady: boolean
portalReady: boolean
webhookReady: boolean
hasStripeCustomer: boolean
hasStripeSubscription: boolean
missingConfiguration: string[]
}
usage: {
monthlyReviewRequests: number
businesses: number
teamMembers: number
paymentConnectors: number
periodStart?: string
periodEnd?: string
}
limits: SubscriptionPlan['limits']
plans: SubscriptionPlan[]
}
const usageLabels: Array<{
key: keyof SubscriptionStatusResponse['usage']
limitKey: keyof SubscriptionPlan['limits']
label: string
}> = [
{ key: 'monthlyReviewRequests', limitKey: 'monthlyReviewRequests', label: 'Review requests this month' },
{ key: 'businesses', limitKey: 'businesses', label: 'Business profiles' },
{ key: 'teamMembers', limitKey: 'teamMembers', label: 'Team members' },
{ key: 'paymentConnectors', limitKey: 'paymentConnectors', label: 'Connected payment providers' },
]
function formatDate(value?: string | null) {
if (!value) return 'Not set'
return new Intl.DateTimeFormat('en', {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(new Date(value))
}
function formatLimit(value: number) {
return value.toLocaleString()
}
function getRequestErrorMessage(requestError: unknown, fallback: string) {
if (axios.isAxiosError(requestError) && requestError.response?.data) {
const data = requestError.response.data
if (typeof data === 'string') {
return data
}
if (typeof data?.message === 'string') {
return data.message
}
}
return fallback
}
export default function SubscriptionPage() {
const router = useRouter()
const [status, setStatus] = useState<SubscriptionStatusResponse | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [billingActionPlanId, setBillingActionPlanId] = useState('')
const [isOpeningPortal, setIsOpeningPortal] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const loadStatus = async () => {
setIsLoading(true)
try {
const response = await axios.get('/subscription/me')
setStatus(response.data)
setError('')
} catch (requestError) {
console.error('Failed to load subscription status:', requestError)
setError('Could not load your subscription status. Please refresh and try again.')
} finally {
setIsLoading(false)
}
}
useEffect(() => {
loadStatus()
}, [])
useEffect(() => {
if (!router.isReady) {
return
}
if (router.query.checkout === 'success') {
setMessage('Thanks — Stripe is confirming your subscription. This page will update after the webhook is received.')
loadStatus()
}
if (router.query.checkout === 'cancelled') {
setMessage('Checkout was cancelled. You can restart checkout whenever you are ready.')
}
}, [router.isReady, router.query.checkout])
const startCheckout = async (planId: string) => {
setBillingActionPlanId(planId)
setError('')
setMessage('')
try {
const response = await axios.post('/subscription/create-checkout-session', { planId })
const url = response.data?.url
if (!url) {
throw new Error('Stripe Checkout did not return a redirect URL.')
}
window.location.href = url
} catch (requestError) {
console.error('Failed to create Stripe Checkout session:', requestError)
setError(getRequestErrorMessage(requestError, 'Could not start Stripe Checkout. Please try again.'))
} finally {
setBillingActionPlanId('')
}
}
const openBillingPortal = async () => {
setIsOpeningPortal(true)
setError('')
setMessage('')
try {
const response = await axios.post('/subscription/create-portal-session')
const url = response.data?.url
if (!url) {
throw new Error('Stripe Customer Portal did not return a redirect URL.')
}
window.location.href = url
} catch (requestError) {
console.error('Failed to create Stripe Customer Portal session:', requestError)
setError(getRequestErrorMessage(requestError, 'Could not open billing management. Please try again.'))
} finally {
setIsOpeningPortal(false)
}
}
const currentPlanId = status?.subscription.planId
const isPaidStripeSubscription = status?.subscription.status === 'active' && Boolean(status.billing?.hasStripeCustomer)
const missingConfiguration = status?.billing?.missingConfiguration || []
const overLimitItems = status
? usageLabels.filter((item) => {
const used = Number(status.usage[item.key]) || 0
const limit = Number(status.limits[item.limitKey]) || 0
return limit > 0 && used > limit
})
: []
return (
<>
<Head>
<title>{getPageTitle('Subscription')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiCreditCardOutline}
title='Subscription and limits'
main
>
<BaseButton
icon={mdiRefresh}
label='Refresh'
color='whiteDark'
onClick={loadStatus}
/>
</SectionTitleLineWithButton>
{message && (
<div className='mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-emerald-900'>
{message}
</div>
)}
{error && (
<div className='mb-6 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-rose-900'>
{error}
</div>
)}
{isLoading && !status ? (
<CardBox>Loading subscription details...</CardBox>
) : status ? (
<>
{missingConfiguration.length > 0 && (
<CardBox className='mb-6 border-0 bg-amber-50 text-amber-950 shadow-xl ring-1 ring-amber-200 dark:bg-amber-950 dark:text-amber-50 dark:ring-amber-800'>
<p className='text-lg font-black'>Stripe setup needed</p>
<p className='mt-2 leading-7'>
Billing UI is wired, but Checkout will not launch until these backend environment variables are set:
{' '}
<strong>{missingConfiguration.join(', ')}</strong>.
</p>
<p className='mt-2 text-sm font-semibold'>
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>
)}
{overLimitItems.length > 0 && (
<CardBox className='mb-6 border-0 bg-rose-50 text-rose-950 shadow-xl ring-1 ring-rose-200 dark:bg-rose-950 dark:text-rose-50 dark:ring-rose-800'>
<p className='text-lg font-black'>Plan limit attention needed</p>
<p className='mt-2 leading-7'>
This account is currently over the {status.subscription.planName} limit for{' '}
<strong>{overLimitItems.map((item) => item.label.toLowerCase()).join(', ')}</strong>.
Existing data stays available, but creating more items in those areas will be blocked until usage is reduced or the account moves to a higher plan.
</p>
</CardBox>
)}
<CardBox className='mb-6 overflow-hidden border-0 bg-gradient-to-br from-slate-950 via-indigo-950 to-emerald-950 text-white shadow-2xl'>
<div className='grid gap-6 lg:grid-cols-[1fr_0.8fr] lg:items-center'>
<div>
<p className='text-sm font-black uppercase tracking-[0.3em] text-emerald-300'>
Current plan
</p>
<h2 className='mt-3 text-4xl font-black tracking-tight md:text-5xl'>
{status.subscription.planName}
</h2>
<p className='mt-3 max-w-2xl text-slate-200'>
Status: <strong>{status.subscription.effectiveStatus}</strong>. Trial ends {formatDate(status.subscription.trialEndsAt)}
{status.subscription.trialDaysLeft !== null && status.subscription.trialDaysLeft !== undefined
? ` (${status.subscription.trialDaysLeft} days left)`
: ''}
.
</p>
{status.subscription.currentPeriodEndsAt && (
<p className='mt-2 text-sm font-semibold text-slate-300'>
Current Stripe billing period ends {formatDate(status.subscription.currentPeriodEndsAt)}.
</p>
)}
{status.billing?.hasStripeCustomer && (
<BaseButton
icon={mdiCreditCardOutline}
label='Manage billing'
color='info'
className='mt-6'
disabled={isOpeningPortal || Boolean(billingActionPlanId)}
onClick={openBillingPortal}
/>
)}
</div>
<div className='rounded-3xl bg-white/10 p-6 ring-1 ring-white/15'>
<p className='text-sm font-bold text-slate-300'>Monthly price</p>
<p className='mt-2 text-5xl font-black'>${status.subscription.priceMonthly}</p>
<p className='mt-1 text-sm text-slate-300'>per month after trial</p>
</div>
</div>
</CardBox>
<div className='mb-6 grid gap-6 lg:grid-cols-2'>
{usageLabels.map((item) => {
const used = Number(status.usage[item.key]) || 0
const limit = Number(status.limits[item.limitKey]) || 1
const percent = Math.min(100, Math.round((used / limit) * 100))
const isOverLimit = used > limit
const isNearLimit = !isOverLimit && percent >= 80
const usageTextClass = isOverLimit
? 'font-black text-rose-600'
: isNearLimit ? 'font-black text-amber-600' : 'font-black text-emerald-600'
const progressClass = isOverLimit
? 'h-full rounded-full bg-rose-500'
: isNearLimit ? 'h-full rounded-full bg-amber-500' : 'h-full rounded-full bg-emerald-500'
return (
<CardBox key={item.key} className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
<div className='mb-3 flex items-center justify-between gap-3'>
<p className='font-black text-slate-900 dark:text-white'>{item.label}</p>
<p className={usageTextClass}>
{item.limitKey === 'businesses'
? getBusinessProfileUsageLabel(used, limit)
: `${formatLimit(used)} / ${formatLimit(limit)}`}
</p>
</div>
<div className='h-3 overflow-hidden rounded-full bg-slate-100 dark:bg-dark-800'>
<div
className={progressClass}
style={{ width: `${percent}%` }}
/>
</div>
{isOverLimit && (
<p className='mt-2 text-sm font-semibold text-rose-600'>
Over this plan limit. Upgrade or reduce usage before adding more.
</p>
)}
</CardBox>
)
})}
</div>
<div className='grid gap-6 lg:grid-cols-2'>
{status.plans.map((plan) => {
const isCurrent = currentPlanId === plan.id
const isPro = plan.id === 'pro'
const isBusy = billingActionPlanId === plan.id || isOpeningPortal
const buttonLabel = isPaidStripeSubscription
? isCurrent ? 'Manage billing' : 'Change in billing portal'
: isCurrent ? `Start paid ${plan.name}` : `Checkout for ${plan.name}`
return (
<CardBox
key={plan.id}
className={`relative overflow-hidden border-0 shadow-2xl ${isPro ? 'ring-2 ring-indigo-600' : 'ring-1 ring-slate-200 dark:ring-dark-700'}`}
cardBoxClassName='p-0'
>
{isPro && (
<div className='absolute right-6 top-6 rounded-full bg-indigo-600 px-4 py-1 text-sm font-black text-white'>
Pro growth tools
</div>
)}
<div className='p-8'>
<p className='text-sm font-black uppercase tracking-[0.3em] text-slate-400'>Review Flow</p>
<h3 className='mt-3 text-3xl font-black text-slate-900 dark:text-white'>{plan.name}</h3>
<p className='mt-3 min-h-[56px] leading-7 text-slate-500 dark:text-slate-400'>{plan.tagline}</p>
<div className='mt-8 flex items-end gap-2'>
<span className='text-5xl font-black tracking-tight text-slate-900 dark:text-white'>${plan.priceMonthly}</span>
<span className='pb-2 font-bold text-slate-500'>/month</span>
</div>
<div className='mt-8 grid gap-3 rounded-3xl bg-slate-50 p-5 dark:bg-dark-800 sm:grid-cols-2'>
<div>
<p className='text-2xl font-black'>{formatLimit(plan.limits.monthlyReviewRequests)}</p>
<p className='text-sm text-slate-500'>requests/month</p>
</div>
<div>
<p className='text-2xl font-black'>{formatLimit(plan.limits.businesses)}</p>
<p className='text-sm text-slate-500'>{getBusinessProfileNoun(plan.limits.businesses)}</p>
</div>
<div>
<p className='text-2xl font-black'>{formatLimit(plan.limits.teamMembers)}</p>
<p className='text-sm text-slate-500'>team members</p>
</div>
<div>
<p className='text-2xl font-black'>{formatLimit(plan.limits.paymentConnectors)}</p>
<p className='text-sm text-slate-500'>connectors</p>
</div>
</div>
<BaseButton
icon={isCurrent ? mdiCheckCircleOutline : mdiArrowUpBoldCircleOutline}
label={buttonLabel}
color={isCurrent ? 'success' : isPro ? 'info' : 'whiteDark'}
className='mt-8 w-full'
disabled={isBusy}
onClick={() => (isPaidStripeSubscription ? openBillingPortal() : startCheckout(plan.id))}
/>
</div>
<div className={isPro ? 'bg-indigo-950 p-8 text-white' : 'bg-slate-950 p-8 text-white'}>
<p className='mb-5 text-sm font-black uppercase tracking-[0.25em] text-emerald-300'>Included</p>
<div className='grid gap-3'>
{plan.features.map((feature) => (
<div key={feature} className='flex items-start gap-3'>
<span className='mt-1 text-emerald-300'></span>
<span className='font-semibold text-slate-100'>{feature}</span>
</div>
))}
</div>
</div>
</CardBox>
)
})}
</div>
</>
) : null}
</SectionMain>
</>
)
}
SubscriptionPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated portal='customer'>{page}</LayoutAuthenticated>
}

View File

@ -5,7 +5,7 @@ import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
export default function PrivacyPolicy() {
const title = 'ReviewFlow';
const title = 'Review Flow';
const [projectUrl, setProjectUrl] = useState('');
useEffect(() => {

View File

@ -2,6 +2,7 @@ import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js
import Head from 'next/head'
import React, { ReactElement } from 'react'
import CardBox from '../../components/CardBox'
import SubscriptionLimitGate from '../../components/SubscriptionLimitGate'
import LayoutAuthenticated from '../../layouts/Authenticated'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
@ -23,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 = {
@ -163,6 +165,8 @@ const initialValues = {
const UsersNew = () => {
const router = useRouter()
const dispatch = useAppDispatch()
const { currentUser } = useAppSelector((state) => state.auth)
const canManageAccessControls = isInternalAdmin(currentUser)
@ -180,6 +184,10 @@ const UsersNew = () => {
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
{''}
</SectionTitleLineWithButton>
<SubscriptionLimitGate
limitKey='teamMembers'
actionLabel='Inviting another team member'
/>
<CardBox>
<Formik
initialValues={
@ -427,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>
)}

View File

@ -407,7 +407,7 @@ const UsersView = () => {
<>
<p className={'block font-bold mb-2'}>Businesses Owner</p>
<p className={'block font-bold mb-2'}>Business profiles owned</p>
<CardBox
className='mb-6 border border-gray-300 rounded overflow-hidden'
hasTable
@ -420,53 +420,53 @@ const UsersView = () => {
<th>BusinessName</th>
<th>Business name</th>
<th>GoogleReviewLink</th>
<th>Google review link</th>
<th>YelpReviewLink</th>
<th>Yelp review link</th>
<th>FacebookReviewLink</th>
<th>Facebook review link</th>
<th>DelayDays</th>
<th>Review delay days</th>
<th>EmailSubjectTemplate</th>
<th>Email subject template</th>
<th>IsActive</th>
<th>Active</th>
<th>StripeAccountReference</th>
<th>Stripe account reference</th>
<th>StripeConnected</th>
<th>Stripe connected</th>
<th>StripeConnectedAt</th>
<th>Stripe connected at</th>
<th>DefaultReviewPlatform</th>
<th>Default review platform</th>
<th>CustomReviewLink</th>
<th>Custom review link</th>
</tr>

View File

@ -158,7 +158,7 @@ export const businessesSlice = createSlice({
builder.addCase(deleteItemsByIds.fulfilled, (state) => {
state.loading = false;
fulfilledNotify(state, 'Businesses has been deleted');
fulfilledNotify(state, 'Businesses have been deleted');
});
builder.addCase(deleteItemsByIds.rejected, (state, action) => {
@ -173,7 +173,7 @@ export const businessesSlice = createSlice({
builder.addCase(deleteItem.fulfilled, (state) => {
state.loading = false
fulfilledNotify(state, `${'Businesses'.slice(0, -1)} has been deleted`);
fulfilledNotify(state, 'Business has been deleted');
})
builder.addCase(deleteItem.rejected, (state, action) => {
@ -192,7 +192,7 @@ export const businessesSlice = createSlice({
builder.addCase(create.fulfilled, (state) => {
state.loading = false
fulfilledNotify(state, `${'Businesses'.slice(0, -1)} has been created`);
fulfilledNotify(state, 'Business has been created');
})
builder.addCase(update.pending, (state) => {
@ -201,7 +201,7 @@ export const businessesSlice = createSlice({
})
builder.addCase(update.fulfilled, (state) => {
state.loading = false
fulfilledNotify(state, `${'Businesses'.slice(0, -1)} has been updated`);
fulfilledNotify(state, 'Business has been updated');
})
builder.addCase(update.rejected, (state, action) => {
state.loading = false
@ -214,7 +214,7 @@ export const businessesSlice = createSlice({
})
builder.addCase(uploadCsv.fulfilled, (state) => {
state.loading = false;
fulfilledNotify(state, 'Businesses has been uploaded');
fulfilledNotify(state, 'Businesses have been uploaded');
})
builder.addCase(uploadCsv.rejected, (state, action) => {
state.loading = false;

View File

@ -0,0 +1,77 @@
export type SubscriptionPlan = {
id: 'starter' | 'pro';
name: string;
priceMonthly: number;
currency: 'USD';
trialDays: number;
tagline: string;
highlight?: string;
ctaLabel: string;
limits: {
monthlyReviewRequests: number;
businesses: number;
teamMembers: number;
paymentConnectors: number;
};
features: string[];
};
export const trialDays = 14;
export const subscriptionPlans: SubscriptionPlan[] = [
{
id: 'starter',
name: 'Grow',
priceMonthly: 49,
currency: 'USD',
trialDays,
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,
teamMembers: 2,
paymentConnectors: 5,
},
features: [
'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',
'Basic usage reporting',
],
},
{
id: 'pro',
name: 'Pro',
priceMonthly: 99,
currency: 'USD',
trialDays,
tagline: 'For teams that want AI replies, referrals, NPS, broadcasts, rebooking campaigns, and competitor insight tools.',
highlight: 'Best value',
ctaLabel: 'Start Pro trial',
limits: {
monthlyReviewRequests: 2500,
businesses: 10,
teamMembers: 10,
paymentConnectors: 5,
},
features: [
'Everything in Grow',
'AI review reply assistant',
'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',
],
},
];