Compare commits
No commits in common. "ai-dev" and "master" have entirely different histories.
@ -1,4 +1 @@
|
|||||||
PORT=8080
|
PORT=8080
|
||||||
TWILIO_ACCOUNT_SID=ACf0b6dd3d34b2aefffd9914c317bf04e0
|
|
||||||
TWILIO_AUTH_TOKEN=5b4dc2c0246b699596997a212a46548a
|
|
||||||
TWILIO_FROM_NUMBER=+17372324091
|
|
||||||
|
|||||||
@ -36,7 +36,6 @@
|
|||||||
"sequelize": "6.35.2",
|
"sequelize": "6.35.2",
|
||||||
"sequelize-json-schema": "^2.1.1",
|
"sequelize-json-schema": "^2.1.1",
|
||||||
"sqlite": "4.0.15",
|
"sqlite": "4.0.15",
|
||||||
"stripe": "^22.3.0",
|
|
||||||
"swagger-jsdoc": "^6.2.8",
|
"swagger-jsdoc": "^6.2.8",
|
||||||
"swagger-ui-express": "^5.0.0",
|
"swagger-ui-express": "^5.0.0",
|
||||||
"tedious": "^18.2.4"
|
"tedious": "^18.2.4"
|
||||||
|
|||||||
@ -65,12 +65,6 @@ const config = {
|
|||||||
|
|
||||||
|
|
||||||
gpt_key: process.env.GPT_KEY || '',
|
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 || '';
|
config.pexelsKey = process.env.PEXELS_KEY || '';
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
|
const FileDBApi = require('./file');
|
||||||
|
const crypto = require('crypto');
|
||||||
const Utils = require('../utils');
|
const Utils = require('../utils');
|
||||||
|
|
||||||
|
|
||||||
@ -24,82 +26,6 @@ module.exports = class BusinessesDBApi {
|
|||||||
null
|
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
|
google_review_link: data.google_review_link
|
||||||
||
|
||
|
||||||
null
|
null
|
||||||
@ -194,82 +120,6 @@ module.exports = class BusinessesDBApi {
|
|||||||
name: item.name
|
name: item.name
|
||||||
||
|
||
|
||||||
null
|
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
|
google_review_link: item.google_review_link
|
||||||
@ -364,39 +214,6 @@ module.exports = class BusinessesDBApi {
|
|||||||
if (data.name !== undefined) updatePayload.name = data.name;
|
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;
|
if (data.google_review_link !== undefined) updatePayload.google_review_link = data.google_review_link;
|
||||||
|
|
||||||
|
|
||||||
@ -567,6 +384,9 @@ module.exports = class BusinessesDBApi {
|
|||||||
|
|
||||||
offset = currentPage * limit;
|
offset = currentPage * limit;
|
||||||
|
|
||||||
|
const orderBy = null;
|
||||||
|
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
let include = [
|
let include = [
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
|
const FileDBApi = require('./file');
|
||||||
|
const crypto = require('crypto');
|
||||||
const Utils = require('../utils');
|
const Utils = require('../utils');
|
||||||
|
|
||||||
|
|
||||||
@ -380,12 +382,15 @@ module.exports = class Review_requestsDBApi {
|
|||||||
|
|
||||||
offset = currentPage * limit;
|
offset = currentPage * limit;
|
||||||
|
|
||||||
|
const orderBy = null;
|
||||||
|
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
let include = [
|
let include = [
|
||||||
|
|
||||||
{
|
{
|
||||||
model: db.businesses,
|
model: db.businesses,
|
||||||
as: 'business',
|
as: 'business',
|
||||||
required: Boolean(filter.business),
|
|
||||||
|
|
||||||
where: filter.business ? {
|
where: filter.business ? {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
@ -403,7 +408,6 @@ module.exports = class Review_requestsDBApi {
|
|||||||
{
|
{
|
||||||
model: db.customers,
|
model: db.customers,
|
||||||
as: 'customer',
|
as: 'customer',
|
||||||
required: Boolean(filter.customer),
|
|
||||||
|
|
||||||
where: filter.customer ? {
|
where: filter.customer ? {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
@ -421,7 +425,6 @@ module.exports = class Review_requestsDBApi {
|
|||||||
{
|
{
|
||||||
model: db.transactions,
|
model: db.transactions,
|
||||||
as: 'transaction',
|
as: 'transaction',
|
||||||
required: Boolean(filter.transaction),
|
|
||||||
|
|
||||||
where: filter.transaction ? {
|
where: filter.transaction ? {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
|
|||||||
@ -12,47 +12,6 @@ const config = require('../../config');
|
|||||||
const Sequelize = db.Sequelize;
|
const Sequelize = db.Sequelize;
|
||||||
const Op = Sequelize.Op;
|
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 {
|
module.exports = class UsersDBApi {
|
||||||
|
|
||||||
static async create(data, options) {
|
static async create(data, options) {
|
||||||
@ -136,16 +95,9 @@ module.exports = class UsersDBApi {
|
|||||||
|
|
||||||
|
|
||||||
if (!data.data.app_role) {
|
if (!data.data.app_role) {
|
||||||
const defaultRoleNames = isInternalAdminUser(currentUser)
|
const role = await db.roles.findOne({
|
||||||
? [config.roles?.user || 'User']
|
where: { name: '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) {
|
if (role) {
|
||||||
await users.setApp_role(role, {
|
await users.setApp_role(role, {
|
||||||
transaction,
|
transaction,
|
||||||
@ -285,10 +237,7 @@ module.exports = class UsersDBApi {
|
|||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
|
|
||||||
const users = await db.users.findOne({
|
const users = await db.users.findByPk(id, {}, {transaction});
|
||||||
where: applyCurrentUserScope({ id }, currentUser),
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -393,11 +342,11 @@ module.exports = class UsersDBApi {
|
|||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
const users = await db.users.findAll({
|
const users = await db.users.findAll({
|
||||||
where: applyCurrentUserScope({
|
where: {
|
||||||
id: {
|
id: {
|
||||||
[Op.in]: ids,
|
[Op.in]: ids,
|
||||||
},
|
},
|
||||||
}, currentUser),
|
},
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -421,10 +370,7 @@ module.exports = class UsersDBApi {
|
|||||||
const currentUser = (options && options.currentUser) || {id: null};
|
const currentUser = (options && options.currentUser) || {id: null};
|
||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
const users = await db.users.findOne({
|
const users = await db.users.findByPk(id, options);
|
||||||
where: applyCurrentUserScope({ id }, currentUser),
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
await users.update({
|
await users.update({
|
||||||
deletedBy: currentUser.id
|
deletedBy: currentUser.id
|
||||||
@ -442,10 +388,10 @@ module.exports = class UsersDBApi {
|
|||||||
static async findBy(where, options) {
|
static async findBy(where, options) {
|
||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
const users = await db.users.findOne({
|
const users = await db.users.findOne(
|
||||||
where: applyCurrentUserScope(where, options?.currentUser),
|
{ where },
|
||||||
transaction,
|
{ transaction },
|
||||||
});
|
);
|
||||||
|
|
||||||
if (!users) {
|
if (!users) {
|
||||||
return users;
|
return users;
|
||||||
@ -509,6 +455,9 @@ module.exports = class UsersDBApi {
|
|||||||
|
|
||||||
offset = currentPage * limit;
|
offset = currentPage * limit;
|
||||||
|
|
||||||
|
const orderBy = null;
|
||||||
|
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
let include = [
|
let include = [
|
||||||
|
|
||||||
@ -774,8 +723,6 @@ module.exports = class UsersDBApi {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
where = applyCurrentUserScope(where, options?.currentUser);
|
|
||||||
|
|
||||||
const queryOptions = {
|
const queryOptions = {
|
||||||
where,
|
where,
|
||||||
include,
|
include,
|
||||||
@ -805,7 +752,7 @@ module.exports = class UsersDBApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAllAutocomplete(query, limit, offset, options = {}) {
|
static async findAllAutocomplete(query, limit, offset, ) {
|
||||||
let where = {};
|
let where = {};
|
||||||
|
|
||||||
|
|
||||||
@ -823,8 +770,6 @@ module.exports = class UsersDBApi {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
where = applyCurrentUserScope(where, options?.currentUser);
|
|
||||||
|
|
||||||
const records = await db.users.findAll({
|
const records = await db.users.findAll({
|
||||||
attributes: [ 'id', 'firstName' ],
|
attributes: [ 'id', 'firstName' ],
|
||||||
where,
|
where,
|
||||||
@ -848,13 +793,6 @@ module.exports = class UsersDBApi {
|
|||||||
firstName: data.firstName,
|
firstName: data.firstName,
|
||||||
authenticationUid: data.authenticationUid,
|
authenticationUid: data.authenticationUid,
|
||||||
password: data.password,
|
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 },
|
{ transaction },
|
||||||
|
|||||||
@ -1,112 +0,0 @@
|
|||||||
'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;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
'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;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
'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;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
'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;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
'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;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,89 +0,0 @@
|
|||||||
'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;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const businessColumns = {
|
|
||||||
brand_logo_url: { type: 'TEXT' },
|
|
||||||
brand_primary_color: { type: 'TEXT', defaultValue: '#4f46e5', allowNull: false },
|
|
||||||
email_sender_name: { type: 'TEXT' },
|
|
||||||
email_reply_to: { type: 'TEXT' },
|
|
||||||
email_footer_text: { type: 'TEXT' },
|
|
||||||
sms_template: { type: 'TEXT' },
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeColumnDefinition(Sequelize, definition) {
|
|
||||||
const normalized = { ...definition };
|
|
||||||
|
|
||||||
if (definition.type === 'TEXT') {
|
|
||||||
normalized.type = Sequelize.DataTypes.TEXT;
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addColumnsIfMissing(queryInterface, Sequelize, transaction, tableName, columns) {
|
|
||||||
const table = await queryInterface.describeTable(tableName);
|
|
||||||
|
|
||||||
for (const [columnName, definition] of Object.entries(columns)) {
|
|
||||||
if (!table[columnName]) {
|
|
||||||
await queryInterface.addColumn(
|
|
||||||
tableName,
|
|
||||||
columnName,
|
|
||||||
normalizeColumnDefinition(Sequelize, definition),
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeColumnsIfPresent(queryInterface, transaction, tableName, columns) {
|
|
||||||
const table = await queryInterface.describeTable(tableName);
|
|
||||||
|
|
||||||
for (const columnName of Object.keys(columns).reverse()) {
|
|
||||||
if (table[columnName]) {
|
|
||||||
await queryInterface.removeColumn(tableName, columnName, { transaction });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
async up(queryInterface, Sequelize) {
|
|
||||||
const transaction = await queryInterface.sequelize.transaction();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'businesses', businessColumns);
|
|
||||||
await transaction.commit();
|
|
||||||
} catch (error) {
|
|
||||||
await transaction.rollback();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async down(queryInterface) {
|
|
||||||
const transaction = await queryInterface.sequelize.transaction();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await removeColumnsIfPresent(queryInterface, transaction, 'businesses', businessColumns);
|
|
||||||
await transaction.commit();
|
|
||||||
} catch (error) {
|
|
||||||
await transaction.rollback();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,3 +1,9 @@
|
|||||||
|
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) {
|
module.exports = function(sequelize, DataTypes) {
|
||||||
const businesses = sequelize.define(
|
const businesses = sequelize.define(
|
||||||
'businesses',
|
'businesses',
|
||||||
@ -57,33 +63,6 @@ email_body_template: {
|
|||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
brand_logo_url: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
},
|
|
||||||
|
|
||||||
brand_primary_color: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: '#4f46e5',
|
|
||||||
},
|
|
||||||
|
|
||||||
email_sender_name: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
},
|
|
||||||
|
|
||||||
email_reply_to: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
},
|
|
||||||
|
|
||||||
email_footer_text: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
},
|
|
||||||
|
|
||||||
sms_template: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
},
|
|
||||||
|
|
||||||
is_active: {
|
is_active: {
|
||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
|
|
||||||
@ -116,138 +95,6 @@ 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: {
|
default_review_platform: {
|
||||||
@ -277,135 +124,6 @@ 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: {
|
importHash: {
|
||||||
@ -458,14 +176,6 @@ custom_review_link: {
|
|||||||
constraints: false,
|
constraints: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
db.businesses.hasMany(db.transactions, {
|
|
||||||
as: 'transactions_business',
|
|
||||||
foreignKey: {
|
|
||||||
name: 'businessId',
|
|
||||||
},
|
|
||||||
constraints: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,9 @@
|
|||||||
|
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) {
|
module.exports = function(sequelize, DataTypes) {
|
||||||
const customers = sequelize.define(
|
const customers = sequelize.define(
|
||||||
'customers',
|
'customers',
|
||||||
@ -34,35 +40,6 @@ 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: {
|
contact_status: {
|
||||||
|
|||||||
@ -1,3 +1,9 @@
|
|||||||
|
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) {
|
module.exports = function(sequelize, DataTypes) {
|
||||||
const review_requests = sequelize.define(
|
const review_requests = sequelize.define(
|
||||||
'review_requests',
|
'review_requests',
|
||||||
@ -107,55 +113,6 @@ 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: {
|
importHash: {
|
||||||
|
|||||||
@ -1,3 +1,9 @@
|
|||||||
|
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) {
|
module.exports = function(sequelize, DataTypes) {
|
||||||
const stripe_events = sequelize.define(
|
const stripe_events = sequelize.define(
|
||||||
'stripe_events',
|
'stripe_events',
|
||||||
@ -13,20 +19,6 @@ stripe_event_reference: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
provider: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
provider_event_type: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
event_type: {
|
event_type: {
|
||||||
|
|||||||
@ -1,3 +1,9 @@
|
|||||||
|
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) {
|
module.exports = function(sequelize, DataTypes) {
|
||||||
const transactions = sequelize.define(
|
const transactions = sequelize.define(
|
||||||
'transactions',
|
'transactions',
|
||||||
@ -13,49 +19,6 @@ 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: {
|
amount: {
|
||||||
@ -168,14 +131,6 @@ receipt_email: {
|
|||||||
constraints: false,
|
constraints: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
db.transactions.belongsTo(db.businesses, {
|
|
||||||
as: 'business',
|
|
||||||
foreignKey: {
|
|
||||||
name: 'businessId',
|
|
||||||
},
|
|
||||||
constraints: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ const config = require('../../config');
|
|||||||
const providers = config.providers;
|
const providers = config.providers;
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
|
const moment = require('moment');
|
||||||
|
|
||||||
module.exports = function(sequelize, DataTypes) {
|
module.exports = function(sequelize, DataTypes) {
|
||||||
const users = sequelize.define(
|
const users = sequelize.define(
|
||||||
@ -103,72 +104,6 @@ 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: {
|
importHash: {
|
||||||
type: DataTypes.STRING(255),
|
type: DataTypes.STRING(255),
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
@ -260,8 +195,8 @@ stripeCurrentPeriodEndAt: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
users.beforeCreate((users) => {
|
users.beforeCreate((users, options) => {
|
||||||
trimStringFields(users);
|
users = trimStringFields(users);
|
||||||
|
|
||||||
if (users.provider !== providers.LOCAL && Object.values(providers).indexOf(users.provider) > -1) {
|
if (users.provider !== providers.LOCAL && Object.values(providers).indexOf(users.provider) > -1) {
|
||||||
users.emailVerified = true;
|
users.emailVerified = true;
|
||||||
@ -281,8 +216,8 @@ stripeCurrentPeriodEndAt: {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
users.beforeUpdate((users) => {
|
users.beforeUpdate((users, options) => {
|
||||||
trimStringFields(users);
|
users = trimStringFields(users);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,387 +0,0 @@
|
|||||||
'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' });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -6,6 +6,7 @@ const passport = require('passport');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const bodyParser = require('body-parser');
|
const bodyParser = require('body-parser');
|
||||||
|
const db = require('./db/models');
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
const swaggerUI = require('swagger-ui-express');
|
const swaggerUI = require('swagger-ui-express');
|
||||||
const swaggerJsDoc = require('swagger-jsdoc');
|
const swaggerJsDoc = require('swagger-jsdoc');
|
||||||
@ -15,9 +16,6 @@ const fileRoutes = require('./routes/file');
|
|||||||
const searchRoutes = require('./routes/search');
|
const searchRoutes = require('./routes/search');
|
||||||
const sqlRoutes = require('./routes/sql');
|
const sqlRoutes = require('./routes/sql');
|
||||||
const pexelsRoutes = require('./routes/pexels');
|
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');
|
const openaiRoutes = require('./routes/openai');
|
||||||
|
|
||||||
@ -37,10 +35,6 @@ const transactionsRoutes = require('./routes/transactions');
|
|||||||
|
|
||||||
const review_requestsRoutes = require('./routes/review_requests');
|
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 stripe_eventsRoutes = require('./routes/stripe_events');
|
||||||
|
|
||||||
const email_delivery_logsRoutes = require('./routes/email_delivery_logs');
|
const email_delivery_logsRoutes = require('./routes/email_delivery_logs');
|
||||||
@ -97,15 +91,11 @@ app.use('/api-docs', function (req, res, next) {
|
|||||||
app.use(cors({origin: true}));
|
app.use(cors({origin: true}));
|
||||||
require('./auth/auth');
|
require('./auth/auth');
|
||||||
|
|
||||||
app.use('/api/subscription/stripe-webhook', bodyParser.raw({type: 'application/json'}), subscriptionWebhookRoutes);
|
|
||||||
|
|
||||||
app.use(bodyParser.json());
|
app.use(bodyParser.json());
|
||||||
|
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/file', fileRoutes);
|
app.use('/api/file', fileRoutes);
|
||||||
app.use('/api/pexels', pexelsRoutes);
|
app.use('/api/pexels', pexelsRoutes);
|
||||||
app.use('/api/plans', plansRoutes);
|
|
||||||
app.use('/api/subscription', passport.authenticate('jwt', {session: false}), subscriptionRoutes);
|
|
||||||
app.enable('trust proxy');
|
app.enable('trust proxy');
|
||||||
|
|
||||||
|
|
||||||
@ -123,12 +113,6 @@ 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/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/stripe_events', passport.authenticate('jwt', {session: false}), stripe_eventsRoutes);
|
||||||
|
|
||||||
app.use('/api/email_delivery_logs', passport.authenticate('jwt', {session: false}), email_delivery_logsRoutes);
|
app.use('/api/email_delivery_logs', passport.authenticate('jwt', {session: false}), email_delivery_logsRoutes);
|
||||||
|
|||||||
@ -1,12 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
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, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get('/widgets/:businessId', wrapAsync(async (req, res) => {
|
|
||||||
const widget = await ReviewFlowService.getSocialWidgetReviews(req.params.businessId, req.query || {});
|
|
||||||
|
|
||||||
if (req.query.format === 'json') {
|
|
||||||
res.status(200).send(widget);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reviewsHtml = widget.reviews.length
|
|
||||||
? widget.reviews.map((review) => `
|
|
||||||
<article class="review-card">
|
|
||||||
<div class="stars" aria-label="${escapeHtml(review.rating)} out of 5 stars">${'★'.repeat(Number(review.rating) || 5)}</div>
|
|
||||||
<h3>${escapeHtml(review.title || 'Great experience')}</h3>
|
|
||||||
<p>${escapeHtml(review.content)}</p>
|
|
||||||
<footer>${escapeHtml(review.reviewer)} · ${escapeHtml(review.source)}</footer>
|
|
||||||
</article>
|
|
||||||
`).join('')
|
|
||||||
: '<div class="empty">Fresh reviews will appear here after customers submit them.</div>';
|
|
||||||
|
|
||||||
res.status(200).type('html').send(`<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<style>
|
|
||||||
:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
||||||
body { margin: 0; background: transparent; color: #0f172a; }
|
|
||||||
.widget { border: 1px solid #e2e8f0; border-radius: 18px; padding: 18px; background: #fff; box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08); }
|
|
||||||
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
|
||||||
.eyebrow { color: #059669; font-size: 11px; font-weight: 900; letter-spacing: .22em; text-transform: uppercase; }
|
|
||||||
h2 { margin: 4px 0 0; font-size: 20px; line-height: 1.2; }
|
|
||||||
.badge { border-radius: 999px; background: #ecfdf5; color: #047857; font-size: 12px; font-weight: 800; padding: 6px 10px; white-space: nowrap; }
|
|
||||||
.reviews { display: grid; gap: 12px; }
|
|
||||||
.review-card { border: 1px solid #e2e8f0; border-radius: 14px; padding: 14px; background: #f8fafc; }
|
|
||||||
.stars { color: #f59e0b; letter-spacing: .08em; font-size: 14px; }
|
|
||||||
h3 { margin: 8px 0 6px; font-size: 16px; }
|
|
||||||
p { margin: 0; color: #475569; line-height: 1.55; }
|
|
||||||
footer { margin-top: 10px; color: #64748b; font-size: 12px; font-weight: 700; }
|
|
||||||
.empty { border: 1px dashed #cbd5e1; border-radius: 14px; padding: 18px; color: #64748b; text-align: center; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<section class="widget" aria-label="Customer reviews for ${escapeHtml(widget.business.name)}">
|
|
||||||
<div class="header">
|
|
||||||
<div>
|
|
||||||
<div class="eyebrow">Verified reviews</div>
|
|
||||||
<h2>${escapeHtml(widget.business.name)}</h2>
|
|
||||||
</div>
|
|
||||||
<div class="badge">Powered by Review Flow</div>
|
|
||||||
</div>
|
|
||||||
<div class="reviews">${reviewsHtml}</div>
|
|
||||||
</section>
|
|
||||||
</body>
|
|
||||||
</html>`);
|
|
||||||
}));
|
|
||||||
|
|
||||||
router.use('/', require('../helpers').commonErrorHandler);
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,597 +0,0 @@
|
|||||||
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 normalizeHexColor(value, fallback = ReviewFlowService.DEFAULT_BRAND_PRIMARY_COLOR) {
|
|
||||||
const color = normalizeString(value) || fallback;
|
|
||||||
|
|
||||||
if (/^#[0-9a-fA-F]{6}$/.test(color) || /^#[0-9a-fA-F]{3}$/.test(color)) {
|
|
||||||
return color;
|
|
||||||
}
|
|
||||||
|
|
||||||
const error = new Error('Brand color must be a valid hex color, such as #4f46e5.');
|
|
||||||
error.code = 400;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeOptionalUrl(value, message) {
|
|
||||||
const normalized = normalizeString(value);
|
|
||||||
|
|
||||||
if (!normalized) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
validateUrl(normalized, message);
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeOptionalEmail(value) {
|
|
||||||
const normalized = normalizeString(value).toLowerCase();
|
|
||||||
|
|
||||||
if (normalized && !EMAIL_PATTERN.test(normalized)) {
|
|
||||||
const error = new Error('Reply-to email must be a valid email address.');
|
|
||||||
error.code = 400;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTemplate(value, fallback) {
|
|
||||||
return normalizeString(value) || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sameTemplateValue(value, candidates) {
|
|
||||||
const normalized = normalizeString(value).toLowerCase();
|
|
||||||
return candidates.some((candidate) => normalizeString(candidate).toLowerCase() === normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDefaultBrandedMessagingValue(key, value, businessName) {
|
|
||||||
const defaultSubject = ReviewFlowService.getDefaultEmailSubjectTemplate();
|
|
||||||
const defaultBody = ReviewFlowService.getDefaultEmailBodyTemplate();
|
|
||||||
const defaultSms = ReviewFlowService.getDefaultSmsTemplate();
|
|
||||||
const defaultFooter = ReviewFlowService.getDefaultEmailFooterTemplate();
|
|
||||||
|
|
||||||
if (key === 'brand_primary_color') {
|
|
||||||
return normalizeString(value).toLowerCase() === ReviewFlowService.DEFAULT_BRAND_PRIMARY_COLOR;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === 'email_subject_template') {
|
|
||||||
return sameTemplateValue(value, [defaultSubject, `How was your experience with ${businessName}?`]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === 'email_body_template') {
|
|
||||||
return sameTemplateValue(value, [
|
|
||||||
defaultBody,
|
|
||||||
buildEmailBody('{customerName}', businessName, '{reviewLink}'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === 'sms_template') {
|
|
||||||
return sameTemplateValue(value, [
|
|
||||||
defaultSms,
|
|
||||||
`Thanks for choosing ${businessName}. Please leave a review: {reviewLink}`,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === 'email_footer_text') {
|
|
||||||
return sameTemplateValue(value, [defaultFooter, `Sent by Review Flow for ${businessName}.`]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return !normalizeString(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasCustomBrandedMessaging(brandedMessagingPayload, businessName) {
|
|
||||||
return Object.entries(brandedMessagingPayload).some(([key, value]) => (
|
|
||||||
!isDefaultBrandedMessagingValue(key, value, businessName)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getReviewLinkField(reviewDestination) {
|
|
||||||
return REVIEW_LINK_FIELDS[reviewDestination] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
brand_primary_color: ReviewFlowService.DEFAULT_BRAND_PRIMARY_COLOR,
|
|
||||||
email_footer_text: ReviewFlowService.getDefaultEmailFooterTemplate(),
|
|
||||||
email_subject_template: ReviewFlowService.getDefaultEmailSubjectTemplate(),
|
|
||||||
email_body_template: ReviewFlowService.getDefaultEmailBodyTemplate(),
|
|
||||||
sms_template: ReviewFlowService.getDefaultSmsTemplate(),
|
|
||||||
is_active: true,
|
|
||||||
createdById: currentUser.id,
|
|
||||||
updatedById: currentUser.id,
|
|
||||||
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, emailBody } = ReviewFlowService.buildReviewRequestMessages(
|
|
||||||
business,
|
|
||||||
customerName,
|
|
||||||
effectiveReviewLink,
|
|
||||||
);
|
|
||||||
const reviewRequest = await db.review_requests.create({
|
|
||||||
status: 'pending',
|
|
||||||
scheduled_for: scheduledFor,
|
|
||||||
email_subject: emailSubject,
|
|
||||||
email_body: emailBody,
|
|
||||||
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 brandedMessagingPayload = {
|
|
||||||
brand_logo_url: normalizeOptionalUrl(body.brandLogoUrl ?? body.brand_logo_url ?? business.brand_logo_url, 'Brand logo URL must be a valid URL.'),
|
|
||||||
brand_primary_color: normalizeHexColor(body.brandPrimaryColor ?? body.brand_primary_color ?? business.brand_primary_color),
|
|
||||||
email_sender_name: normalizeString(body.emailSenderName ?? body.email_sender_name ?? business.email_sender_name),
|
|
||||||
email_reply_to: normalizeOptionalEmail(body.emailReplyTo ?? body.email_reply_to ?? business.email_reply_to),
|
|
||||||
email_footer_text: normalizeTemplate(body.emailFooterText ?? body.email_footer_text ?? business.email_footer_text, ReviewFlowService.getDefaultEmailFooterTemplate()),
|
|
||||||
email_subject_template: normalizeTemplate(body.emailSubjectTemplate ?? body.email_subject_template ?? business.email_subject_template, ReviewFlowService.getDefaultEmailSubjectTemplate()),
|
|
||||||
email_body_template: normalizeTemplate(body.emailBodyTemplate ?? body.email_body_template ?? business.email_body_template, ReviewFlowService.getDefaultEmailBodyTemplate()),
|
|
||||||
sms_template: normalizeTemplate(body.smsTemplate ?? body.sms_template ?? business.sms_template, ReviewFlowService.getDefaultSmsTemplate()),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (hasCustomBrandedMessaging(brandedMessagingPayload, businessName || business.name || 'Review Flow Business')) {
|
|
||||||
await SubscriptionService.assertFeatureAccess(currentUser, 'branded_messaging');
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatePayload = {
|
|
||||||
name: businessName || business.name,
|
|
||||||
business_type: businessType,
|
|
||||||
automation_mode: normalizeString(body.automationMode || body.automation_mode) || 'set_and_forget',
|
|
||||||
review_destination: reviewDestination,
|
|
||||||
google_review_link: normalizeOptionalUrl(body.googleReviewLink ?? body.google_review_link ?? business.google_review_link, 'Google review link must be a valid URL.'),
|
|
||||||
yelp_review_link: normalizeOptionalUrl(body.yelpReviewLink ?? body.yelp_review_link ?? business.yelp_review_link, 'Yelp review link must be a valid URL.'),
|
|
||||||
facebook_review_link: normalizeOptionalUrl(body.facebookReviewLink ?? body.facebook_review_link ?? business.facebook_review_link, 'Facebook review link must be a valid URL.'),
|
|
||||||
trustpilot_review_link: normalizeOptionalUrl(body.trustpilotReviewLink ?? body.trustpilot_review_link ?? business.trustpilot_review_link, 'Trustpilot review link must be a valid URL.'),
|
|
||||||
angi_review_link: normalizeOptionalUrl(body.angiReviewLink ?? body.angi_review_link ?? business.angi_review_link, 'Angi review link must be a valid URL.'),
|
|
||||||
opentable_review_link: normalizeOptionalUrl(body.opentableReviewLink ?? body.opentable_review_link ?? business.opentable_review_link, 'OpenTable review link must be a valid URL.'),
|
|
||||||
custom_review_link: normalizeOptionalUrl(body.customReviewLink ?? body.custom_review_link ?? business.custom_review_link, 'Custom review page link must be a valid URL.'),
|
|
||||||
delay_days: parseInteger(body.delayDays ?? body.delay_days, business.delay_days || 7, 0, 30),
|
|
||||||
followup_enabled: parseBoolean(body.followupEnabled ?? body.followup_enabled, business.followup_enabled !== false),
|
|
||||||
followup_delay_days: parseInteger(body.followupDelayDays ?? body.followup_delay_days, business.followup_delay_days || 3, 1, 30),
|
|
||||||
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',
|
|
||||||
...brandedMessagingPayload,
|
|
||||||
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;
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -347,6 +347,7 @@ router.get('/count', wrapAsync(async (req, res) => {
|
|||||||
const currentUser = req.currentUser;
|
const currentUser = req.currentUser;
|
||||||
const payload = await UsersDBApi.findAll(
|
const payload = await UsersDBApi.findAll(
|
||||||
req.query,
|
req.query,
|
||||||
|
null,
|
||||||
{ countOnly: true, currentUser }
|
{ countOnly: true, currentUser }
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -384,7 +385,7 @@ router.get('/autocomplete', async (req, res) => {
|
|||||||
req.query.query,
|
req.query.query,
|
||||||
req.query.limit,
|
req.query.limit,
|
||||||
req.query.offset,
|
req.query.offset,
|
||||||
{ currentUser: req.currentUser },
|
|
||||||
);
|
);
|
||||||
|
|
||||||
res.status(200).send(payload);
|
res.status(200).send(payload);
|
||||||
@ -425,17 +426,12 @@ router.get('/autocomplete', async (req, res) => {
|
|||||||
router.get('/:id', wrapAsync(async (req, res) => {
|
router.get('/:id', wrapAsync(async (req, res) => {
|
||||||
const payload = await UsersDBApi.findBy(
|
const payload = await UsersDBApi.findBy(
|
||||||
{ id: req.params.id },
|
{ 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);
|
res.status(200).send(payload);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
const UsersDBApi = require('../db/api/users');
|
const UsersDBApi = require('../db/api/users');
|
||||||
const db = require('../db/models');
|
|
||||||
const ValidationError = require('./notifications/errors/validation');
|
const ValidationError = require('./notifications/errors/validation');
|
||||||
const ForbiddenError = require('./notifications/errors/forbidden');
|
const ForbiddenError = require('./notifications/errors/forbidden');
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
@ -9,7 +8,6 @@ const PasswordResetEmail = require('./email/list/passwordReset');
|
|||||||
const EmailSender = require('./email');
|
const EmailSender = require('./email');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const helpers = require('../helpers');
|
const helpers = require('../helpers');
|
||||||
const SubscriptionService = require('./subscription');
|
|
||||||
|
|
||||||
class Auth {
|
class Auth {
|
||||||
static async signup(email, password, options = {}, host) {
|
static async signup(email, password, options = {}, host) {
|
||||||
@ -56,16 +54,11 @@ class Auth {
|
|||||||
return helpers.jwtSign(data);
|
return helpers.jwtSign(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscriptionPayload = SubscriptionService.getSignupSubscriptionPayload(
|
|
||||||
options?.body?.planId || options?.body?.plan,
|
|
||||||
);
|
|
||||||
|
|
||||||
const newUser = await UsersDBApi.createFromAuth(
|
const newUser = await UsersDBApi.createFromAuth(
|
||||||
{
|
{
|
||||||
firstName: email.split('@')[0],
|
firstName: email.split('@')[0],
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
email: email,
|
email: email,
|
||||||
...subscriptionPayload,
|
|
||||||
|
|
||||||
},
|
},
|
||||||
options,
|
options,
|
||||||
|
|||||||
@ -6,7 +6,6 @@ const csv = require('csv-parser');
|
|||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const stream = require('stream');
|
const stream = require('stream');
|
||||||
const SubscriptionService = require('./subscription');
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -16,8 +15,6 @@ module.exports = class BusinessesService {
|
|||||||
static async create(data, currentUser) {
|
static async create(data, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
await SubscriptionService.assertCanCreateBusinesses(currentUser, 1, { transaction });
|
|
||||||
|
|
||||||
await BusinessesDBApi.create(
|
await BusinessesDBApi.create(
|
||||||
data,
|
data,
|
||||||
{
|
{
|
||||||
@ -54,8 +51,6 @@ module.exports = class BusinessesService {
|
|||||||
.on('error', (error) => reject(error));
|
.on('error', (error) => reject(error));
|
||||||
})
|
})
|
||||||
|
|
||||||
await SubscriptionService.assertCanCreateBusinesses(req.currentUser, results.length, { transaction });
|
|
||||||
|
|
||||||
await BusinessesDBApi.bulkImport(results, {
|
await BusinessesDBApi.bulkImport(results, {
|
||||||
transaction,
|
transaction,
|
||||||
ignoreDuplicates: true,
|
ignoreDuplicates: true,
|
||||||
|
|||||||
@ -18,7 +18,7 @@ module.exports = class EmailSender {
|
|||||||
const transporter = nodemailer.createTransport(this.transportConfig);
|
const transporter = nodemailer.createTransport(this.transportConfig);
|
||||||
|
|
||||||
const mailOptions = {
|
const mailOptions = {
|
||||||
from: this.email.from || this.from,
|
from: this.from,
|
||||||
to: this.email.to,
|
to: this.email.to,
|
||||||
subject: this.email.subject,
|
subject: this.email.subject,
|
||||||
html: htmlContent,
|
html: htmlContent,
|
||||||
@ -27,10 +27,6 @@ module.exports = class EmailSender {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (this.email.replyTo) {
|
|
||||||
mailOptions.replyTo = this.email.replyTo;
|
|
||||||
}
|
|
||||||
|
|
||||||
return transporter.sendMail(mailOptions);
|
return transporter.sendMail(mailOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,6 @@ const csv = require('csv-parser');
|
|||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const stream = require('stream');
|
const stream = require('stream');
|
||||||
const SubscriptionService = require('./subscription');
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -16,8 +15,6 @@ module.exports = class Review_requestsService {
|
|||||||
static async create(data, currentUser) {
|
static async create(data, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
await SubscriptionService.assertCanCreateReviewRequests(currentUser, 1, { transaction });
|
|
||||||
|
|
||||||
await Review_requestsDBApi.create(
|
await Review_requestsDBApi.create(
|
||||||
data,
|
data,
|
||||||
{
|
{
|
||||||
@ -54,8 +51,6 @@ module.exports = class Review_requestsService {
|
|||||||
.on('error', (error) => reject(error));
|
.on('error', (error) => reject(error));
|
||||||
})
|
})
|
||||||
|
|
||||||
await SubscriptionService.assertCanCreateReviewRequests(req.currentUser, results.length, { transaction });
|
|
||||||
|
|
||||||
await Review_requestsDBApi.bulkImport(results, {
|
await Review_requestsDBApi.bulkImport(results, {
|
||||||
transaction,
|
transaction,
|
||||||
ignoreDuplicates: true,
|
ignoreDuplicates: true,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,222 +0,0 @@
|
|||||||
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'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,830 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,118 +0,0 @@
|
|||||||
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',
|
|
||||||
'Branded email and SMS templates',
|
|
||||||
'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',
|
|
||||||
'branded_messaging',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
@ -3,12 +3,14 @@ const UsersDBApi = require('../db/api/users');
|
|||||||
const processFile = require("../middlewares/upload");
|
const processFile = require("../middlewares/upload");
|
||||||
const ValidationError = require('./notifications/errors/validation');
|
const ValidationError = require('./notifications/errors/validation');
|
||||||
const csv = require('csv-parser');
|
const csv = require('csv-parser');
|
||||||
|
const axios = require('axios');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const stream = require('stream');
|
const stream = require('stream');
|
||||||
|
|
||||||
|
|
||||||
|
const InvitationEmail = require('./email/list/invitation');
|
||||||
|
const EmailSender = require('./email');
|
||||||
const AuthService = require('./auth');
|
const AuthService = require('./auth');
|
||||||
const SubscriptionService = require('./subscription');
|
|
||||||
|
|
||||||
module.exports = class UsersService {
|
module.exports = class UsersService {
|
||||||
static async create(data, currentUser, sendInvitationEmails = true, host) {
|
static async create(data, currentUser, sendInvitationEmails = true, host) {
|
||||||
@ -24,7 +26,6 @@ module.exports = class UsersService {
|
|||||||
'iam.errors.userAlreadyExists',
|
'iam.errors.userAlreadyExists',
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await SubscriptionService.assertCanCreateTeamMembers(currentUser, 1, { transaction });
|
|
||||||
await UsersDBApi.create(
|
await UsersDBApi.create(
|
||||||
{data},
|
{data},
|
||||||
|
|
||||||
@ -78,8 +79,6 @@ module.exports = class UsersService {
|
|||||||
throw new ValidationError('importer.errors.userEmailMissing');
|
throw new ValidationError('importer.errors.userEmailMissing');
|
||||||
}
|
}
|
||||||
|
|
||||||
await SubscriptionService.assertCanCreateTeamMembers(req.currentUser, results.length, { transaction });
|
|
||||||
|
|
||||||
await UsersDBApi.bulkImport(results, {
|
await UsersDBApi.bulkImport(results, {
|
||||||
transaction,
|
transaction,
|
||||||
ignoreDuplicates: true,
|
ignoreDuplicates: true,
|
||||||
@ -109,7 +108,7 @@ module.exports = class UsersService {
|
|||||||
try {
|
try {
|
||||||
let users = await UsersDBApi.findBy(
|
let users = await UsersDBApi.findBy(
|
||||||
{id},
|
{id},
|
||||||
{transaction, currentUser},
|
{transaction},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!users) {
|
if (!users) {
|
||||||
@ -135,7 +134,7 @@ module.exports = class UsersService {
|
|||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
static async remove(id, currentUser) {
|
static async remove(id, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|||||||
1370
backend/yarn.lock
1370
backend/yarn.lock
File diff suppressed because it is too large
Load Diff
@ -90,7 +90,7 @@ const CardBusinesses = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Business name</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>BusinessName</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.name }
|
{ item.name }
|
||||||
@ -102,7 +102,7 @@ const CardBusinesses = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Google review link</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>GoogleReviewLink</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.google_review_link }
|
{ item.google_review_link }
|
||||||
@ -114,7 +114,7 @@ const CardBusinesses = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Yelp review link</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>YelpReviewLink</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.yelp_review_link }
|
{ item.yelp_review_link }
|
||||||
@ -126,7 +126,7 @@ const CardBusinesses = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Facebook review link</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>FacebookReviewLink</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.facebook_review_link }
|
{ item.facebook_review_link }
|
||||||
@ -138,7 +138,7 @@ const CardBusinesses = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Review delay days</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>DelayDays</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.delay_days }
|
{ item.delay_days }
|
||||||
@ -150,7 +150,7 @@ const CardBusinesses = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Email subject template</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>EmailSubjectTemplate</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.email_subject_template }
|
{ item.email_subject_template }
|
||||||
@ -162,7 +162,7 @@ const CardBusinesses = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Email body template</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>EmailBodyTemplate</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.email_body_template }
|
{ item.email_body_template }
|
||||||
@ -174,7 +174,7 @@ const CardBusinesses = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Active</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>IsActive</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ dataFormatter.booleanFormatter(item.is_active) }
|
{ dataFormatter.booleanFormatter(item.is_active) }
|
||||||
@ -186,7 +186,7 @@ const CardBusinesses = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Stripe account reference</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>StripeAccountReference</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.stripe_account_reference }
|
{ item.stripe_account_reference }
|
||||||
@ -198,7 +198,7 @@ const CardBusinesses = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Stripe connected</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>StripeConnected</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ dataFormatter.booleanFormatter(item.stripe_connected) }
|
{ dataFormatter.booleanFormatter(item.stripe_connected) }
|
||||||
@ -210,7 +210,7 @@ const CardBusinesses = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Stripe connected at</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>StripeConnectedAt</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ dataFormatter.dateTimeFormatter(item.stripe_connected_at) }
|
{ dataFormatter.dateTimeFormatter(item.stripe_connected_at) }
|
||||||
@ -222,7 +222,7 @@ const CardBusinesses = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Default review platform</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>DefaultReviewPlatform</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.default_review_platform }
|
{ item.default_review_platform }
|
||||||
@ -234,7 +234,7 @@ const CardBusinesses = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Custom review link</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>CustomReviewLink</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.custom_review_link }
|
{ item.custom_review_link }
|
||||||
|
|||||||
@ -56,7 +56,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
|||||||
|
|
||||||
|
|
||||||
<div className={'flex-1 px-3'}>
|
<div className={'flex-1 px-3'}>
|
||||||
<p className={'text-xs text-gray-500 '}>Business name</p>
|
<p className={'text-xs text-gray-500 '}>BusinessName</p>
|
||||||
<p className={'line-clamp-2'}>{ item.name }</p>
|
<p className={'line-clamp-2'}>{ item.name }</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -64,7 +64,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
|||||||
|
|
||||||
|
|
||||||
<div className={'flex-1 px-3'}>
|
<div className={'flex-1 px-3'}>
|
||||||
<p className={'text-xs text-gray-500 '}>Google review link</p>
|
<p className={'text-xs text-gray-500 '}>GoogleReviewLink</p>
|
||||||
<p className={'line-clamp-2'}>{ item.google_review_link }</p>
|
<p className={'line-clamp-2'}>{ item.google_review_link }</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -72,7 +72,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
|||||||
|
|
||||||
|
|
||||||
<div className={'flex-1 px-3'}>
|
<div className={'flex-1 px-3'}>
|
||||||
<p className={'text-xs text-gray-500 '}>Yelp review link</p>
|
<p className={'text-xs text-gray-500 '}>YelpReviewLink</p>
|
||||||
<p className={'line-clamp-2'}>{ item.yelp_review_link }</p>
|
<p className={'line-clamp-2'}>{ item.yelp_review_link }</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -80,7 +80,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
|||||||
|
|
||||||
|
|
||||||
<div className={'flex-1 px-3'}>
|
<div className={'flex-1 px-3'}>
|
||||||
<p className={'text-xs text-gray-500 '}>Facebook review link</p>
|
<p className={'text-xs text-gray-500 '}>FacebookReviewLink</p>
|
||||||
<p className={'line-clamp-2'}>{ item.facebook_review_link }</p>
|
<p className={'line-clamp-2'}>{ item.facebook_review_link }</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -88,7 +88,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
|||||||
|
|
||||||
|
|
||||||
<div className={'flex-1 px-3'}>
|
<div className={'flex-1 px-3'}>
|
||||||
<p className={'text-xs text-gray-500 '}>Review delay days</p>
|
<p className={'text-xs text-gray-500 '}>DelayDays</p>
|
||||||
<p className={'line-clamp-2'}>{ item.delay_days }</p>
|
<p className={'line-clamp-2'}>{ item.delay_days }</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -96,7 +96,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
|||||||
|
|
||||||
|
|
||||||
<div className={'flex-1 px-3'}>
|
<div className={'flex-1 px-3'}>
|
||||||
<p className={'text-xs text-gray-500 '}>Email subject template</p>
|
<p className={'text-xs text-gray-500 '}>EmailSubjectTemplate</p>
|
||||||
<p className={'line-clamp-2'}>{ item.email_subject_template }</p>
|
<p className={'line-clamp-2'}>{ item.email_subject_template }</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -104,7 +104,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
|||||||
|
|
||||||
|
|
||||||
<div className={'flex-1 px-3'}>
|
<div className={'flex-1 px-3'}>
|
||||||
<p className={'text-xs text-gray-500 '}>Email body template</p>
|
<p className={'text-xs text-gray-500 '}>EmailBodyTemplate</p>
|
||||||
<p className={'line-clamp-2'}>{ item.email_body_template }</p>
|
<p className={'line-clamp-2'}>{ item.email_body_template }</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -112,7 +112,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
|||||||
|
|
||||||
|
|
||||||
<div className={'flex-1 px-3'}>
|
<div className={'flex-1 px-3'}>
|
||||||
<p className={'text-xs text-gray-500 '}>Active</p>
|
<p className={'text-xs text-gray-500 '}>IsActive</p>
|
||||||
<p className={'line-clamp-2'}>{ dataFormatter.booleanFormatter(item.is_active) }</p>
|
<p className={'line-clamp-2'}>{ dataFormatter.booleanFormatter(item.is_active) }</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -120,7 +120,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
|||||||
|
|
||||||
|
|
||||||
<div className={'flex-1 px-3'}>
|
<div className={'flex-1 px-3'}>
|
||||||
<p className={'text-xs text-gray-500 '}>Stripe account reference</p>
|
<p className={'text-xs text-gray-500 '}>StripeAccountReference</p>
|
||||||
<p className={'line-clamp-2'}>{ item.stripe_account_reference }</p>
|
<p className={'line-clamp-2'}>{ item.stripe_account_reference }</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -128,7 +128,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
|||||||
|
|
||||||
|
|
||||||
<div className={'flex-1 px-3'}>
|
<div className={'flex-1 px-3'}>
|
||||||
<p className={'text-xs text-gray-500 '}>Stripe connected</p>
|
<p className={'text-xs text-gray-500 '}>StripeConnected</p>
|
||||||
<p className={'line-clamp-2'}>{ dataFormatter.booleanFormatter(item.stripe_connected) }</p>
|
<p className={'line-clamp-2'}>{ dataFormatter.booleanFormatter(item.stripe_connected) }</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -136,7 +136,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
|||||||
|
|
||||||
|
|
||||||
<div className={'flex-1 px-3'}>
|
<div className={'flex-1 px-3'}>
|
||||||
<p className={'text-xs text-gray-500 '}>Stripe connected at</p>
|
<p className={'text-xs text-gray-500 '}>StripeConnectedAt</p>
|
||||||
<p className={'line-clamp-2'}>{ dataFormatter.dateTimeFormatter(item.stripe_connected_at) }</p>
|
<p className={'line-clamp-2'}>{ dataFormatter.dateTimeFormatter(item.stripe_connected_at) }</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -144,7 +144,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
|||||||
|
|
||||||
|
|
||||||
<div className={'flex-1 px-3'}>
|
<div className={'flex-1 px-3'}>
|
||||||
<p className={'text-xs text-gray-500 '}>Default review platform</p>
|
<p className={'text-xs text-gray-500 '}>DefaultReviewPlatform</p>
|
||||||
<p className={'line-clamp-2'}>{ item.default_review_platform }</p>
|
<p className={'line-clamp-2'}>{ item.default_review_platform }</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -152,7 +152,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
|||||||
|
|
||||||
|
|
||||||
<div className={'flex-1 px-3'}>
|
<div className={'flex-1 px-3'}>
|
||||||
<p className={'text-xs text-gray-500 '}>Custom review link</p>
|
<p className={'text-xs text-gray-500 '}>CustomReviewLink</p>
|
||||||
<p className={'line-clamp-2'}>{ item.custom_review_link }</p>
|
<p className={'line-clamp-2'}>{ item.custom_review_link }</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -65,7 +65,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'name',
|
field: 'name',
|
||||||
headerName: 'Business name',
|
headerName: 'BusinessName',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -80,7 +80,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'google_review_link',
|
field: 'google_review_link',
|
||||||
headerName: 'Google review link',
|
headerName: 'GoogleReviewLink',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -95,7 +95,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'yelp_review_link',
|
field: 'yelp_review_link',
|
||||||
headerName: 'Yelp review link',
|
headerName: 'YelpReviewLink',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -110,7 +110,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'facebook_review_link',
|
field: 'facebook_review_link',
|
||||||
headerName: 'Facebook review link',
|
headerName: 'FacebookReviewLink',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -125,7 +125,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'delay_days',
|
field: 'delay_days',
|
||||||
headerName: 'Review delay days',
|
headerName: 'DelayDays',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -141,7 +141,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'email_subject_template',
|
field: 'email_subject_template',
|
||||||
headerName: 'Email subject template',
|
headerName: 'EmailSubjectTemplate',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -156,7 +156,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'email_body_template',
|
field: 'email_body_template',
|
||||||
headerName: 'Email body template',
|
headerName: 'EmailBodyTemplate',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -171,7 +171,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'is_active',
|
field: 'is_active',
|
||||||
headerName: 'Active',
|
headerName: 'IsActive',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -187,7 +187,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'stripe_account_reference',
|
field: 'stripe_account_reference',
|
||||||
headerName: 'Stripe account reference',
|
headerName: 'StripeAccountReference',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -202,7 +202,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'stripe_connected',
|
field: 'stripe_connected',
|
||||||
headerName: 'Stripe connected',
|
headerName: 'StripeConnected',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -218,7 +218,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'stripe_connected_at',
|
field: 'stripe_connected_at',
|
||||||
headerName: 'Stripe connected at',
|
headerName: 'StripeConnectedAt',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -236,7 +236,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'default_review_platform',
|
field: 'default_review_platform',
|
||||||
headerName: 'Default review platform',
|
headerName: 'DefaultReviewPlatform',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -251,7 +251,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'custom_review_link',
|
field: 'custom_review_link',
|
||||||
headerName: 'Custom review link',
|
headerName: 'CustomReviewLink',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
|
|||||||
@ -152,7 +152,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
type: 'dateTime',
|
type: 'dateTime',
|
||||||
valueGetter: (params: GridValueGetterParams) =>
|
valueGetter: (params: GridValueGetterParams) =>
|
||||||
params.row.last_transaction_at ? new Date(params.row.last_transaction_at) : null,
|
new Date(params.row.last_transaction_at),
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, {useEffect, useRef} from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { useState } from 'react'
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
@ -11,7 +12,6 @@ import { setDarkMode } from '../stores/styleSlice'
|
|||||||
import { logoutUser } from '../stores/authSlice'
|
import { logoutUser } from '../stores/authSlice'
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import ClickOutside from "./ClickOutside";
|
import ClickOutside from "./ClickOutside";
|
||||||
import { getPortalLabel } from '../helpers/portalRoles';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item: MenuNavBarItem
|
item: MenuNavBarItem
|
||||||
@ -30,9 +30,7 @@ export default function NavBarItem({ item }: Props) {
|
|||||||
|
|
||||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
||||||
|
|
||||||
const userName = `${currentUser?.firstName ? currentUser?.firstName : ""} ${currentUser?.lastName ? currentUser?.lastName : ""}`.trim();
|
const userName = `${currentUser?.firstName ? currentUser?.firstName : ""} ${currentUser?.lastName ? currentUser?.lastName : ""}`;
|
||||||
const userDisplayName = userName || currentUser?.email || '';
|
|
||||||
const portalLabel = currentUser ? getPortalLabel(currentUser) : '';
|
|
||||||
|
|
||||||
const [isDropdownActive, setIsDropdownActive] = useState(false)
|
const [isDropdownActive, setIsDropdownActive] = useState(false)
|
||||||
|
|
||||||
@ -49,7 +47,7 @@ export default function NavBarItem({ item }: Props) {
|
|||||||
item.isDesktopNoLabel ? 'lg:w-16 lg:justify-center' : '',
|
item.isDesktopNoLabel ? 'lg:w-16 lg:justify-center' : '',
|
||||||
].join(' ')
|
].join(' ')
|
||||||
|
|
||||||
const itemLabel = item.isCurrentUser ? userDisplayName : item.label
|
const itemLabel = item.isCurrentUser ? userName : item.label
|
||||||
|
|
||||||
const handleMenuClick = () => {
|
const handleMenuClick = () => {
|
||||||
if (item.menu) {
|
if (item.menu) {
|
||||||
@ -94,12 +92,7 @@ export default function NavBarItem({ item }: Props) {
|
|||||||
item.isDesktopNoLabel && item.icon ? 'lg:hidden' : ''
|
item.isDesktopNoLabel && item.icon ? 'lg:hidden' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{item.isCurrentUser ? (
|
{itemLabel}
|
||||||
<span className='flex flex-col leading-tight'>
|
|
||||||
<span>{itemLabel}</span>
|
|
||||||
<span className='text-[10px] uppercase tracking-wider opacity-70'>{portalLabel}</span>
|
|
||||||
</span>
|
|
||||||
) : itemLabel}
|
|
||||||
</span>
|
</span>
|
||||||
{item.isCurrentUser && <UserAvatarCurrentUser className="w-6 h-6 mr-3 inline-flex" />}
|
{item.isCurrentUser && <UserAvatarCurrentUser className="w-6 h-6 mr-3 inline-flex" />}
|
||||||
{item.menu && (
|
{item.menu && (
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -27,19 +27,11 @@ export const SelectField = ({ options, field, form, itemRef, showField, disabled
|
|||||||
|
|
||||||
async function callApi(inputValue: string, loadedOptions: any[]) {
|
async function callApi(inputValue: string, loadedOptions: any[]) {
|
||||||
const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}`;
|
const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}`;
|
||||||
try {
|
|
||||||
const { data } = await axios(path);
|
const { data } = await axios(path);
|
||||||
return {
|
return {
|
||||||
options: data.map(mapResponseToValuesAndLabels),
|
options: data.map(mapResponseToValuesAndLabels),
|
||||||
hasMore: data.length === PAGE_SIZE,
|
hasMore: data.length === PAGE_SIZE,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to load options for ${itemRef}:`, error);
|
|
||||||
return {
|
|
||||||
options: [],
|
|
||||||
hasMore: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<AsyncPaginate
|
<AsyncPaginate
|
||||||
|
|||||||
@ -42,19 +42,11 @@ export const SelectFieldMany = ({ options, field, form, itemRef, showField }) =>
|
|||||||
|
|
||||||
async function callApi(inputValue: string, loadedOptions: any[]) {
|
async function callApi(inputValue: string, loadedOptions: any[]) {
|
||||||
const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}`;
|
const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}`;
|
||||||
try {
|
|
||||||
const { data } = await axios(path);
|
const { data } = await axios(path);
|
||||||
return {
|
return {
|
||||||
options: data.map(mapResponseToValuesAndLabels),
|
options: data.map(mapResponseToValuesAndLabels),
|
||||||
hasMore: data.length === PAGE_SIZE,
|
hasMore: data.length === PAGE_SIZE,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to load options for ${itemRef}:`, error);
|
|
||||||
return {
|
|
||||||
options: [],
|
|
||||||
hasMore: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<AsyncPaginate
|
<AsyncPaginate
|
||||||
|
|||||||
@ -63,39 +63,9 @@ 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',
|
field: 'stripe_event_reference',
|
||||||
headerName: 'EventReference',
|
headerName: 'StripeEventReference',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
|
|||||||
@ -1,146 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -63,24 +63,9 @@ 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',
|
field: 'stripe_payment_reference',
|
||||||
headerName: 'PaymentReference',
|
headerName: 'StripePaymentReference',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
|
|||||||
@ -1,26 +0,0 @@
|
|||||||
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)}`
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
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'
|
|
||||||
}
|
|
||||||
@ -1,21 +1,18 @@
|
|||||||
|
|
||||||
export function hasPermission(user, permission_name?: string | string[]) {
|
export function hasPermission(user, permission_name: string | string[]) {
|
||||||
if (!user?.app_role?.name) return false
|
if (!user?.app_role?.name) return false;
|
||||||
if (!permission_name) {
|
if (!permission_name) {
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.app_role.name === 'Administrator') return true
|
|
||||||
|
|
||||||
const permissions = new Set<string>([
|
const permissions = new Set<string>([
|
||||||
...(user?.custom_permissions ?? []).map((p) => p.name),
|
...(user?.custom_permissions ?? []).map((p) => p.name),
|
||||||
...(user?.app_role_permissions ?? []).map((p) => p.name),
|
...(user?.app_role_permissions ?? []).map((p) => p.name),
|
||||||
])
|
]);
|
||||||
|
|
||||||
if (typeof permission_name === 'string') {
|
if (typeof permission_name === 'string') {
|
||||||
return permissions.has(permission_name)
|
return permissions.has(permission_name) || user.app_role.name === 'Administrator'
|
||||||
}
|
} else {
|
||||||
|
return permission_name.some((permission) => permissions.has(permission));
|
||||||
return permission_name.some((permission) => permissions.has(permission))
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import React, { ReactNode, useEffect, useMemo, useState } from 'react'
|
import React, { ReactNode, useEffect } from 'react'
|
||||||
|
import { useState } from 'react'
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import { getMenuAsideForUser } from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
import menuNavBar from '../menuNavBar'
|
import menuNavBar from '../menuNavBar'
|
||||||
import BaseIcon from '../components/BaseIcon'
|
import BaseIcon from '../components/BaseIcon'
|
||||||
import NavBar from '../components/NavBar'
|
import NavBar from '../components/NavBar'
|
||||||
@ -14,23 +15,19 @@ import { useRouter } from 'next/router'
|
|||||||
import {findMe, logoutUser} from "../stores/authSlice";
|
import {findMe, logoutUser} from "../stores/authSlice";
|
||||||
|
|
||||||
import {hasPermission} from "../helpers/userPermissions";
|
import {hasPermission} from "../helpers/userPermissions";
|
||||||
import { getBusinessMenuLabel } from '../helpers/businessPlanLabels';
|
|
||||||
import { getPortalLabel, isInternalAdmin } from '../helpers/portalRoles';
|
|
||||||
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
|
||||||
permission?: string
|
permission?: string
|
||||||
portal?: 'admin' | 'customer'
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LayoutAuthenticated({
|
export default function LayoutAuthenticated({
|
||||||
children,
|
children,
|
||||||
|
|
||||||
permission,
|
permission
|
||||||
portal
|
|
||||||
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
@ -67,38 +64,11 @@ export default function LayoutAuthenticated({
|
|||||||
if (!hasPermission(currentUser, permission)) router.push('/error');
|
if (!hasPermission(currentUser, permission)) router.push('/error');
|
||||||
}, [currentUser, permission]);
|
}, [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 darkMode = useAppSelector((state) => state.style.darkMode)
|
||||||
|
|
||||||
const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false)
|
const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false)
|
||||||
const [isAsideLgActive, setIsAsideLgActive] = 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(() => {
|
useEffect(() => {
|
||||||
const handleRouteChangeStart = () => {
|
const handleRouteChangeStart = () => {
|
||||||
@ -148,11 +118,11 @@ export default function LayoutAuthenticated({
|
|||||||
<AsideMenu
|
<AsideMenu
|
||||||
isAsideMobileExpanded={isAsideMobileExpanded}
|
isAsideMobileExpanded={isAsideMobileExpanded}
|
||||||
isAsideLgActive={isAsideLgActive}
|
isAsideLgActive={isAsideLgActive}
|
||||||
menu={planAwareMenuAside}
|
menu={menuAside}
|
||||||
onAsideLgClose={() => setIsAsideLgActive(false)}
|
onAsideLgClose={() => setIsAsideLgActive(false)}
|
||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
<FooterBar>{portalLabel} · ReviewFlow</FooterBar>
|
<FooterBar>Hand-crafted & Made with ❤️</FooterBar>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,204 +1,107 @@
|
|||||||
import * as icon from '@mdi/js'
|
import * as icon from '@mdi/js';
|
||||||
import { MenuAsideItem } from './interfaces'
|
import { MenuAsideItem } from './interfaces'
|
||||||
import { isInternalAdmin } from './helpers/portalRoles'
|
|
||||||
|
|
||||||
const storeIcon =
|
const menuAside: MenuAsideItem[] = [
|
||||||
'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',
|
href: '/dashboard',
|
||||||
icon: icon.mdiViewDashboardOutline,
|
icon: icon.mdiViewDashboardOutline,
|
||||||
label: 'Workspace dashboard',
|
label: 'Dashboard',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
href: '/growth-tools',
|
|
||||||
icon: icon.mdiStarCircleOutline,
|
|
||||||
label: 'Growth Tools',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/setup',
|
|
||||||
icon: icon.mdiStarOutline,
|
|
||||||
label: 'Setup',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/businesses/businesses-list',
|
|
||||||
label: 'Businesses',
|
|
||||||
icon: storeIcon,
|
|
||||||
permissions: 'READ_BUSINESSES',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/customers/customers-list',
|
|
||||||
label: 'Customers',
|
|
||||||
icon: accountMultipleIcon,
|
|
||||||
permissions: 'READ_CUSTOMERS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/transactions/transactions-list',
|
|
||||||
label: 'Transactions',
|
|
||||||
icon: icon.mdiCreditCardOutline,
|
|
||||||
permissions: 'READ_TRANSACTIONS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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',
|
|
||||||
icon: emailCheckIcon,
|
|
||||||
permissions: 'READ_EMAIL_DELIVERY_LOGS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/users/users-list',
|
|
||||||
label: 'Team members',
|
|
||||||
icon: icon.mdiAccountGroup,
|
|
||||||
permissions: 'READ_USERS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/subscription',
|
|
||||||
icon: icon.mdiCreditCardOutline,
|
|
||||||
label: 'Subscription',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/profile',
|
|
||||||
label: 'Profile',
|
|
||||||
icon: icon.mdiAccountCircle,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const internalAdminMenuAside: MenuAsideItem[] = [
|
|
||||||
{
|
|
||||||
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',
|
href: '/users/users-list',
|
||||||
label: 'Customer accounts',
|
label: 'Users',
|
||||||
icon: icon.mdiAccountGroup,
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
permissions: 'READ_USERS',
|
// @ts-ignore
|
||||||
|
icon: icon.mdiAccountGroup ?? icon.mdiTable,
|
||||||
|
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',
|
href: '/roles/roles-list',
|
||||||
label: 'Roles',
|
label: 'Roles',
|
||||||
icon: icon.mdiShieldAccountVariantOutline,
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
permissions: 'READ_ROLES',
|
// @ts-ignore
|
||||||
|
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_ROLES'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/permissions/permissions-list',
|
href: '/permissions/permissions-list',
|
||||||
label: 'Permissions',
|
label: 'Permissions',
|
||||||
icon: icon.mdiKeyVariant,
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
permissions: 'READ_PERMISSIONS',
|
// @ts-ignore
|
||||||
|
icon: icon.mdiShieldAccountOutline ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_PERMISSIONS'
|
||||||
},
|
},
|
||||||
],
|
{
|
||||||
|
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'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: '/profile',
|
href: '/profile',
|
||||||
label: 'Profile',
|
label: 'Profile',
|
||||||
icon: icon.mdiAccountCircle,
|
icon: icon.mdiAccountCircle,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
{
|
||||||
|
href: '/api-docs',
|
||||||
|
target: '_blank',
|
||||||
|
label: 'Swagger API',
|
||||||
|
icon: icon.mdiFileCode,
|
||||||
|
permissions: 'READ_API_DOCS'
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function getMenuAsideForUser(user?: any) {
|
export default menuAside
|
||||||
return isInternalAdmin(user) ? internalAdminMenuAside : customerMenuAside
|
|
||||||
}
|
|
||||||
|
|
||||||
export default customerMenuAside
|
|
||||||
|
|||||||
@ -70,8 +70,6 @@ const EditBusinesses = () => {
|
|||||||
|
|
||||||
'name': '',
|
'name': '',
|
||||||
|
|
||||||
'business_type': 'hybrid',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -471,10 +469,10 @@ const EditBusinesses = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Edit Business')}</title>
|
<title>{getPageTitle('Edit businesses')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='Edit Business' main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit businesses'} main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
<CardBox>
|
<CardBox>
|
||||||
@ -552,11 +550,11 @@ const EditBusinesses = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Business name"
|
label="BusinessName"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="name"
|
name="name"
|
||||||
placeholder="Business name"
|
placeholder="BusinessName"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -585,28 +583,15 @@ const EditBusinesses = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Business type"
|
label="GoogleReviewLink"
|
||||||
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
|
<Field
|
||||||
name="google_review_link"
|
name="google_review_link"
|
||||||
placeholder="Google review link"
|
placeholder="GoogleReviewLink"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -639,11 +624,11 @@ const EditBusinesses = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Yelp review link"
|
label="YelpReviewLink"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="yelp_review_link"
|
name="yelp_review_link"
|
||||||
placeholder="Yelp review link"
|
placeholder="YelpReviewLink"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -676,11 +661,11 @@ const EditBusinesses = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Facebook review link"
|
label="FacebookReviewLink"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="facebook_review_link"
|
name="facebook_review_link"
|
||||||
placeholder="Facebook review link"
|
placeholder="FacebookReviewLink"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -719,12 +704,12 @@ const EditBusinesses = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Review delay days"
|
label="DelayDays"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
type="number"
|
type="number"
|
||||||
name="delay_days"
|
name="delay_days"
|
||||||
placeholder="Review delay days"
|
placeholder="DelayDays"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -751,11 +736,11 @@ const EditBusinesses = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Email subject template"
|
label="EmailSubjectTemplate"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="email_subject_template"
|
name="email_subject_template"
|
||||||
placeholder="Email subject template"
|
placeholder="EmailSubjectTemplate"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -791,7 +776,7 @@ const EditBusinesses = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Email body template' hasTextareaHeight>
|
<FormField label='EmailBodyTemplate' hasTextareaHeight>
|
||||||
<Field
|
<Field
|
||||||
name='email_body_template'
|
name='email_body_template'
|
||||||
id='email_body_template'
|
id='email_body_template'
|
||||||
@ -839,7 +824,7 @@ const EditBusinesses = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Active' labelFor='is_active'>
|
<FormField label='IsActive' labelFor='is_active'>
|
||||||
<Field
|
<Field
|
||||||
name='is_active'
|
name='is_active'
|
||||||
id='is_active'
|
id='is_active'
|
||||||
@ -860,11 +845,11 @@ const EditBusinesses = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Stripe account reference"
|
label="StripeAccountReference"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="stripe_account_reference"
|
name="stripe_account_reference"
|
||||||
placeholder="Stripe account reference"
|
placeholder="StripeAccountReference"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -912,7 +897,7 @@ const EditBusinesses = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Stripe connected' labelFor='stripe_connected'>
|
<FormField label='StripeConnected' labelFor='stripe_connected'>
|
||||||
<Field
|
<Field
|
||||||
name='stripe_connected'
|
name='stripe_connected'
|
||||||
id='stripe_connected'
|
id='stripe_connected'
|
||||||
@ -943,7 +928,7 @@ const EditBusinesses = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Stripe connected at"
|
label="StripeConnectedAt"
|
||||||
>
|
>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
dateFormat="yyyy-MM-dd hh:mm"
|
dateFormat="yyyy-MM-dd hh:mm"
|
||||||
@ -989,7 +974,7 @@ const EditBusinesses = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label="Default review platform" labelFor="default_review_platform">
|
<FormField label="DefaultReviewPlatform" labelFor="default_review_platform">
|
||||||
<Field name="default_review_platform" id="default_review_platform" component="select">
|
<Field name="default_review_platform" id="default_review_platform" component="select">
|
||||||
|
|
||||||
<option value="google">google</option>
|
<option value="google">google</option>
|
||||||
@ -1018,11 +1003,11 @@ const EditBusinesses = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Custom review link"
|
label="CustomReviewLink"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="custom_review_link"
|
name="custom_review_link"
|
||||||
placeholder="Custom review link"
|
placeholder="CustomReviewLink"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
|||||||
@ -70,8 +70,6 @@ const EditBusinessesPage = () => {
|
|||||||
|
|
||||||
'name': '',
|
'name': '',
|
||||||
|
|
||||||
'business_type': 'hybrid',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -468,10 +466,10 @@ const EditBusinessesPage = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Edit Business')}</title>
|
<title>{getPageTitle('Edit businesses')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='Edit Business' main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit businesses'} main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
<CardBox>
|
<CardBox>
|
||||||
@ -549,11 +547,11 @@ const EditBusinessesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Business name"
|
label="BusinessName"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="name"
|
name="name"
|
||||||
placeholder="Business name"
|
placeholder="BusinessName"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -582,28 +580,15 @@ const EditBusinessesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Business type"
|
label="GoogleReviewLink"
|
||||||
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
|
<Field
|
||||||
name="google_review_link"
|
name="google_review_link"
|
||||||
placeholder="Google review link"
|
placeholder="GoogleReviewLink"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -636,11 +621,11 @@ const EditBusinessesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Yelp review link"
|
label="YelpReviewLink"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="yelp_review_link"
|
name="yelp_review_link"
|
||||||
placeholder="Yelp review link"
|
placeholder="YelpReviewLink"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -673,11 +658,11 @@ const EditBusinessesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Facebook review link"
|
label="FacebookReviewLink"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="facebook_review_link"
|
name="facebook_review_link"
|
||||||
placeholder="Facebook review link"
|
placeholder="FacebookReviewLink"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -716,12 +701,12 @@ const EditBusinessesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Review delay days"
|
label="DelayDays"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
type="number"
|
type="number"
|
||||||
name="delay_days"
|
name="delay_days"
|
||||||
placeholder="Review delay days"
|
placeholder="DelayDays"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -748,11 +733,11 @@ const EditBusinessesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Email subject template"
|
label="EmailSubjectTemplate"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="email_subject_template"
|
name="email_subject_template"
|
||||||
placeholder="Email subject template"
|
placeholder="EmailSubjectTemplate"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -788,7 +773,7 @@ const EditBusinessesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Email body template' hasTextareaHeight>
|
<FormField label='EmailBodyTemplate' hasTextareaHeight>
|
||||||
<Field
|
<Field
|
||||||
name='email_body_template'
|
name='email_body_template'
|
||||||
id='email_body_template'
|
id='email_body_template'
|
||||||
@ -836,7 +821,7 @@ const EditBusinessesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Active' labelFor='is_active'>
|
<FormField label='IsActive' labelFor='is_active'>
|
||||||
<Field
|
<Field
|
||||||
name='is_active'
|
name='is_active'
|
||||||
id='is_active'
|
id='is_active'
|
||||||
@ -857,11 +842,11 @@ const EditBusinessesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Stripe account reference"
|
label="StripeAccountReference"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="stripe_account_reference"
|
name="stripe_account_reference"
|
||||||
placeholder="Stripe account reference"
|
placeholder="StripeAccountReference"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -909,7 +894,7 @@ const EditBusinessesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Stripe connected' labelFor='stripe_connected'>
|
<FormField label='StripeConnected' labelFor='stripe_connected'>
|
||||||
<Field
|
<Field
|
||||||
name='stripe_connected'
|
name='stripe_connected'
|
||||||
id='stripe_connected'
|
id='stripe_connected'
|
||||||
@ -940,7 +925,7 @@ const EditBusinessesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Stripe connected at"
|
label="StripeConnectedAt"
|
||||||
>
|
>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
dateFormat="yyyy-MM-dd hh:mm"
|
dateFormat="yyyy-MM-dd hh:mm"
|
||||||
@ -986,7 +971,7 @@ const EditBusinessesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label="Default review platform" labelFor="default_review_platform">
|
<FormField label="DefaultReviewPlatform" labelFor="default_review_platform">
|
||||||
<Field name="default_review_platform" id="default_review_platform" component="select">
|
<Field name="default_review_platform" id="default_review_platform" component="select">
|
||||||
|
|
||||||
<option value="google">google</option>
|
<option value="google">google</option>
|
||||||
@ -1015,11 +1000,11 @@ const EditBusinessesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Custom review link"
|
label="CustomReviewLink"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="custom_review_link"
|
name="custom_review_link"
|
||||||
placeholder="Custom review link"
|
placeholder="CustomReviewLink"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
|||||||
@ -14,13 +14,10 @@ import Link from "next/link";
|
|||||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||||
import CardBoxModal from "../../components/CardBoxModal";
|
import CardBoxModal from "../../components/CardBoxModal";
|
||||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||||
import SubscriptionLimitGate from '../../components/SubscriptionLimitGate';
|
|
||||||
import {setRefetch, uploadCsv} from '../../stores/businesses/businessesSlice';
|
import {setRefetch, uploadCsv} from '../../stores/businesses/businessesSlice';
|
||||||
|
|
||||||
|
|
||||||
import {hasPermission} from "../../helpers/userPermissions";
|
import {hasPermission} from "../../helpers/userPermissions";
|
||||||
import { getBusinessMenuLabel } from '../../helpers/businessPlanLabels';
|
|
||||||
import { isInternalAdmin } from '../../helpers/portalRoles';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -37,22 +34,20 @@ const BusinessesTablesPage = () => {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
|
||||||
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'},
|
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: 'Review delay days', title: 'delay_days', number: 'true'},
|
{label: 'DelayDays', title: 'delay_days', number: 'true'},
|
||||||
|
|
||||||
{label: 'Stripe connected at', title: 'stripe_connected_at', date: 'true'},
|
{label: 'StripeConnectedAt', title: 'stripe_connected_at', date: 'true'},
|
||||||
|
|
||||||
|
|
||||||
{label: 'Owner', title: 'owner'},
|
{label: 'Owner', title: 'owner'},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{label: 'Default review platform', title: 'default_review_platform', type: 'enum', options: ['google','yelp','facebook','custom']},
|
{label: 'DefaultReviewPlatform', title: 'default_review_platform', type: 'enum', options: ['google','yelp','facebook','custom']},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_BUSINESSES');
|
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_BUSINESSES');
|
||||||
const isAdminPortal = isInternalAdmin(currentUser);
|
|
||||||
const businessPageTitle = isAdminPortal ? 'Business profiles' : getBusinessMenuLabel(currentUser?.subscriptionPlanId);
|
|
||||||
|
|
||||||
|
|
||||||
const addFilter = () => {
|
const addFilter = () => {
|
||||||
@ -95,27 +90,15 @@ const BusinessesTablesPage = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle(businessPageTitle)}</title>
|
<title>{getPageTitle('Businesses')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={businessPageTitle} main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Businesses" main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</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'>
|
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||||
|
|
||||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/businesses/businesses-new'} color='info' label='Add Business'/>}
|
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/businesses/businesses-new'} color='info' label='New Item'/>}
|
||||||
|
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className={'mr-3'}
|
className={'mr-3'}
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js
|
|||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import React, { ReactElement } from 'react'
|
import React, { ReactElement } from 'react'
|
||||||
import CardBox from '../../components/CardBox'
|
import CardBox from '../../components/CardBox'
|
||||||
import SubscriptionLimitGate from '../../components/SubscriptionLimitGate'
|
|
||||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||||
import SectionMain from '../../components/SectionMain'
|
import SectionMain from '../../components/SectionMain'
|
||||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||||
@ -49,8 +48,6 @@ const initialValues = {
|
|||||||
|
|
||||||
name: '',
|
name: '',
|
||||||
|
|
||||||
business_type: 'hybrid',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -274,16 +271,12 @@ const BusinessesNew = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Add Business')}</title>
|
<title>{getPageTitle('New Item')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='Add Business' main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
<SubscriptionLimitGate
|
|
||||||
limitKey='businesses'
|
|
||||||
actionLabel='Adding another business profile'
|
|
||||||
/>
|
|
||||||
<CardBox>
|
<CardBox>
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={
|
initialValues={
|
||||||
@ -328,11 +321,11 @@ const BusinessesNew = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Business name"
|
label="BusinessName"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="name"
|
name="name"
|
||||||
placeholder="Business name"
|
placeholder="BusinessName"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -359,28 +352,15 @@ const BusinessesNew = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Business type"
|
label="GoogleReviewLink"
|
||||||
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
|
<Field
|
||||||
name="google_review_link"
|
name="google_review_link"
|
||||||
placeholder="Google review link"
|
placeholder="GoogleReviewLink"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -411,11 +391,11 @@ const BusinessesNew = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Yelp review link"
|
label="YelpReviewLink"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="yelp_review_link"
|
name="yelp_review_link"
|
||||||
placeholder="Yelp review link"
|
placeholder="YelpReviewLink"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -446,11 +426,11 @@ const BusinessesNew = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Facebook review link"
|
label="FacebookReviewLink"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="facebook_review_link"
|
name="facebook_review_link"
|
||||||
placeholder="Facebook review link"
|
placeholder="FacebookReviewLink"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -487,12 +467,12 @@ const BusinessesNew = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Review delay days"
|
label="DelayDays"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
type="number"
|
type="number"
|
||||||
name="delay_days"
|
name="delay_days"
|
||||||
placeholder="Review delay days"
|
placeholder="DelayDays"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -517,11 +497,11 @@ const BusinessesNew = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Email subject template"
|
label="EmailSubjectTemplate"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="email_subject_template"
|
name="email_subject_template"
|
||||||
placeholder="Email subject template"
|
placeholder="EmailSubjectTemplate"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -555,7 +535,7 @@ const BusinessesNew = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Email body template' hasTextareaHeight>
|
<FormField label='EmailBodyTemplate' hasTextareaHeight>
|
||||||
<Field
|
<Field
|
||||||
name='email_body_template'
|
name='email_body_template'
|
||||||
id='email_body_template'
|
id='email_body_template'
|
||||||
@ -601,7 +581,7 @@ const BusinessesNew = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Active' labelFor='is_active'>
|
<FormField label='IsActive' labelFor='is_active'>
|
||||||
<Field
|
<Field
|
||||||
name='is_active'
|
name='is_active'
|
||||||
id='is_active'
|
id='is_active'
|
||||||
@ -620,11 +600,11 @@ const BusinessesNew = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Stripe account reference"
|
label="StripeAccountReference"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="stripe_account_reference"
|
name="stripe_account_reference"
|
||||||
placeholder="Stripe account reference"
|
placeholder="StripeAccountReference"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -670,7 +650,7 @@ const BusinessesNew = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Stripe connected' labelFor='stripe_connected'>
|
<FormField label='StripeConnected' labelFor='stripe_connected'>
|
||||||
<Field
|
<Field
|
||||||
name='stripe_connected'
|
name='stripe_connected'
|
||||||
id='stripe_connected'
|
id='stripe_connected'
|
||||||
@ -699,12 +679,12 @@ const BusinessesNew = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Stripe connected at"
|
label="StripeConnectedAt"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
name="stripe_connected_at"
|
name="stripe_connected_at"
|
||||||
placeholder="Stripe connected at"
|
placeholder="StripeConnectedAt"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -738,7 +718,7 @@ const BusinessesNew = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label="Default review platform" labelFor="default_review_platform">
|
<FormField label="DefaultReviewPlatform" labelFor="default_review_platform">
|
||||||
<Field name="default_review_platform" id="default_review_platform" component="select">
|
<Field name="default_review_platform" id="default_review_platform" component="select">
|
||||||
|
|
||||||
<option value="google">google</option>
|
<option value="google">google</option>
|
||||||
@ -765,11 +745,11 @@ const BusinessesNew = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Custom review link"
|
label="CustomReviewLink"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="custom_review_link"
|
name="custom_review_link"
|
||||||
placeholder="Custom review link"
|
placeholder="CustomReviewLink"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
|||||||
@ -14,13 +14,10 @@ import Link from "next/link";
|
|||||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||||
import CardBoxModal from "../../components/CardBoxModal";
|
import CardBoxModal from "../../components/CardBoxModal";
|
||||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||||
import SubscriptionLimitGate from '../../components/SubscriptionLimitGate';
|
|
||||||
import {setRefetch, uploadCsv} from '../../stores/businesses/businessesSlice';
|
import {setRefetch, uploadCsv} from '../../stores/businesses/businessesSlice';
|
||||||
|
|
||||||
|
|
||||||
import {hasPermission} from "../../helpers/userPermissions";
|
import {hasPermission} from "../../helpers/userPermissions";
|
||||||
import { getBusinessMenuLabel } from '../../helpers/businessPlanLabels';
|
|
||||||
import { isInternalAdmin } from '../../helpers/portalRoles';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -37,22 +34,20 @@ const BusinessesTablesPage = () => {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
|
||||||
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'},
|
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: 'Review delay days', title: 'delay_days', number: 'true'},
|
{label: 'DelayDays', title: 'delay_days', number: 'true'},
|
||||||
|
|
||||||
{label: 'Stripe connected at', title: 'stripe_connected_at', date: 'true'},
|
{label: 'StripeConnectedAt', title: 'stripe_connected_at', date: 'true'},
|
||||||
|
|
||||||
|
|
||||||
{label: 'Owner', title: 'owner'},
|
{label: 'Owner', title: 'owner'},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{label: 'Default review platform', title: 'default_review_platform', type: 'enum', options: ['google','yelp','facebook','custom']},
|
{label: 'DefaultReviewPlatform', title: 'default_review_platform', type: 'enum', options: ['google','yelp','facebook','custom']},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_BUSINESSES');
|
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_BUSINESSES');
|
||||||
const isAdminPortal = isInternalAdmin(currentUser);
|
|
||||||
const businessPageTitle = isAdminPortal ? 'Business profiles' : getBusinessMenuLabel(currentUser?.subscriptionPlanId);
|
|
||||||
|
|
||||||
|
|
||||||
const addFilter = () => {
|
const addFilter = () => {
|
||||||
@ -95,27 +90,15 @@ const BusinessesTablesPage = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle(businessPageTitle)}</title>
|
<title>{getPageTitle('Businesses')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={businessPageTitle} main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Businesses" main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</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'>
|
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||||
|
|
||||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/businesses/businesses-new'} color='info' label='Add Business'/>}
|
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/businesses/businesses-new'} color='info' label='New Item'/>}
|
||||||
|
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className={'mr-3'}
|
className={'mr-3'}
|
||||||
|
|||||||
@ -29,6 +29,11 @@ const BusinessesView = () => {
|
|||||||
|
|
||||||
const { id } = router.query;
|
const { id } = router.query;
|
||||||
|
|
||||||
|
function removeLastCharacter(str) {
|
||||||
|
console.log(str,`str`)
|
||||||
|
return str.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetch({ id }));
|
dispatch(fetch({ id }));
|
||||||
}, [dispatch, id]);
|
}, [dispatch, id]);
|
||||||
@ -37,10 +42,10 @@ const BusinessesView = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('View Business')}</title>
|
<title>{getPageTitle('View businesses')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='View Business' main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={removeLastCharacter('View businesses')} main>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
color='info'
|
color='info'
|
||||||
label='Edit'
|
label='Edit'
|
||||||
@ -108,7 +113,7 @@ const BusinessesView = () => {
|
|||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
<div className={'mb-4'}>
|
||||||
<p className={'block font-bold mb-2'}>Business name</p>
|
<p className={'block font-bold mb-2'}>BusinessName</p>
|
||||||
<p>{businesses?.name}</p>
|
<p>{businesses?.name}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -140,7 +145,7 @@ const BusinessesView = () => {
|
|||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
<div className={'mb-4'}>
|
||||||
<p className={'block font-bold mb-2'}>Google review link</p>
|
<p className={'block font-bold mb-2'}>GoogleReviewLink</p>
|
||||||
<p>{businesses?.google_review_link}</p>
|
<p>{businesses?.google_review_link}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -172,7 +177,7 @@ const BusinessesView = () => {
|
|||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
<div className={'mb-4'}>
|
||||||
<p className={'block font-bold mb-2'}>Yelp review link</p>
|
<p className={'block font-bold mb-2'}>YelpReviewLink</p>
|
||||||
<p>{businesses?.yelp_review_link}</p>
|
<p>{businesses?.yelp_review_link}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -204,7 +209,7 @@ const BusinessesView = () => {
|
|||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
<div className={'mb-4'}>
|
||||||
<p className={'block font-bold mb-2'}>Facebook review link</p>
|
<p className={'block font-bold mb-2'}>FacebookReviewLink</p>
|
||||||
<p>{businesses?.facebook_review_link}</p>
|
<p>{businesses?.facebook_review_link}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -242,7 +247,7 @@ const BusinessesView = () => {
|
|||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
<div className={'mb-4'}>
|
||||||
<p className={'block font-bold mb-2'}>Review delay days</p>
|
<p className={'block font-bold mb-2'}>DelayDays</p>
|
||||||
<p>{businesses?.delay_days || 'No data'}</p>
|
<p>{businesses?.delay_days || 'No data'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -268,7 +273,7 @@ const BusinessesView = () => {
|
|||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
<div className={'mb-4'}>
|
||||||
<p className={'block font-bold mb-2'}>Email subject template</p>
|
<p className={'block font-bold mb-2'}>EmailSubjectTemplate</p>
|
||||||
<p>{businesses?.email_subject_template}</p>
|
<p>{businesses?.email_subject_template}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -304,7 +309,7 @@ const BusinessesView = () => {
|
|||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
<div className={'mb-4'}>
|
||||||
<p className={'block font-bold mb-2'}>Email body template</p>
|
<p className={'block font-bold mb-2'}>EmailBodyTemplate</p>
|
||||||
{businesses.email_body_template
|
{businesses.email_body_template
|
||||||
? <p dangerouslySetInnerHTML={{__html: businesses.email_body_template}}/>
|
? <p dangerouslySetInnerHTML={{__html: businesses.email_body_template}}/>
|
||||||
: <p>No data</p>
|
: <p>No data</p>
|
||||||
@ -350,7 +355,7 @@ const BusinessesView = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Active'>
|
<FormField label='IsActive'>
|
||||||
<SwitchField
|
<SwitchField
|
||||||
field={{name: 'is_active', value: businesses?.is_active}}
|
field={{name: 'is_active', value: businesses?.is_active}}
|
||||||
form={{setFieldValue: () => null}}
|
form={{setFieldValue: () => null}}
|
||||||
@ -370,7 +375,7 @@ const BusinessesView = () => {
|
|||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
<div className={'mb-4'}>
|
||||||
<p className={'block font-bold mb-2'}>Stripe account reference</p>
|
<p className={'block font-bold mb-2'}>StripeAccountReference</p>
|
||||||
<p>{businesses?.stripe_account_reference}</p>
|
<p>{businesses?.stripe_account_reference}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -417,7 +422,7 @@ const BusinessesView = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Stripe connected'>
|
<FormField label='StripeConnected'>
|
||||||
<SwitchField
|
<SwitchField
|
||||||
field={{name: 'stripe_connected', value: businesses?.stripe_connected}}
|
field={{name: 'stripe_connected', value: businesses?.stripe_connected}}
|
||||||
form={{setFieldValue: () => null}}
|
form={{setFieldValue: () => null}}
|
||||||
@ -446,7 +451,7 @@ const BusinessesView = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Stripe connected at'>
|
<FormField label='StripeConnectedAt'>
|
||||||
{businesses.stripe_connected_at ? <DatePicker
|
{businesses.stripe_connected_at ? <DatePicker
|
||||||
dateFormat="yyyy-MM-dd hh:mm"
|
dateFormat="yyyy-MM-dd hh:mm"
|
||||||
showTimeSelect
|
showTimeSelect
|
||||||
@ -456,7 +461,7 @@ const BusinessesView = () => {
|
|||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
disabled
|
disabled
|
||||||
/> : <p>No Stripe connection date</p>}
|
/> : <p>No StripeConnectedAt</p>}
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
|
||||||
@ -491,7 +496,7 @@ const BusinessesView = () => {
|
|||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
<div className={'mb-4'}>
|
||||||
<p className={'block font-bold mb-2'}>Default review platform</p>
|
<p className={'block font-bold mb-2'}>DefaultReviewPlatform</p>
|
||||||
<p>{businesses?.default_review_platform ?? 'No data'}</p>
|
<p>{businesses?.default_review_platform ?? 'No data'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -509,7 +514,7 @@ const BusinessesView = () => {
|
|||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
<div className={'mb-4'}>
|
||||||
<p className={'block font-bold mb-2'}>Custom review link</p>
|
<p className={'block font-bold mb-2'}>CustomReviewLink</p>
|
||||||
<p>{businesses?.custom_review_link}</p>
|
<p>{businesses?.custom_review_link}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -545,7 +550,7 @@ const BusinessesView = () => {
|
|||||||
|
|
||||||
|
|
||||||
<>
|
<>
|
||||||
<p className={'block font-bold mb-2'}>Customers for this business</p>
|
<p className={'block font-bold mb-2'}>Customers Business</p>
|
||||||
<CardBox
|
<CardBox
|
||||||
className='mb-6 border border-gray-300 rounded overflow-hidden'
|
className='mb-6 border border-gray-300 rounded overflow-hidden'
|
||||||
hasTable
|
hasTable
|
||||||
|
|||||||
@ -1,64 +0,0 @@
|
|||||||
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>;
|
|
||||||
};
|
|
||||||
@ -645,7 +645,6 @@ const EditCron_runs = () => {
|
|||||||
EditCron_runs.getLayout = function getLayout(page: ReactElement) {
|
EditCron_runs.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'UPDATE_CRON_RUNS'}
|
permission={'UPDATE_CRON_RUNS'}
|
||||||
|
|
||||||
|
|||||||
@ -642,7 +642,6 @@ const EditCron_runsPage = () => {
|
|||||||
EditCron_runsPage.getLayout = function getLayout(page: ReactElement) {
|
EditCron_runsPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'UPDATE_CRON_RUNS'}
|
permission={'UPDATE_CRON_RUNS'}
|
||||||
|
|
||||||
|
|||||||
@ -154,7 +154,6 @@ const Cron_runsTablesPage = () => {
|
|||||||
Cron_runsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
Cron_runsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'READ_CRON_RUNS'}
|
permission={'READ_CRON_RUNS'}
|
||||||
|
|
||||||
|
|||||||
@ -504,7 +504,6 @@ const Cron_runsNew = () => {
|
|||||||
Cron_runsNew.getLayout = function getLayout(page: ReactElement) {
|
Cron_runsNew.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'CREATE_CRON_RUNS'}
|
permission={'CREATE_CRON_RUNS'}
|
||||||
|
|
||||||
|
|||||||
@ -152,7 +152,6 @@ const Cron_runsTablesPage = () => {
|
|||||||
Cron_runsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
Cron_runsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'READ_CRON_RUNS'}
|
permission={'READ_CRON_RUNS'}
|
||||||
|
|
||||||
|
|||||||
@ -354,7 +354,6 @@ const Cron_runsView = () => {
|
|||||||
Cron_runsView.getLayout = function getLayout(page: ReactElement) {
|
Cron_runsView.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'READ_CRON_RUNS'}
|
permission={'READ_CRON_RUNS'}
|
||||||
|
|
||||||
|
|||||||
@ -1,501 +1,119 @@
|
|||||||
import * as icon from '@mdi/js'
|
import * as icon from '@mdi/js';
|
||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import axios from 'axios'
|
import axios from 'axios';
|
||||||
import type { ReactElement } from 'react'
|
import type { ReactElement } from 'react'
|
||||||
import Link from 'next/link'
|
|
||||||
import LayoutAuthenticated from '../layouts/Authenticated'
|
import LayoutAuthenticated from '../layouts/Authenticated'
|
||||||
import SectionMain from '../components/SectionMain'
|
import SectionMain from '../components/SectionMain'
|
||||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
|
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 { getPageTitle } from '../config'
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
import { hasPermission } from '../helpers/userPermissions'
|
import { hasPermission } from "../helpers/userPermissions";
|
||||||
import { getBusinessMenuLabel } from '../helpers/businessPlanLabels'
|
import { fetchWidgets } from '../stores/roles/rolesSlice';
|
||||||
import { getPortalLabel, isInternalAdmin } from '../helpers/portalRoles'
|
import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator';
|
||||||
import { fetchWidgets } from '../stores/roles/rolesSlice'
|
import { SmartWidget } from '../components/SmartWidget/SmartWidget';
|
||||||
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 Dashboard = () => {
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch();
|
||||||
const iconsColor = useAppSelector((state) => state.style.iconsColor)
|
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
||||||
const corners = useAppSelector((state) => state.style.corners)
|
const corners = useAppSelector((state) => state.style.corners);
|
||||||
const cardsStyle = useAppSelector((state) => state.style.cardsStyle)
|
const cardsStyle = useAppSelector((state) => state.style.cardsStyle);
|
||||||
const { currentUser } = useAppSelector((state) => state.auth)
|
|
||||||
const { isFetchingQuery } = useAppSelector((state) => state.openAi)
|
const loadingMessage = 'Loading...';
|
||||||
const { rolesWidgets, loading } = useAppSelector((state) => state.roles)
|
|
||||||
const [counts, setCounts] = React.useState<CountState>(initialCounts)
|
|
||||||
|
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 [widgetsRole, setWidgetsRole] = React.useState({
|
const [widgetsRole, setWidgetsRole] = React.useState({
|
||||||
role: { value: '', label: '' },
|
role: { value: '', label: '' },
|
||||||
})
|
});
|
||||||
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
|
const { isFetchingQuery } = useAppSelector((state) => state.openAi);
|
||||||
|
|
||||||
const adminPortal = isInternalAdmin(currentUser)
|
const { rolesWidgets, loading } = useAppSelector((state) => state.roles);
|
||||||
const businessLabel = getBusinessMenuLabel(currentUser?.subscriptionPlanId)
|
|
||||||
const businessProfilesLabel = adminPortal ? 'Business profiles' : businessLabel
|
|
||||||
|
|
||||||
const loadData = React.useCallback(async () => {
|
|
||||||
if (!currentUser) return
|
|
||||||
|
|
||||||
const requests = entityKeys.map(async (key) => {
|
async function loadData() {
|
||||||
const config = entityConfig[key]
|
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,];
|
||||||
|
|
||||||
if (!hasPermission(currentUser, config.permission)) {
|
const requests = entities.map((entity, index) => {
|
||||||
return { key, count: null }
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await axios.get(`/${config.endpoint}/count`)
|
if(hasPermission(currentUser, `READ_${entity.toUpperCase()}`)) {
|
||||||
|
return axios.get(`/${entity.toLowerCase()}/count`);
|
||||||
return { key, count: response.data.count as CountValue }
|
|
||||||
})
|
|
||||||
|
|
||||||
const results = await Promise.allSettled(requests)
|
|
||||||
|
|
||||||
setCounts((previousCounts) => {
|
|
||||||
const nextCounts = { ...previousCounts }
|
|
||||||
|
|
||||||
results.forEach((result, index) => {
|
|
||||||
const key = entityKeys[index]
|
|
||||||
|
|
||||||
if (result.status === 'fulfilled') {
|
|
||||||
nextCounts[result.value.key] = result.value.count
|
|
||||||
} else {
|
} else {
|
||||||
console.error(`Failed to load ${key} dashboard count:`, result.reason)
|
fns[index](null);
|
||||||
nextCounts[key] = 'Error'
|
return Promise.resolve({data: {count: null}});
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
return nextCounts
|
});
|
||||||
})
|
|
||||||
}, [currentUser])
|
|
||||||
|
|
||||||
const getWidgets = React.useCallback(async (roleId: string) => {
|
Promise.allSettled(requests).then((results) => {
|
||||||
await dispatch(fetchWidgets(roleId))
|
results.forEach((result, i) => {
|
||||||
}, [dispatch])
|
if (result.status === 'fulfilled') {
|
||||||
|
fns[i](result.value.data.count);
|
||||||
|
} else {
|
||||||
|
fns[i](result.reason.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (!currentUser) return
|
if (!currentUser || !widgetsRole?.role?.value) return;
|
||||||
|
getWidgets(widgetsRole?.role?.value || '').then();
|
||||||
loadData().then()
|
}, [widgetsRole?.role?.value]);
|
||||||
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 Setup', href: '/setup' },
|
|
||||||
{ label: `Manage ${businessLabel}`, href: '/businesses/businesses-list', permission: 'READ_BUSINESSES' },
|
|
||||||
{ label: 'Manage review requests', href: '/review_requests/review_requests-list', permission: 'READ_REVIEW_REQUESTS' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle(title)}</title>
|
<title>
|
||||||
|
{getPageTitle('Overview')}
|
||||||
|
</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={sectionIcon} title={title} main>
|
<SectionTitleLineWithButton
|
||||||
|
icon={icon.mdiChartTimelineVariant}
|
||||||
|
title='Overview'
|
||||||
|
main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
<PortalIntroCard currentUser={currentUser} adminPortal={adminPortal} />
|
{hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator
|
||||||
|
|
||||||
{showCustomerWidgets && (
|
|
||||||
<WidgetCreator
|
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
isFetchingQuery={isFetchingQuery}
|
isFetchingQuery={isFetchingQuery}
|
||||||
setWidgetsRole={setWidgetsRole}
|
setWidgetsRole={setWidgetsRole}
|
||||||
widgetsRole={widgetsRole}
|
widgetsRole={widgetsRole}
|
||||||
/>
|
/>}
|
||||||
)}
|
{!!rolesWidgets.length &&
|
||||||
{!!rolesWidgets.length && showCustomerWidgets && (
|
hasPermission(currentUser, 'CREATE_ROLES') && (
|
||||||
<p className='mb-4 text-gray-500 dark:text-gray-400'>
|
<p className=' text-gray-500 dark:text-gray-400 mb-4'>
|
||||||
{`${widgetsRole?.role?.label || 'Users'}'s widgets`}
|
{`${widgetsRole?.role?.label || 'Users'}'s widgets`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!adminPortal && (
|
|
||||||
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-6 grid-flow-dense'>
|
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-6 grid-flow-dense'>
|
||||||
{(isFetchingQuery || loading) && (
|
{(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`}>
|
<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`}>
|
||||||
@ -510,37 +128,304 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{rolesWidgets.map((widget) => (
|
{ rolesWidgets &&
|
||||||
|
rolesWidgets.map((widget) => (
|
||||||
<SmartWidget
|
<SmartWidget
|
||||||
key={widget.id}
|
key={widget.id}
|
||||||
userId={currentUser?.id}
|
userId={currentUser?.id}
|
||||||
widget={widget}
|
widget={widget}
|
||||||
roleId={widgetsRole?.role?.value || ''}
|
roleId={widgetsRole?.role?.value || ''}
|
||||||
admin={false}
|
admin={hasPermission(currentUser, 'CREATE_ROLES')}
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<div className='grid grid-cols-1 gap-6 lg:grid-cols-3'>
|
{!!rolesWidgets.length && <hr className='my-6 ' />}
|
||||||
{actionGroups.map((group) => (
|
|
||||||
<ActionGroupCard key={group.title} group={group} currentUser={currentUser} />
|
<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>
|
</div>
|
||||||
</SectionMain>
|
</SectionMain>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1,677 +0,0 @@
|
|||||||
import {
|
|
||||||
mdiCreditCardOutline,
|
|
||||||
mdiOpenInNew,
|
|
||||||
mdiRefresh,
|
|
||||||
mdiSend,
|
|
||||||
mdiStarCircleOutline,
|
|
||||||
} from '@mdi/js';
|
|
||||||
import axios from 'axios';
|
|
||||||
import Head from 'next/head';
|
|
||||||
import React, { 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;
|
|
||||||
brand_logo_url?: string;
|
|
||||||
brand_primary_color?: string;
|
|
||||||
email_sender_name?: string;
|
|
||||||
email_reply_to?: string;
|
|
||||||
email_footer_text?: string;
|
|
||||||
email_subject_template?: string;
|
|
||||||
email_body_template?: string;
|
|
||||||
sms_template?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SummaryResponse = {
|
|
||||||
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',
|
|
||||||
brandLogoUrl: '',
|
|
||||||
brandPrimaryColor: '#4f46e5',
|
|
||||||
emailSenderName: '',
|
|
||||||
emailReplyTo: '',
|
|
||||||
emailFooterText: 'Sent by Review Flow for {businessName}.',
|
|
||||||
emailSubjectTemplate: 'How was your experience with {businessName}?',
|
|
||||||
emailBodyTemplate: [
|
|
||||||
'Hi {customerName},',
|
|
||||||
'',
|
|
||||||
'Thank you for choosing {businessName}. We would love to hear about your experience.',
|
|
||||||
'',
|
|
||||||
'Leave a review: {reviewLink}',
|
|
||||||
'',
|
|
||||||
'Thank you,',
|
|
||||||
'{businessName}',
|
|
||||||
].join('\n'),
|
|
||||||
smsTemplate: 'Thanks for choosing {businessName}. Please leave a review: {reviewLink}',
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultCampaign = {
|
|
||||||
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',
|
|
||||||
brandLogoUrl: business.brand_logo_url || defaultSettings.brandLogoUrl,
|
|
||||||
brandPrimaryColor: business.brand_primary_color || defaultSettings.brandPrimaryColor,
|
|
||||||
emailSenderName: business.email_sender_name || defaultSettings.emailSenderName,
|
|
||||||
emailReplyTo: business.email_reply_to || defaultSettings.emailReplyTo,
|
|
||||||
emailFooterText: business.email_footer_text || defaultSettings.emailFooterText,
|
|
||||||
emailSubjectTemplate: business.email_subject_template || defaultSettings.emailSubjectTemplate,
|
|
||||||
emailBodyTemplate: business.email_body_template || defaultSettings.emailBodyTemplate,
|
|
||||||
smsTemplate: business.sms_template || defaultSettings.smsTemplate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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 [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 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 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'>
|
|
||||||
Growth actions · campaigns · widgets · AI replies
|
|
||||||
</p>
|
|
||||||
<h2 className='max-w-3xl text-4xl font-black tracking-tight md:text-5xl'>
|
|
||||||
Use the tools that grow reviews after Setup is done.
|
|
||||||
</h2>
|
|
||||||
<p className='mt-4 max-w-2xl text-base text-slate-200 md:text-lg'>
|
|
||||||
Business configuration now lives in Setup. This page stays focused on the social widget, AI replies, campaigns, rebooking, NPS, competitor insights, and automation actions.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className='grid grid-cols-2 gap-3'>
|
|
||||||
{[
|
|
||||||
['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.75fr_1.25fr]'>
|
|
||||||
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
|
|
||||||
<div className='mb-5 flex items-start justify-between gap-4'>
|
|
||||||
<div>
|
|
||||||
<p className='text-sm font-bold uppercase tracking-[0.25em] text-emerald-500'>Growth workspace</p>
|
|
||||||
<h3 className='text-2xl font-black text-slate-900 dark:text-white'>Use tools after Setup is complete</h3>
|
|
||||||
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400'>
|
|
||||||
Growth Tools is now action-focused. Company info, payment connectors, review links, and branded templates live in Setup.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<BaseButton icon={mdiRefresh} label='Refresh' color='whiteDark' onClick={loadData} disabled={isLoading} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{businesses.length > 0 && (
|
|
||||||
<FormField label='Business profile' help='Choose which business profile these tools should use.'>
|
|
||||||
<select value={selectedBusinessId} onChange={(event) => selectBusiness(event.target.value)}>
|
|
||||||
{businesses.map((business) => (
|
|
||||||
<option key={business.id} value={business.id}>{business.name || 'Business'}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className='rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm leading-6 text-slate-600 dark:border-dark-700 dark:bg-dark-800 dark:text-slate-300'>
|
|
||||||
<p className='font-black text-slate-900 dark:text-white'>{selectedBusiness?.name || settingsForm.businessName || 'No business selected'}</p>
|
|
||||||
<p className='mt-1'>Review workflow: {settingsForm.businessType} · destination: {settingsForm.reviewDestination}</p>
|
|
||||||
<p className='mt-1'>If this looks wrong, update it in Setup instead of editing it here.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='mt-5 flex flex-wrap gap-3'>
|
|
||||||
<BaseButton href='/setup' icon={mdiOpenInNew} label='Open Setup' color='info' />
|
|
||||||
<BaseButton icon={mdiSend} label='Run due automation' color='success' onClick={runDueAutomation} disabled={isWorking} />
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
|
|
||||||
<div className='grid gap-6'>
|
|
||||||
<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>;
|
|
||||||
};
|
|
||||||
@ -1,261 +1,161 @@
|
|||||||
import { mdiArrowRight, mdiCheckCircleOutline, mdiLogin, mdiShieldCheckOutline, mdiStarCircleOutline } from '@mdi/js';
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import React, { ReactElement } from 'react';
|
|
||||||
import BaseButton from '../components/BaseButton';
|
import BaseButton from '../components/BaseButton';
|
||||||
import CardBox from '../components/CardBox';
|
import CardBox from '../components/CardBox';
|
||||||
|
import SectionFullScreen from '../components/SectionFullScreen';
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
|
import BaseDivider from '../components/BaseDivider';
|
||||||
|
import BaseButtons from '../components/BaseButtons';
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import { subscriptionPlans, trialDays } from '../subscriptionPlans';
|
import { useAppSelector } from '../stores/hooks';
|
||||||
import { getBusinessProfileNoun } from '../helpers/businessPlanLabels';
|
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
||||||
|
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
||||||
|
|
||||||
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() {
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-[#F7F8FC] text-slate-950">
|
<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',
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Review Flow')}</title>
|
<title>{getPageTitle('Starter Page')}</title>
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
content="Review Flow helps businesses queue and track review requests after customer purchases or visits."
|
|
||||||
/>
|
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<header className="sticky top-0 z-20 border-b border-white/70 bg-white/80 backdrop-blur-xl">
|
<SectionFullScreen bg='violet'>
|
||||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
|
<div
|
||||||
<Link href="/" className="flex items-center gap-3 font-black tracking-tight">
|
className={`flex ${
|
||||||
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#101828] text-white shadow-lg shadow-indigo-950/20">
|
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||||
★
|
} min-h-screen w-full`}
|
||||||
</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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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 && (
|
{contentType === 'image' && contentPosition !== 'background'
|
||||||
<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">
|
? imageBlock(illustrationImage)
|
||||||
{plan.highlight}
|
: null}
|
||||||
</div>
|
{contentType === 'video' && contentPosition !== 'background'
|
||||||
)}
|
? videoBlock(illustrationVideo)
|
||||||
<div className="p-8">
|
: null}
|
||||||
<p className="text-sm font-black uppercase tracking-[0.3em] text-slate-400">Review Flow</p>
|
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||||
<h3 className="mt-3 text-3xl font-black text-slate-950">{plan.name}</h3>
|
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||||
<p className="mt-3 min-h-[56px] leading-7 text-slate-600">{plan.tagline}</p>
|
<CardBoxComponentTitle title="Welcome to your ReviewFlow app!"/>
|
||||||
|
|
||||||
<div className="mt-8 flex items-end gap-2">
|
<div className="space-y-3">
|
||||||
<span className="text-5xl font-black tracking-tight text-slate-950">${plan.priceMonthly}</span>
|
<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>
|
||||||
<span className="pb-2 font-bold text-slate-500">/month</span>
|
<p className='text-center text-gray-500'>For guides and documentation please check
|
||||||
</div>
|
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
|
<BaseButtons>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
href={`/register?plan=${plan.id}`}
|
href='/login'
|
||||||
icon={mdiArrowRight}
|
label='Login'
|
||||||
label={plan.ctaLabel}
|
color='info'
|
||||||
color={isPro ? 'info' : 'whiteDark'}
|
className='w-full'
|
||||||
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'}>
|
</BaseButtons>
|
||||||
<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>
|
</CardBox>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
||||||
|
|
||||||
<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>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -263,3 +163,4 @@ export default function Starter() {
|
|||||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,7 @@ import { findMe, loginUser, resetAction } from '../stores/authSlice';
|
|||||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {toast, ToastContainer} from "react-toastify";
|
import {toast, ToastContainer} from "react-toastify";
|
||||||
import { getPexelsImage } from '../helpers/pexels'
|
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -33,99 +33,26 @@ export default function Login() {
|
|||||||
photographer: undefined,
|
photographer: undefined,
|
||||||
photographer_url: undefined,
|
photographer_url: undefined,
|
||||||
})
|
})
|
||||||
const [contentPosition] = useState<'left' | 'right' | 'background'>('left');
|
const [ illustrationVideo, setIllustrationVideo ] = useState({video_files: []})
|
||||||
|
const [contentType, setContentType] = useState('video');
|
||||||
|
const [contentPosition, setContentPosition] = useState('left');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
|
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
|
||||||
(state) => state.auth,
|
(state) => state.auth,
|
||||||
);
|
);
|
||||||
const [initialValues, setInitialValues] = React.useState({ email:'pro@reviewflow.demo',
|
const [initialValues, setInitialValues] = React.useState({ email:'admin@flatlogic.com',
|
||||||
password: 'ProDemo2026!',
|
password: 'fc6e39e3',
|
||||||
remember: true })
|
remember: true })
|
||||||
|
|
||||||
const title = 'ReviewFlow'
|
const title = 'ReviewFlow'
|
||||||
|
|
||||||
const appHighlights = [
|
// Fetch Pexels image/video
|
||||||
'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( () => {
|
useEffect( () => {
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
const image = await getPexelsImage()
|
const image = await getPexelsImage()
|
||||||
|
const video = await getPexelsVideo()
|
||||||
setIllustrationImage(image);
|
setIllustrationImage(image);
|
||||||
|
setIllustrationVideo(video);
|
||||||
}
|
}
|
||||||
fetchData();
|
fetchData();
|
||||||
}, []);
|
}, []);
|
||||||
@ -188,7 +115,32 @@ export default function Login() {
|
|||||||
</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 (
|
return (
|
||||||
<div style={contentPosition === 'background' ? {
|
<div style={contentPosition === 'background' ? {
|
||||||
@ -207,7 +159,8 @@ export default function Login() {
|
|||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<SectionFullScreen bg='violet'>
|
||||||
<div className={`flex ${contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'} min-h-screen w-full`}>
|
<div className={`flex ${contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'} min-h-screen w-full`}>
|
||||||
{contentPosition !== 'background' ? imageBlock(illustrationImage) : null}
|
{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'>
|
<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'>
|
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'>
|
||||||
@ -218,23 +171,17 @@ export default function Login() {
|
|||||||
<div>
|
<div>
|
||||||
|
|
||||||
<p className='mb-2'>Use{' '}
|
<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} `}
|
<code className={`cursor-pointer ${textColor} `}
|
||||||
data-password="fc6e39e3"
|
data-password="fc6e39e3"
|
||||||
onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '}
|
onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '}
|
||||||
<code className={`${textColor}`}>fc6e39e3</code>{' / '}
|
<code className={`${textColor}`}>fc6e39e3</code>{' / '}
|
||||||
to login as Internal Admin</p>
|
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>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<BaseIcon
|
<BaseIcon
|
||||||
@ -310,95 +257,6 @@ export default function Login() {
|
|||||||
</Form>
|
</Form>
|
||||||
</Formik>
|
</Formik>
|
||||||
</CardBox>
|
</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'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>
|
||||||
</div>
|
</div>
|
||||||
</SectionFullScreen>
|
</SectionFullScreen>
|
||||||
|
|||||||
@ -175,7 +175,6 @@ const EditPermissions = () => {
|
|||||||
EditPermissions.getLayout = function getLayout(page: ReactElement) {
|
EditPermissions.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'UPDATE_PERMISSIONS'}
|
permission={'UPDATE_PERMISSIONS'}
|
||||||
|
|
||||||
|
|||||||
@ -172,7 +172,6 @@ const EditPermissionsPage = () => {
|
|||||||
EditPermissionsPage.getLayout = function getLayout(page: ReactElement) {
|
EditPermissionsPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'UPDATE_PERMISSIONS'}
|
permission={'UPDATE_PERMISSIONS'}
|
||||||
|
|
||||||
|
|||||||
@ -150,7 +150,6 @@ const PermissionsTablesPage = () => {
|
|||||||
PermissionsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
PermissionsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'READ_PERMISSIONS'}
|
permission={'READ_PERMISSIONS'}
|
||||||
|
|
||||||
|
|||||||
@ -131,7 +131,6 @@ const PermissionsNew = () => {
|
|||||||
PermissionsNew.getLayout = function getLayout(page: ReactElement) {
|
PermissionsNew.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'CREATE_PERMISSIONS'}
|
permission={'CREATE_PERMISSIONS'}
|
||||||
|
|
||||||
|
|||||||
@ -148,7 +148,6 @@ const PermissionsTablesPage = () => {
|
|||||||
PermissionsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
PermissionsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'READ_PERMISSIONS'}
|
permission={'READ_PERMISSIONS'}
|
||||||
|
|
||||||
|
|||||||
@ -115,7 +115,6 @@ const PermissionsView = () => {
|
|||||||
PermissionsView.getLayout = function getLayout(page: ReactElement) {
|
PermissionsView.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'READ_PERMISSIONS'}
|
permission={'READ_PERMISSIONS'}
|
||||||
|
|
||||||
|
|||||||
@ -12,15 +12,12 @@ import BaseDivider from '../components/BaseDivider';
|
|||||||
import BaseButtons from '../components/BaseButtons';
|
import BaseButtons from '../components/BaseButtons';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import { subscriptionPlans } from '../subscriptionPlans';
|
|
||||||
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
export default function Register() {
|
export default function Register() {
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
const router = useRouter();
|
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"});
|
const notify = (type, msg) => toast( msg, {type, position: "bottom-center"});
|
||||||
|
|
||||||
|
|
||||||
@ -28,7 +25,7 @@ export default function Register() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const { data: response } = await axios.post('/auth/signup',{ ...value, planId: selectedPlan.id });
|
const { data: response } = await axios.post('/auth/signup',value);
|
||||||
await router.push('/login')
|
await router.push('/login')
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
notify('success', 'Please check your email for verification link')
|
notify('success', 'Please check your email for verification link')
|
||||||
@ -47,10 +44,6 @@ export default function Register() {
|
|||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<SectionFullScreen bg='violet'>
|
||||||
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
|
<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
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
email: '',
|
email: '',
|
||||||
|
|||||||
@ -1,344 +0,0 @@
|
|||||||
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>;
|
|
||||||
};
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
import type { GetServerSideProps } from 'next';
|
|
||||||
|
|
||||||
export const getServerSideProps: GetServerSideProps = async () => ({
|
|
||||||
redirect: {
|
|
||||||
destination: '/setup',
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function ReviewFlowRedirect() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@ -264,7 +264,6 @@ const EditRoles = () => {
|
|||||||
EditRoles.getLayout = function getLayout(page: ReactElement) {
|
EditRoles.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'UPDATE_ROLES'}
|
permission={'UPDATE_ROLES'}
|
||||||
|
|
||||||
|
|||||||
@ -261,7 +261,6 @@ const EditRolesPage = () => {
|
|||||||
EditRolesPage.getLayout = function getLayout(page: ReactElement) {
|
EditRolesPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'UPDATE_ROLES'}
|
permission={'UPDATE_ROLES'}
|
||||||
|
|
||||||
|
|||||||
@ -150,7 +150,6 @@ const RolesTablesPage = () => {
|
|||||||
RolesTablesPage.getLayout = function getLayout(page: ReactElement) {
|
RolesTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'READ_ROLES'}
|
permission={'READ_ROLES'}
|
||||||
|
|
||||||
|
|||||||
@ -183,7 +183,6 @@ const RolesNew = () => {
|
|||||||
RolesNew.getLayout = function getLayout(page: ReactElement) {
|
RolesNew.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'CREATE_ROLES'}
|
permission={'CREATE_ROLES'}
|
||||||
|
|
||||||
|
|||||||
@ -148,7 +148,6 @@ const RolesTablesPage = () => {
|
|||||||
RolesTablesPage.getLayout = function getLayout(page: ReactElement) {
|
RolesTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'READ_ROLES'}
|
permission={'READ_ROLES'}
|
||||||
|
|
||||||
|
|||||||
@ -292,7 +292,6 @@ const RolesView = () => {
|
|||||||
RolesView.getLayout = function getLayout(page: ReactElement) {
|
RolesView.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'READ_ROLES'}
|
permission={'READ_ROLES'}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -665,7 +665,6 @@ const EditStripe_events = () => {
|
|||||||
EditStripe_events.getLayout = function getLayout(page: ReactElement) {
|
EditStripe_events.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'UPDATE_STRIPE_EVENTS'}
|
permission={'UPDATE_STRIPE_EVENTS'}
|
||||||
|
|
||||||
|
|||||||
@ -662,7 +662,6 @@ const EditStripe_eventsPage = () => {
|
|||||||
EditStripe_eventsPage.getLayout = function getLayout(page: ReactElement) {
|
EditStripe_eventsPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'UPDATE_STRIPE_EVENTS'}
|
permission={'UPDATE_STRIPE_EVENTS'}
|
||||||
|
|
||||||
|
|||||||
@ -154,7 +154,6 @@ const Stripe_eventsTablesPage = () => {
|
|||||||
Stripe_eventsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
Stripe_eventsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'READ_STRIPE_EVENTS'}
|
permission={'READ_STRIPE_EVENTS'}
|
||||||
|
|
||||||
|
|||||||
@ -482,7 +482,6 @@ const Stripe_eventsNew = () => {
|
|||||||
Stripe_eventsNew.getLayout = function getLayout(page: ReactElement) {
|
Stripe_eventsNew.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'CREATE_STRIPE_EVENTS'}
|
permission={'CREATE_STRIPE_EVENTS'}
|
||||||
|
|
||||||
|
|||||||
@ -156,7 +156,6 @@ const Stripe_eventsTablesPage = () => {
|
|||||||
Stripe_eventsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
Stripe_eventsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'READ_STRIPE_EVENTS'}
|
permission={'READ_STRIPE_EVENTS'}
|
||||||
|
|
||||||
|
|||||||
@ -380,7 +380,6 @@ const Stripe_eventsView = () => {
|
|||||||
Stripe_eventsView.getLayout = function getLayout(page: ReactElement) {
|
Stripe_eventsView.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'READ_STRIPE_EVENTS'}
|
permission={'READ_STRIPE_EVENTS'}
|
||||||
|
|
||||||
|
|||||||
@ -1,412 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
||||||
@ -2,7 +2,6 @@ import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js
|
|||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import React, { ReactElement } from 'react'
|
import React, { ReactElement } from 'react'
|
||||||
import CardBox from '../../components/CardBox'
|
import CardBox from '../../components/CardBox'
|
||||||
import SubscriptionLimitGate from '../../components/SubscriptionLimitGate'
|
|
||||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||||
import SectionMain from '../../components/SectionMain'
|
import SectionMain from '../../components/SectionMain'
|
||||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||||
@ -24,10 +23,9 @@ import { SelectFieldMany } from "../../components/SelectFieldMany";
|
|||||||
import {RichTextField} from "../../components/RichTextField";
|
import {RichTextField} from "../../components/RichTextField";
|
||||||
|
|
||||||
import { create } from '../../stores/users/usersSlice'
|
import { create } from '../../stores/users/usersSlice'
|
||||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
|
import { useAppDispatch } from '../../stores/hooks'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { isInternalAdmin } from '../../helpers/portalRoles';
|
|
||||||
|
|
||||||
const initialValues = {
|
const initialValues = {
|
||||||
|
|
||||||
@ -165,8 +163,6 @@ const initialValues = {
|
|||||||
const UsersNew = () => {
|
const UsersNew = () => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { currentUser } = useAppSelector((state) => state.auth)
|
|
||||||
const canManageAccessControls = isInternalAdmin(currentUser)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -184,10 +180,6 @@ const UsersNew = () => {
|
|||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
<SubscriptionLimitGate
|
|
||||||
limitKey='teamMembers'
|
|
||||||
actionLabel='Inviting another team member'
|
|
||||||
/>
|
|
||||||
<CardBox>
|
<CardBox>
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={
|
initialValues={
|
||||||
@ -435,15 +427,9 @@ const UsersNew = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
{canManageAccessControls ? (
|
|
||||||
<FormField label="App Role" labelFor="app_role">
|
<FormField label="App Role" labelFor="app_role">
|
||||||
<Field name="app_role" id="app_role" component={SelectField} options={[]} itemRef={'roles'}></Field>
|
<Field name="app_role" id="app_role" component={SelectField} options={[]} itemRef={'roles'}></Field>
|
||||||
</FormField>
|
</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -473,7 +459,6 @@ const UsersNew = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
{canManageAccessControls && (
|
|
||||||
<FormField label='Custom Permissions' labelFor='custom_permissions'>
|
<FormField label='Custom Permissions' labelFor='custom_permissions'>
|
||||||
<Field
|
<Field
|
||||||
name='custom_permissions'
|
name='custom_permissions'
|
||||||
@ -483,7 +468,6 @@ const UsersNew = () => {
|
|||||||
component={SelectFieldMany}>
|
component={SelectFieldMany}>
|
||||||
</Field>
|
</Field>
|
||||||
</FormField>
|
</FormField>
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -407,7 +407,7 @@ const UsersView = () => {
|
|||||||
|
|
||||||
|
|
||||||
<>
|
<>
|
||||||
<p className={'block font-bold mb-2'}>Business profiles owned</p>
|
<p className={'block font-bold mb-2'}>Businesses Owner</p>
|
||||||
<CardBox
|
<CardBox
|
||||||
className='mb-6 border border-gray-300 rounded overflow-hidden'
|
className='mb-6 border border-gray-300 rounded overflow-hidden'
|
||||||
hasTable
|
hasTable
|
||||||
@ -420,53 +420,53 @@ const UsersView = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Business name</th>
|
<th>BusinessName</th>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Google review link</th>
|
<th>GoogleReviewLink</th>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Yelp review link</th>
|
<th>YelpReviewLink</th>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Facebook review link</th>
|
<th>FacebookReviewLink</th>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Review delay days</th>
|
<th>DelayDays</th>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Email subject template</th>
|
<th>EmailSubjectTemplate</th>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Active</th>
|
<th>IsActive</th>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Stripe account reference</th>
|
<th>StripeAccountReference</th>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Stripe connected</th>
|
<th>StripeConnected</th>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Stripe connected at</th>
|
<th>StripeConnectedAt</th>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Default review platform</th>
|
<th>DefaultReviewPlatform</th>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Custom review link</th>
|
<th>CustomReviewLink</th>
|
||||||
|
|
||||||
|
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -158,7 +158,7 @@ export const businessesSlice = createSlice({
|
|||||||
|
|
||||||
builder.addCase(deleteItemsByIds.fulfilled, (state) => {
|
builder.addCase(deleteItemsByIds.fulfilled, (state) => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
fulfilledNotify(state, 'Businesses have been deleted');
|
fulfilledNotify(state, 'Businesses has been deleted');
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.addCase(deleteItemsByIds.rejected, (state, action) => {
|
builder.addCase(deleteItemsByIds.rejected, (state, action) => {
|
||||||
@ -173,7 +173,7 @@ export const businessesSlice = createSlice({
|
|||||||
|
|
||||||
builder.addCase(deleteItem.fulfilled, (state) => {
|
builder.addCase(deleteItem.fulfilled, (state) => {
|
||||||
state.loading = false
|
state.loading = false
|
||||||
fulfilledNotify(state, 'Business has been deleted');
|
fulfilledNotify(state, `${'Businesses'.slice(0, -1)} has been deleted`);
|
||||||
})
|
})
|
||||||
|
|
||||||
builder.addCase(deleteItem.rejected, (state, action) => {
|
builder.addCase(deleteItem.rejected, (state, action) => {
|
||||||
@ -192,7 +192,7 @@ export const businessesSlice = createSlice({
|
|||||||
|
|
||||||
builder.addCase(create.fulfilled, (state) => {
|
builder.addCase(create.fulfilled, (state) => {
|
||||||
state.loading = false
|
state.loading = false
|
||||||
fulfilledNotify(state, 'Business has been created');
|
fulfilledNotify(state, `${'Businesses'.slice(0, -1)} has been created`);
|
||||||
})
|
})
|
||||||
|
|
||||||
builder.addCase(update.pending, (state) => {
|
builder.addCase(update.pending, (state) => {
|
||||||
@ -201,7 +201,7 @@ export const businessesSlice = createSlice({
|
|||||||
})
|
})
|
||||||
builder.addCase(update.fulfilled, (state) => {
|
builder.addCase(update.fulfilled, (state) => {
|
||||||
state.loading = false
|
state.loading = false
|
||||||
fulfilledNotify(state, 'Business has been updated');
|
fulfilledNotify(state, `${'Businesses'.slice(0, -1)} has been updated`);
|
||||||
})
|
})
|
||||||
builder.addCase(update.rejected, (state, action) => {
|
builder.addCase(update.rejected, (state, action) => {
|
||||||
state.loading = false
|
state.loading = false
|
||||||
@ -214,7 +214,7 @@ export const businessesSlice = createSlice({
|
|||||||
})
|
})
|
||||||
builder.addCase(uploadCsv.fulfilled, (state) => {
|
builder.addCase(uploadCsv.fulfilled, (state) => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
fulfilledNotify(state, 'Businesses have been uploaded');
|
fulfilledNotify(state, 'Businesses has been uploaded');
|
||||||
})
|
})
|
||||||
builder.addCase(uploadCsv.rejected, (state, action) => {
|
builder.addCase(uploadCsv.rejected, (state, action) => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
|
|||||||
@ -1,78 +0,0 @@
|
|||||||
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',
|
|
||||||
'Branded email and SMS templates',
|
|
||||||
'2,500 review requests per month',
|
|
||||||
'Up to 10 business profiles',
|
|
||||||
'Up to 10 team members',
|
|
||||||
'Subscription usage dashboard and upgrade controls',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
Loading…
x
Reference in New Issue
Block a user