Compare commits

..

No commits in common. "ai-dev" and "master" have entirely different histories.

95 changed files with 896 additions and 9903 deletions

View File

@ -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"

View File

@ -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 || '';

View File

@ -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]: [

View File

@ -793,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 },

View File

@ -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;
}
},
};

View File

@ -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;
}
},
};

View File

@ -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;
}
},
};

View File

@ -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;
}
},
};

View File

@ -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;
}
},
};

View File

@ -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',
@ -89,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: {
@ -250,44 +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,
}, },
importHash: { importHash: {
@ -340,14 +176,6 @@ custom_review_link: {
constraints: false, constraints: false,
}); });
db.businesses.hasMany(db.transactions, {
as: 'transactions_business',
foreignKey: {
name: 'businessId',
},
constraints: false,
});

View File

@ -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: {

View File

@ -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: {

View File

@ -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: {

View File

@ -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,
});

View File

@ -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);
}); });

View File

@ -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);

View File

@ -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;

View File

@ -1,24 +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 });
}));
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;

View File

@ -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;

View File

@ -1,302 +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 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,
] = 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,
}),
]);
res.status(200).send({
stats: { pending, sent, clicked, reviewed, customers, transactions, paymentEvents },
requests,
recentTransactions,
recentEvents,
});
}));
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 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();
try {
const businessDefaults = {
name: businessName,
review_destination: reviewDestination,
shopify_hosted_reviews_enabled: isHostedReviewDestination,
delay_days: delayDays,
email_subject_template: `How was your experience with ${businessName}?`,
email_body_template: buildEmailBody('{customerName}', businessName, '{reviewLink}'),
is_active: true,
createdById: currentUser.id,
updatedById: currentUser.id,
};
if (reviewLink && reviewLinkField) {
businessDefaults[reviewLinkField] = reviewLink;
}
const [business] = await db.businesses.findOrCreate({
where: { name: businessName, createdById: currentUser.id },
defaults: businessDefaults,
transaction,
});
const businessUpdates = {
review_destination: reviewDestination,
shopify_hosted_reviews_enabled: business.shopify_hosted_reviews_enabled || isHostedReviewDestination,
delay_days: delayDays,
is_active: true,
updatedById: currentUser.id,
};
if (reviewLink && reviewLinkField) {
businessUpdates[reviewLinkField] = reviewLink;
}
await business.update(businessUpdates, { transaction });
const [customer] = await db.customers.findOrCreate({
where: { email: customerEmail, createdById: currentUser.id },
defaults: {
email: customerEmail,
name: customerName || null,
phone: phone || null,
contact_status: 'active',
businessId: business.id,
createdById: currentUser.id,
updatedById: currentUser.id,
},
transaction,
});
await customer.update({
name: customerName || customer.name,
phone: phone || customer.phone,
contact_status: customer.contact_status || 'active',
businessId: business.id,
updatedById: currentUser.id,
}, { transaction });
const emailSubject = `How was your experience with ${businessName}?`;
const reviewRequest = await db.review_requests.create({
status: 'pending',
scheduled_for: scheduledFor,
email_subject: emailSubject,
email_body: buildEmailBody(customerName, businessName, effectiveReviewLink),
review_link: effectiveReviewLink,
tracking_token: trackingToken,
review_platform: reviewDestination,
businessId: business.id,
customerId: customer.id,
createdById: currentUser.id,
updatedById: currentUser.id,
}, { transaction });
await transaction.commit();
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 });
} catch (error) {
await transaction.rollback();
throw error;
}
}));
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;

View File

@ -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;

View File

@ -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;

View File

@ -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,

View File

@ -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,

View File

@ -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

View File

@ -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'],
});
}
};

View File

@ -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;
}
};

View File

@ -1,113 +0,0 @@
const TRIAL_DAYS = 14;
const subscriptionPlans = [
{
id: 'starter',
name: 'Starter',
priceMonthly: 49,
currency: 'USD',
trialDays: TRIAL_DAYS,
tagline: 'For small teams that want automated review collection without extra marketing automation.',
limits: {
monthlyReviewRequests: 250,
businesses: 1,
teamMembers: 2,
paymentConnectors: 5,
},
features: [
'Review Flow dashboard',
'Manual review request creation',
'Hosted public review form',
'Customer management',
'Business profile management',
'Transaction tracking',
'Stripe, Square, PayPal, Shopify, and WooCommerce webhook intake',
'Review request status tracking',
'Email delivery logs',
'Basic reporting',
'Standard support',
],
includedFeatureKeys: [
'reviewflow_dashboard',
'manual_review_requests',
'hosted_review_form',
'customer_management',
'business_management',
'transaction_tracking',
'payment_webhooks',
'review_status_tracking',
'email_delivery_logs',
'basic_reporting',
'standard_support',
],
},
{
id: 'pro',
name: 'Pro',
priceMonthly: 99,
currency: 'USD',
trialDays: TRIAL_DAYS,
tagline: 'For growing businesses that want automation, AI assistance, and reputation marketing tools.',
limits: {
monthlyReviewRequests: 2500,
businesses: 10,
teamMembers: 10,
paymentConnectors: 5,
},
features: [
'Everything in Starter',
'Advanced automation rules',
'AI review reply assistant',
'Social proof widgets',
'Review monitoring workspace',
'Referral campaigns',
'Repeat booking reminders',
'NPS surveys',
'Competitor/reputation insights',
'Broadcast campaigns',
'Advanced reporting',
'Branding customization',
'Priority support',
],
includedFeatureKeys: [
'reviewflow_dashboard',
'manual_review_requests',
'hosted_review_form',
'customer_management',
'business_management',
'transaction_tracking',
'payment_webhooks',
'review_status_tracking',
'email_delivery_logs',
'basic_reporting',
'standard_support',
'advanced_automation',
'ai_review_replies',
'social_proof_widgets',
'review_monitoring',
'referral_campaigns',
'repeat_booking_reminders',
'nps_surveys',
'competitor_insights',
'broadcast_campaigns',
'advanced_reporting',
'branding_customization',
'priority_support',
],
},
];
const getSubscriptionPlans = () => subscriptionPlans.map((plan) => ({
...plan,
limits: { ...plan.limits },
features: [...plan.features],
includedFeatureKeys: [...plan.includedFeatureKeys],
}));
const getSubscriptionPlanById = (planId) => getSubscriptionPlans().find((plan) => plan.id === planId);
module.exports = {
TRIAL_DAYS,
getSubscriptionPlanById,
getSubscriptionPlans,
};

View File

@ -3,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,
@ -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();

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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 }

View File

@ -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>

View File

@ -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,

View File

@ -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),
}, },

View File

@ -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 && (

View File

@ -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,

View File

@ -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>
)
}

View File

@ -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,

View File

@ -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)}`
}

View File

@ -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'
}

View File

@ -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))
} }

View File

@ -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()
@ -66,39 +63,12 @@ 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>
) )

View File

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

View File

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

View File

@ -469,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>
@ -550,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>
@ -587,11 +587,11 @@ const EditBusinesses = () => {
<FormField <FormField
label="Google review link" label="GoogleReviewLink"
> >
<Field <Field
name="google_review_link" name="google_review_link"
placeholder="Google review link" placeholder="GoogleReviewLink"
/> />
</FormField> </FormField>
@ -624,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>
@ -661,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>
@ -704,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>
@ -736,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>
@ -776,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'
@ -824,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'
@ -845,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>
@ -897,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'
@ -928,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"
@ -974,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>
@ -1003,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>

View File

@ -466,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>
@ -547,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>
@ -584,11 +584,11 @@ const EditBusinessesPage = () => {
<FormField <FormField
label="Google review link" label="GoogleReviewLink"
> >
<Field <Field
name="google_review_link" name="google_review_link"
placeholder="Google review link" placeholder="GoogleReviewLink"
/> />
</FormField> </FormField>
@ -621,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>
@ -658,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>
@ -701,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>
@ -733,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>
@ -773,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'
@ -821,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'
@ -842,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>
@ -894,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'
@ -925,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"
@ -971,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>
@ -1000,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>

View File

@ -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'> <CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
<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. Starter accounts manage one business profile; Pro accounts can manage up to ten.'}
</p>
</CardBox>
<SubscriptionLimitGate
limitKey='businesses'
actionLabel='Adding another business profile'
/>
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/businesses/businesses-new'} color='info' label='Add Business'/>} {hasCreatePermission && <BaseButton className={'mr-3'} href={'/businesses/businesses-new'} color='info' label='New Item'/>}
<BaseButton <BaseButton
className={'mr-3'} className={'mr-3'}

View File

@ -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'
@ -272,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={
@ -326,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>
@ -361,11 +356,11 @@ const BusinessesNew = () => {
<FormField <FormField
label="Google review link" label="GoogleReviewLink"
> >
<Field <Field
name="google_review_link" name="google_review_link"
placeholder="Google review link" placeholder="GoogleReviewLink"
/> />
</FormField> </FormField>
@ -396,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>
@ -431,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>
@ -472,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>
@ -502,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>
@ -540,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'
@ -586,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'
@ -605,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>
@ -655,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'
@ -684,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>
@ -723,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>
@ -750,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>

View File

@ -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. Starter 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'}

View File

@ -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

View File

@ -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>;
};

View File

@ -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'}

View File

@ -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'}

View File

@ -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'}

View File

@ -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'}

View File

@ -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'}

View File

@ -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'}

View File

@ -1,546 +1,431 @@
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 { rolesWidgets, loading } = useAppSelector((state) => state.roles)
const [counts, setCounts] = React.useState<CountState>(initialCounts)
const [widgetsRole, setWidgetsRole] = React.useState({
role: { value: '', label: '' },
})
const adminPortal = isInternalAdmin(currentUser) const loadingMessage = 'Loading...';
const businessLabel = getBusinessMenuLabel(currentUser?.subscriptionPlanId)
const businessProfilesLabel = adminPortal ? 'Business profiles' : businessLabel
const loadData = React.useCallback(async () => {
if (!currentUser) return const [users, setUsers] = React.useState(loadingMessage);
const [roles, setRoles] = React.useState(loadingMessage);
const [permissions, setPermissions] = React.useState(loadingMessage);
const [businesses, setBusinesses] = React.useState(loadingMessage);
const [customers, setCustomers] = React.useState(loadingMessage);
const [transactions, setTransactions] = React.useState(loadingMessage);
const [review_requests, setReview_requests] = React.useState(loadingMessage);
const [stripe_events, setStripe_events] = React.useState(loadingMessage);
const [email_delivery_logs, setEmail_delivery_logs] = React.useState(loadingMessage);
const [cron_runs, setCron_runs] = React.useState(loadingMessage);
const requests = entityKeys.map(async (key) => {
const config = entityConfig[key] const [widgetsRole, setWidgetsRole] = React.useState({
role: { value: '', label: '' },
});
const { currentUser } = useAppSelector((state) => state.auth);
const { isFetchingQuery } = useAppSelector((state) => state.openAi);
const { rolesWidgets, loading } = useAppSelector((state) => state.roles);
async function loadData() {
const entities = ['users','roles','permissions','businesses','customers','transactions','review_requests','stripe_events','email_delivery_logs','cron_runs',];
const fns = [setUsers,setRoles,setPermissions,setBusinesses,setCustomers,setTransactions,setReview_requests,setStripe_events,setEmail_delivery_logs,setCron_runs,];
if (!hasPermission(currentUser, config.permission)) { const requests = entities.map((entity, index) => {
return { key, count: null }
} if(hasPermission(currentUser, `READ_${entity.toUpperCase()}`)) {
return axios.get(`/${entity.toLowerCase()}/count`);
} else {
fns[index](null);
return Promise.resolve({data: {count: null}});
}
});
const response = await axios.get(`/${config.endpoint}/count`) Promise.allSettled(requests).then((results) => {
results.forEach((result, i) => {
return { key, count: response.data.count as CountValue } if (result.status === 'fulfilled') {
}) fns[i](result.value.data.count);
} else {
const results = await Promise.allSettled(requests) fns[i](result.reason.message);
}
setCounts((previousCounts) => { });
const nextCounts = { ...previousCounts } });
}
results.forEach((result, index) => {
const key = entityKeys[index] async function getWidgets(roleId) {
await dispatch(fetchWidgets(roleId));
if (result.status === 'fulfilled') { }
nextCounts[result.value.key] = result.value.count React.useEffect(() => {
} else { if (!currentUser) return;
console.error(`Failed to load ${key} dashboard count:`, result.reason) loadData().then();
nextCounts[key] = 'Error' setWidgetsRole({ role: { value: currentUser?.app_role?.id, label: currentUser?.app_role?.name } });
} }, [currentUser]);
})
return nextCounts
})
}, [currentUser])
const getWidgets = React.useCallback(async (roleId: string) => {
await dispatch(fetchWidgets(roleId))
}, [dispatch])
React.useEffect(() => {
if (!currentUser) return
loadData().then()
setWidgetsRole({
role: {
value: currentUser?.app_role?.id,
label: currentUser?.app_role?.name,
},
})
}, [currentUser, loadData])
React.useEffect(() => {
if (!currentUser || !widgetsRole?.role?.value || adminPortal) return
getWidgets(widgetsRole?.role?.value || '').then()
}, [adminPortal, currentUser, getWidgets, widgetsRole?.role?.value])
const adminCards: DashboardCard[] = [
{
key: 'users',
label: 'Customer accounts',
description: 'Owners and team users across the platform.',
href: '/users/users-list',
iconPath: icon.mdiAccountGroup,
permission: 'READ_USERS',
},
{
key: 'businesses',
label: 'Business profiles',
description: 'Business locations connected to review flows.',
href: '/businesses/businesses-list',
iconPath: storeIcon,
permission: 'READ_BUSINESSES',
},
{
key: 'transactions',
label: 'Transactions',
description: 'Payment records feeding review requests.',
href: '/transactions/transactions-list',
iconPath: icon.mdiCreditCardOutline,
permission: 'READ_TRANSACTIONS',
},
{
key: 'review_requests',
label: 'Review requests',
description: 'Review invitations generated by the system.',
href: '/review_requests/review_requests-list',
iconPath: emailFastIcon,
permission: 'READ_REVIEW_REQUESTS',
},
{
key: 'stripe_events',
label: 'Payment events',
description: 'Stripe webhook events and processing status.',
href: '/stripe_events/stripe_events-list',
iconPath: webhookIcon,
permission: 'READ_STRIPE_EVENTS',
},
{
key: 'cron_runs',
label: 'Automation runs',
description: 'Scheduled background job execution history.',
href: '/cron_runs/cron_runs-list',
iconPath: clockIcon,
permission: 'READ_CRON_RUNS',
},
]
const customerCards: DashboardCard[] = [
{
key: 'businesses',
label: businessProfilesLabel,
description: 'Your business profile and Google review destination.',
href: '/businesses/businesses-list',
iconPath: storeIcon,
permission: 'READ_BUSINESSES',
},
{
key: 'customers',
label: 'Customers',
description: 'Customer records created from payments or imports.',
href: '/customers/customers-list',
iconPath: accountMultipleIcon,
permission: 'READ_CUSTOMERS',
},
{
key: 'transactions',
label: 'Transactions',
description: 'Payments that can trigger review follow-up.',
href: '/transactions/transactions-list',
iconPath: icon.mdiCreditCardOutline,
permission: 'READ_TRANSACTIONS',
},
{
key: 'review_requests',
label: 'Review requests',
description: 'Messages scheduled or sent to customers.',
href: '/review_requests/review_requests-list',
iconPath: emailFastIcon,
permission: 'READ_REVIEW_REQUESTS',
},
{
key: 'email_delivery_logs',
label: 'Email delivery',
description: 'Delivery activity for review request emails.',
href: '/email_delivery_logs/email_delivery_logs-list',
iconPath: emailCheckIcon,
permission: 'READ_EMAIL_DELIVERY_LOGS',
},
]
const adminActionGroups: DashboardActionGroup[] = [
{
title: 'Customer operations',
description: 'Support customer accounts and the business profiles they manage.',
actions: [
{ label: 'Review customer accounts', href: '/users/users-list', permission: 'READ_USERS' },
{ label: 'Review business profiles', href: '/businesses/businesses-list', permission: 'READ_BUSINESSES' },
{ label: 'Review end customers', href: '/customers/customers-list', permission: 'READ_CUSTOMERS' },
],
},
{
title: 'Billing & review operations',
description: 'Monitor payment data, webhook events, review requests, and delivery health.',
actions: [
{ label: 'View transactions', href: '/transactions/transactions-list', permission: 'READ_TRANSACTIONS' },
{ label: 'View payment events', href: '/stripe_events/stripe_events-list', permission: 'READ_STRIPE_EVENTS' },
{ label: 'View review requests', href: '/review_requests/review_requests-list', permission: 'READ_REVIEW_REQUESTS' },
{ label: 'View email delivery logs', href: '/email_delivery_logs/email_delivery_logs-list', permission: 'READ_EMAIL_DELIVERY_LOGS' },
{ label: 'View automation runs', href: '/cron_runs/cron_runs-list', permission: 'READ_CRON_RUNS' },
],
},
{
title: 'Platform access control',
description: 'Manage internal roles and permissions for the platform.',
actions: [
{ label: 'Manage roles', href: '/roles/roles-list', permission: 'READ_ROLES' },
{ label: 'Manage permissions', href: '/permissions/permissions-list', permission: 'READ_PERMISSIONS' },
],
},
]
const customerActionGroups: DashboardActionGroup[] = [
{
title: 'Review automation setup',
description: 'Configure the business profile, review request templates, and payment triggers.',
actions: [
{ label: 'Open Review Flow', href: '/reviewflow' },
{ label: `Manage ${businessLabel}`, href: '/businesses/businesses-list', permission: 'READ_BUSINESSES' },
{ label: 'Manage review requests', href: '/review_requests/review_requests-list', permission: 'READ_REVIEW_REQUESTS' },
],
},
{
title: 'Customer records',
description: 'Track customers and transactions that power follow-up messages.',
actions: [
{ label: 'Manage customers', href: '/customers/customers-list', permission: 'READ_CUSTOMERS' },
{ label: 'View transactions', href: '/transactions/transactions-list', permission: 'READ_TRANSACTIONS' },
{ label: 'View email delivery', href: '/email_delivery_logs/email_delivery_logs-list', permission: 'READ_EMAIL_DELIVERY_LOGS' },
],
},
{
title: 'Plan & billing',
description: 'Review plan limits, usage, and billing status for this workspace.',
actions: [
{ label: 'Open subscription', href: '/subscription' },
],
},
]
const cards = adminPortal ? adminCards : customerCards
const actionGroups = adminPortal ? adminActionGroups : customerActionGroups
const visibleCards = cards.filter((card) => hasPermission(currentUser, card.permission))
const title = adminPortal ? 'Internal admin portal' : 'Customer workspace'
const sectionIcon = adminPortal ? icon.mdiShieldAccountVariantOutline : icon.mdiChartTimelineVariant
const showCustomerWidgets = !adminPortal && hasPermission(currentUser, 'CREATE_ROLES')
React.useEffect(() => {
if (!currentUser || !widgetsRole?.role?.value) return;
getWidgets(widgetsRole?.role?.value || '').then();
}, [widgetsRole?.role?.value]);
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'>
{(isFetchingQuery || loading) && (
<div className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 text-lg leading-tight text-gray-500 flex items-center ${cardsStyle} dark:border-dark-700 p-6`}>
<BaseIcon
className={`${iconsColor} animate-spin mr-5`}
w='w-16'
h='h-16'
size={48}
path={icon.mdiLoading}
/>{' '}
Loading widgets...
</div>
)} )}
{rolesWidgets.map((widget) => ( <div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-6 grid-flow-dense'>
<SmartWidget {(isFetchingQuery || loading) && (
key={widget.id} <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`}>
userId={currentUser?.id} <BaseIcon
widget={widget} className={`${iconsColor} animate-spin mr-5`}
roleId={widgetsRole?.role?.value || ''} w='w-16'
admin={false} h='h-16'
/> size={48}
path={icon.mdiLoading}
/>{' '}
Loading widgets...
</div>
)}
{ rolesWidgets &&
rolesWidgets.map((widget) => (
<SmartWidget
key={widget.id}
userId={currentUser?.id}
widget={widget}
roleId={widgetsRole?.role?.value || ''}
admin={hasPermission(currentUser, 'CREATE_ROLES')}
/>
))} ))}
</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>
</> </>

View File

@ -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 (
<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 className="min-h-screen bg-[#F7F8FC] text-slate-950"> <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> {contentType === 'image' && contentPosition !== 'background'
</Link> ? imageBlock(illustrationImage)
<nav className="flex items-center gap-3"> : null}
<BaseButton href="/#pricing" label="Pricing" color="whiteDark" /> {contentType === 'video' && contentPosition !== 'background'
<BaseButton href="/login" icon={mdiLogin} label="Login" color="whiteDark" /> ? videoBlock(illustrationVideo)
<BaseButton href="/reviewflow" icon={mdiArrowRight} label="Review Flow workspace" color="info" /> : null}
</nav> <div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your ReviewFlow app!"/>
<div className="space-y-3">
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center text-gray-500'>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
</BaseButtons>
</CardBox>
</div> </div>
</header> </div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
<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" />
Review automation for modern local businesses
</div>
<h1 className="max-w-4xl text-5xl font-black leading-[0.95] tracking-tight text-slate-950 md:text-7xl">
Ask at the perfect moment. Earn more five-star reviews.
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600">
Review Flow turns Stripe, Square, PayPal, Shopify, and WooCommerce webhooks into scheduled review requests with a clean queue, message preview, and admin controls already wired into your app.
</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 Starter or Pro.</h2>
<p className="mt-5 text-lg leading-8 text-slate-600">
Every plan starts with a {trialDays}-day free trial. Starter covers the core review workflow. Pro adds the advanced automation and reputation marketing tools growing teams need.
</p>
</div>
<div className="mt-12 grid gap-6 lg:grid-cols-2">
{subscriptionPlans.map((plan) => {
const isPro = plan.id === 'pro';
return (
<CardBox
key={plan.id}
className={`relative overflow-hidden border-0 bg-white shadow-2xl ${
isPro ? 'shadow-indigo-950/20 ring-2 ring-indigo-600' : 'shadow-slate-200/70 ring-1 ring-slate-200'
}`}
cardBoxClassName="p-0"
>
{plan.highlight && (
<div className="absolute right-6 top-6 rounded-full bg-indigo-600 px-4 py-1 text-sm font-black text-white shadow-lg shadow-indigo-600/30">
{plan.highlight}
</div>
)}
<div className="p-8">
<p className="text-sm font-black uppercase tracking-[0.3em] text-slate-400">Review Flow</p>
<h3 className="mt-3 text-3xl font-black text-slate-950">{plan.name}</h3>
<p className="mt-3 min-h-[56px] leading-7 text-slate-600">{plan.tagline}</p>
<div className="mt-8 flex items-end gap-2">
<span className="text-5xl font-black tracking-tight text-slate-950">${plan.priceMonthly}</span>
<span className="pb-2 font-bold text-slate-500">/month</span>
</div>
<p className="mt-2 text-sm font-bold text-emerald-600">{plan.trialDays}-day free trial included</p>
<div className="mt-8 grid gap-3 rounded-3xl bg-slate-50 p-5 sm:grid-cols-2">
<div>
<p className="text-2xl font-black text-slate-950">{plan.limits.monthlyReviewRequests.toLocaleString()}</p>
<p className="text-sm text-slate-500">review requests/month</p>
</div>
<div>
<p className="text-2xl font-black text-slate-950">{plan.limits.businesses}</p>
<p className="text-sm text-slate-500">{getBusinessProfileNoun(plan.limits.businesses)}</p>
</div>
<div>
<p className="text-2xl font-black text-slate-950">{plan.limits.teamMembers}</p>
<p className="text-sm text-slate-500">team members</p>
</div>
<div>
<p className="text-2xl font-black text-slate-950">{plan.limits.paymentConnectors}</p>
<p className="text-sm text-slate-500">payment connectors</p>
</div>
</div>
<BaseButton
href={`/register?plan=${plan.id}`}
icon={mdiArrowRight}
label={plan.ctaLabel}
color={isPro ? 'info' : 'whiteDark'}
className="mt-8 w-full shadow-xl shadow-indigo-600/10"
/>
</div>
<div className={isPro ? 'bg-indigo-950 p-8 text-white' : 'bg-slate-950 p-8 text-white'}>
<p className="mb-5 text-sm font-black uppercase tracking-[0.25em] text-emerald-300">Included features</p>
<div className="grid gap-3">
{plan.features.map((feature) => (
<div key={feature} className="flex items-start gap-3">
<span className="mt-1 text-emerald-300">
<svg className="h-5 w-5" viewBox="0 0 24 24">
<path fill="currentColor" d={mdiCheckCircleOutline} />
</svg>
</span>
<span className="font-semibold text-slate-100">{feature}</span>
</div>
))}
</div>
</div>
</CardBox>
);
})}
</div>
</div>
</section>
<section className="bg-[#101828] px-6 py-20 text-white">
<div className="mx-auto grid max-w-7xl gap-10 lg:grid-cols-[0.8fr_1.2fr] lg:items-center">
<div>
<p className="text-sm font-black uppercase tracking-[0.3em] text-emerald-300">First MVP slice</p>
<h2 className="mt-4 text-4xl font-black tracking-tight md:text-5xl">A complete thin workflow, not just a screen.</h2>
<p className="mt-5 leading-8 text-slate-300">
The customer workspace lets an account owner connect payment webhooks, receive events, create transactions and customers, queue review requests, browse recent activity, and inspect the generated message. Internal admin users stay separate for support and operations.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{features.map((feature) => (
<div key={feature} className="flex items-start gap-3 rounded-3xl bg-white/10 p-5 ring-1 ring-white/10">
<span className="mt-1 text-emerald-300"><svg className="h-5 w-5" viewBox="0 0 24 24"><path fill="currentColor" d={mdiCheckCircleOutline} /></svg></span>
<span className="font-semibold text-slate-100">{feature}</span>
</div>
))}
</div>
</div>
</section>
</main>
<footer className="bg-white px-6 py-8">
<div className="mx-auto flex max-w-7xl flex-col gap-4 text-sm text-slate-500 md:flex-row md:items-center md:justify-between">
<p>© 2026 Review Flow. All rights reserved.</p>
<div className="flex gap-5">
<Link href="/privacy-policy/" className="hover:text-slate-950">Privacy Policy</Link>
<Link href="/terms-of-use/" className="hover:text-slate-950">Terms of Use</Link>
<Link href="/login" className="font-bold text-slate-950">Login</Link>
</div>
</div>
</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>;
}; };

View File

@ -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,7 +33,9 @@ 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,
@ -42,90 +44,15 @@ export default function Login() {
password: 'fc6e39e3', password: 'fc6e39e3',
remember: true }) remember: true })
const title = 'Review Flow' 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 Starter and Pro tiers',
description:
'Starter is $49/month for the core review workflow. Pro is $99/month for higher limits, automation, AI, and reputation marketing tools.',
},
];
const pricingPlans = [
{
name: 'Starter',
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: 'Starter 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 want higher limits, automation, AI assistance, and reputation marketing tools.',
sections: [
{
title: 'Everything in Starter',
features: [
'2,500 review requests per month.',
'10 business profiles.',
'10 team members.',
'Priority support and advanced reporting.',
],
},
{
title: 'Growth tools',
features: [
'Advanced automation rules.',
'AI review reply assistant.',
'Social proof widgets, referral campaigns, repeat booking reminders, NPS surveys, and broadcasts.',
],
},
],
},
];
// 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'>
@ -222,13 +175,13 @@ export default function Login() {
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 <p>Use <code
className={`cursor-pointer ${textColor} `} className={`cursor-pointer ${textColor} `}
data-password="874c3b951385" data-password="874c3b951385"
onClick={(e) => setLogin(e.target)}>john@doe.com</code>{' / '} onClick={(e) => setLogin(e.target)}>client@hello.com</code>{' / '}
<code className={`${textColor}`}>874c3b951385</code>{' / '} <code className={`${textColor}`}>874c3b951385</code>{' / '}
to login as Customer Owner</p> to login as User</p>
</div> </div>
<div> <div>
<BaseIcon <BaseIcon
@ -304,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&apos;re better</h4>
<div className='mt-4 grid gap-4 md:grid-cols-3'>
{competitorAdvantages.map((item) => (
<div key={item.title} className='rounded-2xl border border-gray-200 p-4 dark:border-dark-700'>
<h5 className='font-semibold text-gray-900 dark:text-white'>{item.title}</h5>
<p className='mt-2 text-sm leading-6 text-gray-600 dark:text-slate-300'>
{item.description}
</p>
</div>
))}
</div>
</div>
<div>
<div className='flex flex-col justify-between gap-2 md:flex-row md:items-end'>
<div>
<p className='text-sm font-semibold uppercase tracking-[0.2em] text-blue-600'>Pricing</p>
<h4 className='mt-2 text-2xl font-semibold text-gray-900 dark:text-white'>Simple monthly plans</h4>
</div>
<p className='text-sm text-gray-500 dark:text-slate-400'>Upgrade when your review workflow grows.</p>
</div>
<div className='mt-4 grid gap-4 md:grid-cols-2'>
{pricingPlans.map((plan) => (
<div
key={plan.name}
className='rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-dark-700 dark:bg-dark-900'
>
<div className='flex items-start justify-between gap-3'>
<div>
<h5 className='text-lg font-semibold text-gray-900 dark:text-white'>{plan.name}</h5>
<p className='mt-1 text-sm leading-6 text-gray-600 dark:text-slate-300'>{plan.description}</p>
</div>
<div className='text-right'>
<span className='text-3xl font-bold text-blue-600'>{plan.price}</span>
<span className='block text-xs text-gray-500 dark:text-slate-400'>/month</span>
</div>
</div>
<div className='mt-5 space-y-5'>
{plan.sections.map((section) => (
<div key={section.title}>
<h6 className='text-sm font-semibold uppercase tracking-wide text-gray-900 dark:text-white'>
{section.title}
</h6>
<ul className='mt-2 space-y-2 text-sm text-gray-700 dark:text-slate-300'>
{section.features.map((feature) => (
<li key={feature} className='flex gap-2'>
<span className='font-semibold text-blue-600'></span>
<span>{feature}</span>
</li>
))}
</ul>
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
</CardBox>
</div> </div>
</div> </div>
</SectionFullScreen> </SectionFullScreen>

View File

@ -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'}

View File

@ -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'}

View File

@ -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'}

View File

@ -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'}

View File

@ -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'}

View File

@ -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'}

View File

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

View File

@ -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: '',

View File

@ -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>;
};

View File

@ -1,901 +0,0 @@
import {
mdiAccountPlusOutline,
mdiCreditCardOutline,
mdiEmailOutline,
mdiOpenInNew,
mdiRefresh,
mdiSend,
mdiStarCircleOutline,
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import Link from 'next/link';
import React, {
FormEvent,
ReactElement,
useEffect,
useMemo,
useState,
} from 'react';
import PaymentProviderConnectors, {
ConnectorFormValues,
} from '../components/ReviewFlow/PaymentProviderConnectors';
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 { getBusinessProfileLimitLabel } from '../helpers/businessPlanLabels';
interface ReviewBusiness {
id?: string;
name?: string;
google_review_link?: string;
}
interface ReviewCustomer {
name?: string;
email?: string;
}
interface ReviewTransaction {
id: string;
payment_provider?: string;
amount?: string | number;
currency?: string;
paid_at?: string;
receipt_email?: string;
description?: string;
business?: ReviewBusiness;
customer?: ReviewCustomer;
}
interface ReviewEvent {
id: string;
provider?: string;
provider_event_type?: string;
event_type?: string;
processed?: boolean;
processing_error?: string;
createdAt?: string;
business?: ReviewBusiness;
}
interface ReviewRequest {
id: string;
status?: string;
scheduled_for?: string;
email_subject?: string;
email_body?: string;
review_link?: string;
review_platform?: string;
review_rating?: number;
createdAt?: string;
business?: ReviewBusiness;
customer?: ReviewCustomer;
transaction?: ReviewTransaction;
}
interface SummaryResponse {
stats: {
pending: number;
sent: number;
clicked: number;
reviewed: number;
customers: number;
transactions: number;
paymentEvents: number;
};
requests: ReviewRequest[];
recentTransactions?: ReviewTransaction[];
recentEvents?: ReviewEvent[];
}
interface SubscriptionStatusResponse {
subscription: {
planId: string;
planName: string;
effectiveStatus: string;
isActive: boolean;
trialEndsAt?: string | null;
trialDaysLeft?: number | null;
};
usage: {
monthlyReviewRequests: number;
businesses: number;
teamMembers: number;
paymentConnectors: number;
};
limits: {
monthlyReviewRequests: number;
businesses: number;
teamMembers: number;
paymentConnectors: number;
};
}
const defaultForm = {
businessName: 'Review Flow Studio',
reviewDestination: 'google',
reviewLink: 'https://g.page/r/example/review',
delayDays: '7',
customerName: '',
customerEmail: '',
phone: '',
};
const reviewDestinationOptions = [
{ key: 'google', label: 'Google', requiresLink: true },
{ key: 'facebook', label: 'Facebook', requiresLink: true },
{ key: 'yelp', label: 'Yelp', requiresLink: true },
{ key: 'angi', label: 'Angi', requiresLink: true },
{ key: 'opentable', label: 'OpenTable', requiresLink: true },
{ key: 'trustpilot', label: 'Trustpilot', requiresLink: true },
{ key: 'shopify_hosted', label: 'Shopify hosted product review', requiresLink: false },
{ key: 'custom', label: 'Custom review page', requiresLink: true },
];
const statusStyles: Record<string, string> = {
pending: 'bg-amber-100 text-amber-800 ring-amber-200',
sent: 'bg-sky-100 text-sky-800 ring-sky-200',
clicked: 'bg-violet-100 text-violet-800 ring-violet-200',
reviewed: 'bg-emerald-100 text-emerald-800 ring-emerald-200',
failed: 'bg-rose-100 text-rose-800 ring-rose-200',
};
const proFeaturePrompts = [
['Advanced automation', 'Create rules for timing, destinations, and follow-up behavior.'],
['AI reply assistant', 'Draft thoughtful review replies faster from one workspace.'],
['Reputation marketing', 'Unlock widgets, referral campaigns, NPS surveys, and broadcasts.'],
];
function formatDate(value?: string | null) {
if (!value) return 'Not scheduled';
return new Intl.DateTimeFormat('en', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(new Date(value));
}
function hasAuthToken() {
return (
typeof window !== 'undefined' && Boolean(localStorage.getItem('token'))
);
}
function isUnauthorizedError(error: unknown) {
return axios.isAxiosError(error) && error.response?.status === 401;
}
function formatAmount(amount?: string | number, currency?: string) {
const numericAmount = Number(amount);
if (!Number.isFinite(numericAmount)) {
return 'Amount pending';
}
return new Intl.NumberFormat('en', {
style: 'currency',
currency: currency || 'USD',
}).format(numericAmount);
}
export default function ReviewFlowWorkspace() {
const [form, setForm] = useState(defaultForm);
const [summary, setSummary] = useState<SummaryResponse | null>(null);
const [selected, setSelected] = useState<ReviewRequest | null>(null);
const [created, setCreated] = useState<ReviewRequest | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
const [subscriptionStatus, setSubscriptionStatus] =
useState<SubscriptionStatusResponse | null>(null);
const [isClientReady, setIsClientReady] = useState(false);
const requests = summary?.requests ?? [];
const recentTransactions = summary?.recentTransactions ?? [];
const recentEvents = summary?.recentEvents ?? [];
const stats = summary?.stats ?? {
pending: 0,
sent: 0,
clicked: 0,
reviewed: 0,
customers: 0,
transactions: 0,
paymentEvents: 0,
};
const selectedReviewDestination =
reviewDestinationOptions.find(
(destination) => destination.key === form.reviewDestination,
) || reviewDestinationOptions[0];
const isHostedReviewDestination = !selectedReviewDestination.requiresLink;
const previewDate = useMemo(() => {
if (!isClientReady) return 'after the selected delay';
const days = Math.max(0, Number(form.delayDays) || 0);
return formatDate(
new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString(),
);
}, [form.delayDays, isClientReady]);
const loadSummary = async () => {
setIsLoading(true);
try {
const response = await axios.get('/reviewflow/summary');
setSummary(response.data);
if (!selected && response.data.requests?.length) {
setSelected(response.data.requests[0]);
}
setError('');
} catch (requestError) {
if (!isUnauthorizedError(requestError)) {
console.error('Failed to load Review Flow summary:', requestError);
setError(
'Could not load your review queue. Please refresh or try again.',
);
}
} finally {
setIsLoading(false);
}
};
const loadSubscriptionStatus = async () => {
try {
const response = await axios.get('/subscription/me');
setSubscriptionStatus(response.data);
} catch (requestError) {
if (!isUnauthorizedError(requestError)) {
console.error('Failed to load subscription status:', requestError);
}
}
};
useEffect(() => {
setIsClientReady(true);
if (!hasAuthToken()) {
setIsLoading(false);
return;
}
loadSummary();
loadSubscriptionStatus();
}, []);
const updateForm = (key: keyof typeof defaultForm, value: string) => {
setForm((current) => ({ ...current, [key]: value }));
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsSubmitting(true);
setError('');
setCreated(null);
try {
const response = await axios.post('/reviewflow/request', {
...form,
reviewLink: isHostedReviewDestination ? '' : form.reviewLink,
delayDays: Number(form.delayDays),
});
const newRequest = response.data.request;
setCreated(newRequest);
setSelected(newRequest);
setForm((current) => ({
...current,
customerName: '',
customerEmail: '',
phone: '',
}));
await Promise.all([loadSummary(), loadSubscriptionStatus()]);
} catch (requestError) {
console.error('Failed to create review request:', requestError);
if (axios.isAxiosError(requestError) && requestError.response?.data) {
setError(String(requestError.response.data));
} else {
setError(
'Could not create the review request. Please check the fields and try again.',
);
}
} finally {
setIsSubmitting(false);
}
};
const handleProviderConnected = async (
_business: unknown,
connectorForm: ConnectorFormValues,
) => {
setForm((current) => ({
...current,
businessName: connectorForm.businessName,
reviewDestination: connectorForm.reviewDestination,
reviewLink: connectorForm.reviewLink,
delayDays: connectorForm.delayDays,
}));
await Promise.all([loadSummary(), loadSubscriptionStatus()]);
};
const currentSubscription = subscriptionStatus?.subscription;
const currentUsage = subscriptionStatus?.usage;
const currentLimits = subscriptionStatus?.limits;
const reviewRequestsUsed = currentUsage?.monthlyReviewRequests ?? 0;
const reviewRequestsLimit = currentLimits?.monthlyReviewRequests ?? 0;
const reviewRequestsRemaining = Math.max(
0,
reviewRequestsLimit - reviewRequestsUsed,
);
const reviewRequestsPercent = reviewRequestsLimit
? Math.min(100, Math.round((reviewRequestsUsed / reviewRequestsLimit) * 100))
: 0;
const businessesUsed = currentUsage?.businesses ?? 0;
const businessesLimit = currentLimits?.businesses ?? 0;
const businessesRemaining = Math.max(0, businessesLimit - businessesUsed);
const isStarterPlan = currentSubscription?.planId === 'starter';
const isSubscriptionInactive =
currentSubscription && !currentSubscription.isActive;
const isReviewRequestLimitReached = Boolean(
currentSubscription &&
reviewRequestsLimit > 0 &&
reviewRequestsUsed >= reviewRequestsLimit,
);
const isReviewRequestBlocked = Boolean(
isSubscriptionInactive || isReviewRequestLimitReached,
);
return (
<>
<Head>
<title>{getPageTitle('Review Flow')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiStarCircleOutline}
title='Review Flow command center'
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'>
Clean workflow · trigger customer right review destination
</p>
<h2 className='max-w-3xl text-4xl font-black tracking-tight md:text-5xl'>
Keep ecommerce triggers and local review destinations cleanly separated.
</h2>
<p className='mt-4 max-w-2xl text-base text-slate-200 md:text-lg'>
Stripe, Square, PayPal, Shopify, and WooCommerce create customers and transactions from webhooks. Google, Facebook, Yelp, Angi, OpenTable, Trustpilot, and Shopify hosted reviews are treated as review destinations.
</p>
</div>
<div className='grid grid-cols-2 gap-3'>
{[
['Events', stats.paymentEvents],
['Payments', stats.transactions],
['Pending', stats.pending],
['Customers', stats.customers],
['Clicked', stats.clicked],
['Reviewed', stats.reviewed],
].map(([label, value]) => (
<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>
{currentSubscription && currentUsage && currentLimits && (
<div className={`mb-6 rounded-3xl border p-5 shadow-xl ${isSubscriptionInactive ? 'border-rose-200 bg-rose-50 text-rose-950' : 'border-slate-200 bg-white text-slate-900 dark:border-dark-700 dark:bg-dark-900 dark:text-white'}`}>
<div className='grid gap-5 lg:grid-cols-[0.9fr_1.1fr] lg:items-center'>
<div>
<p className='text-sm font-black uppercase tracking-[0.25em] text-emerald-500'>
Plan and usage
</p>
<h3 className='mt-2 text-2xl font-black'>
{currentSubscription.planName} · {currentSubscription.effectiveStatus}
</h3>
<p className='mt-2 text-sm text-slate-500 dark:text-slate-400'>
{currentSubscription.trialDaysLeft !== null &&
currentSubscription.trialDaysLeft !== undefined
? `${currentSubscription.trialDaysLeft} trial days left. `
: ''}
{reviewRequestsRemaining.toLocaleString()} review requests and {getBusinessProfileLimitLabel(businessesRemaining)} remaining on this plan.
</p>
</div>
<div className='grid gap-3 md:grid-cols-[1fr_auto] md:items-center'>
<div>
<div className='mb-2 flex items-center justify-between text-sm font-bold'>
<span>Monthly review requests</span>
<span>{reviewRequestsUsed.toLocaleString()} / {reviewRequestsLimit.toLocaleString()}</span>
</div>
<div className='h-3 overflow-hidden rounded-full bg-slate-100 dark:bg-dark-800'>
<div
className={reviewRequestsPercent >= 80 ? 'h-full rounded-full bg-amber-500' : 'h-full rounded-full bg-emerald-500'}
style={{ width: `${reviewRequestsPercent}%` }}
/>
</div>
</div>
<BaseButton
href='/subscription'
icon={mdiCreditCardOutline}
label={isStarterPlan ? 'Upgrade / manage' : 'Manage plan'}
color={isStarterPlan ? 'info' : 'whiteDark'}
/>
</div>
</div>
</div>
)}
{created && (
<div className='mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-emerald-900'>
<strong>Review request queued.</strong> {created.customer?.email} is
scheduled for {formatDate(created.scheduled_for)}.
</div>
)}
{error && (
<div className='mb-6 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-rose-900'>
<p>{error}</p>
{error.includes('Upgrade to Pro') && (
<BaseButton
href='/subscription'
icon={mdiCreditCardOutline}
label='Manage subscription'
color='danger'
className='mt-3'
/>
)}
</div>
)}
<PaymentProviderConnectors
className='mb-6'
onConnected={handleProviderConnected}
/>
{isStarterPlan && (
<CardBox className='mb-6 border-0 bg-gradient-to-br from-indigo-950 to-slate-950 text-white shadow-2xl'>
<div className='grid gap-6 lg:grid-cols-[0.8fr_1.2fr] lg:items-center'>
<div>
<p className='text-sm font-black uppercase tracking-[0.25em] text-emerald-300'>
Pro upgrade prompts
</p>
<h3 className='mt-2 text-3xl font-black'>
Unlock advanced reputation growth tools.
</h3>
<p className='mt-3 text-slate-300'>
Starter keeps the core review workflow running. Pro raises limits to 10 business profiles and unlocks the next automation, AI, and marketing modules as they are enabled.
</p>
<BaseButton
href='/subscription'
icon={mdiOpenInNew}
label='Upgrade to Pro'
color='info'
className='mt-5'
/>
</div>
<div className='grid gap-3 md:grid-cols-3'>
{proFeaturePrompts.map(([title, copy]) => (
<div key={title} className='rounded-2xl bg-white/10 p-4 ring-1 ring-white/15'>
<p className='font-black'>{title}</p>
<p className='mt-2 text-sm leading-6 text-slate-300'>{copy}</p>
</div>
))}
</div>
</div>
</CardBox>
)}
<div className='grid gap-6 xl:grid-cols-[0.95fr_1.05fr]'>
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
<div className='mb-6 flex items-start justify-between gap-4'>
<div>
<p className='text-sm font-bold uppercase tracking-[0.25em] text-emerald-500'>
Manual fallback
</p>
<h3 className='text-2xl font-black text-slate-900 dark:text-white'>
Queue a review request
</h3>
<p className='mt-2 text-sm text-slate-500 dark:text-slate-400'>
Use this when a payment did not come through a webhook, or
when you want to test the review queue manually.
</p>
</div>
<div className='rounded-2xl bg-emerald-100 p-3 text-emerald-700'>
<BaseButton
icon={mdiAccountPlusOutline}
color='success'
roundedFull
/>
</div>
</div>
{isReviewRequestBlocked && (
<div className='mb-5 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-rose-900 dark:border-rose-900 dark:bg-rose-950 dark:text-rose-50'>
<p className='text-sm font-black uppercase tracking-[0.25em]'>
{isSubscriptionInactive ? 'Subscription inactive' : 'Monthly request limit reached'}
</p>
<p className='mt-2 text-sm leading-6'>
{isSubscriptionInactive
? 'Review requests are paused until this account has an active plan.'
: `${currentSubscription?.planName} includes ${reviewRequestsLimit.toLocaleString()} review requests per month, and this account has already used ${reviewRequestsUsed.toLocaleString()}.`}
{' '}Existing queued requests stay available.
</p>
<BaseButton
href='/subscription'
icon={mdiCreditCardOutline}
label={isStarterPlan ? 'Upgrade to Pro' : 'Manage plan'}
color='danger'
className='mt-3'
/>
</div>
)}
<form onSubmit={handleSubmit}>
<FormField
label='Business and review destination'
help='Choose the destination first. Shopify hosted reviews generate a Review Flow form automatically.'
>
<input
required
value={form.businessName}
onChange={(event) =>
updateForm('businessName', event.target.value)
}
placeholder='Business name'
/>
<select
value={form.reviewDestination}
onChange={(event) =>
updateForm('reviewDestination', event.target.value)
}
>
{reviewDestinationOptions.map((destination) => (
<option key={destination.key} value={destination.key}>
{destination.label}
</option>
))}
</select>
</FormField>
<FormField
label={isHostedReviewDestination ? 'Hosted review form' : 'External review link'}
help={
isHostedReviewDestination
? 'No external URL needed; the outgoing email points to a hosted /review page.'
: 'Use the exact review page where this customer should land.'
}
>
{isHostedReviewDestination ? (
<div className='rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm font-semibold text-emerald-900'>
Review Flow will create a secure hosted product-review link for this request.
</div>
) : (
<input
required
type='url'
value={form.reviewLink}
onChange={(event) =>
updateForm('reviewLink', event.target.value)
}
placeholder='https://your-review-destination.example/review'
/>
)}
</FormField>
<FormField
label='Customer'
help='Webhook payments fill this automatically when the provider sends a customer email.'
>
<input
value={form.customerName}
onChange={(event) =>
updateForm('customerName', event.target.value)
}
placeholder='Customer name'
/>
<input
required
type='email'
value={form.customerEmail}
onChange={(event) =>
updateForm('customerEmail', event.target.value)
}
placeholder='customer@example.com'
/>
</FormField>
<FormField
label='Delay and phone'
help={`Preview: scheduled for ${previewDate}`}
>
<input
min='0'
max='30'
type='number'
value={form.delayDays}
onChange={(event) =>
updateForm('delayDays', event.target.value)
}
placeholder='Delay days'
/>
<input
value={form.phone}
onChange={(event) => updateForm('phone', event.target.value)}
placeholder='Optional phone'
/>
</FormField>
<div className='flex flex-wrap gap-3'>
<BaseButton
type='submit'
icon={mdiSend}
label={isSubmitting ? 'Queueing...' : 'Queue review request'}
color='info'
disabled={isSubmitting || isReviewRequestBlocked}
/>
<BaseButton
type='button'
icon={mdiRefresh}
label='Refresh queue'
color='whiteDark'
onClick={loadSummary}
disabled={isLoading}
/>
</div>
</form>
</CardBox>
<div className='grid gap-6 lg:grid-cols-[0.95fr_1.05fr] xl:grid-cols-1 2xl:grid-cols-[0.95fr_1.05fr]'>
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
<div className='mb-5 flex items-center justify-between'>
<div>
<p className='text-sm font-bold uppercase tracking-[0.25em] text-indigo-500'>
Queue
</p>
<h3 className='text-2xl font-black text-slate-900 dark:text-white'>
Recent requests
</h3>
</div>
</div>
{isLoading ? (
<div className='rounded-2xl border border-dashed border-slate-200 p-8 text-center text-slate-500'>
Loading review queue...
</div>
) : requests.length === 0 ? (
<div className='rounded-2xl border border-dashed border-slate-200 bg-slate-50 p-8 text-center dark:bg-dark-800'>
<div className='mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-indigo-100 text-indigo-700'>
<BaseButton
icon={mdiEmailOutline}
color='info'
roundedFull
/>
</div>
<p className='font-bold text-slate-900 dark:text-white'>
No requests yet
</p>
<p className='mt-1 text-sm text-slate-500'>
Create one manually or send a successful provider payment
webhook.
</p>
</div>
) : (
<div className='space-y-3'>
{requests.map((request) => {
const status = request.status || 'pending';
return (
<button
key={request.id}
type='button'
onClick={() => setSelected(request)}
className={`w-full rounded-2xl border p-4 text-left transition hover:-translate-y-0.5 hover:shadow-lg ${selected?.id === request.id ? 'border-indigo-300 bg-indigo-50 dark:bg-indigo-950/30' : 'border-slate-200 bg-white dark:border-dark-700 dark:bg-dark-900'}`}
>
<div className='flex items-start justify-between gap-3'>
<div>
<p className='font-black text-slate-900 dark:text-white'>
{request.customer?.name ||
request.customer?.email ||
'Customer'}
</p>
<p className='text-sm text-slate-500'>
{request.business?.name || 'Business'} ·{' '}
{formatDate(request.scheduled_for)}
</p>
{request.transaction?.payment_provider && (
<p className='mt-1 text-xs font-bold uppercase tracking-widest text-emerald-600'>
From {request.transaction.payment_provider}{' '}
payment
</p>
)}
</div>
<span
className={`rounded-full px-3 py-1 text-xs font-bold ring-1 ${statusStyles[status] || statusStyles.pending}`}
>
{status}
</span>
</div>
</button>
);
})}
</div>
)}
</CardBox>
<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-fuchsia-500'>
Detail
</p>
<h3 className='mb-5 text-2xl font-black text-slate-900 dark:text-white'>
Message preview
</h3>
{selected ? (
<div className='space-y-4'>
<div className='rounded-2xl bg-slate-50 p-4 dark:bg-dark-800'>
<p className='text-xs font-bold uppercase tracking-widest text-slate-400'>
To
</p>
<p className='font-bold text-slate-900 dark:text-white'>
{selected.customer?.email}
</p>
</div>
<div className='rounded-2xl bg-slate-50 p-4 dark:bg-dark-800'>
<p className='text-xs font-bold uppercase tracking-widest text-slate-400'>
Subject
</p>
<p className='font-bold text-slate-900 dark:text-white'>
{selected.email_subject}
</p>
</div>
<div className='rounded-2xl border border-slate-200 bg-white p-5 text-sm leading-6 text-slate-700 shadow-inner dark:border-dark-700 dark:bg-dark-900 dark:text-slate-200'>
{(selected.email_body || '')
.split('\n')
.map((line, index) => (
<p
key={`${selected.id}-${index}-${line || 'space'}`}
className={line ? '' : 'h-4'}
>
{line}
</p>
))}
</div>
<div className='rounded-2xl bg-gradient-to-r from-indigo-600 to-emerald-500 p-4 text-white'>
<p className='text-xs font-bold uppercase tracking-widest text-white/70'>
Review link / hosted form
</p>
<Link
href={selected.review_link || '#'}
target='_blank'
className='break-all font-bold underline underline-offset-4'
>
{selected.review_link}
</Link>
</div>
</div>
) : (
<div className='rounded-2xl border border-dashed border-slate-200 p-8 text-center text-slate-500'>
Select a request to preview the outgoing message.
</div>
)}
</CardBox>
</div>
</div>
<div className='mt-6 grid gap-6 lg:grid-cols-2'>
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
<div className='mb-5 flex items-center justify-between'>
<div>
<p className='text-sm font-bold uppercase tracking-[0.25em] text-sky-500'>
Webhook intake
</p>
<h3 className='text-2xl font-black text-slate-900 dark:text-white'>
Recent payment events
</h3>
</div>
</div>
{recentEvents.length === 0 ? (
<div className='rounded-2xl border border-dashed border-slate-200 p-8 text-center text-slate-500'>
No provider webhooks received yet.
</div>
) : (
<div className='space-y-3'>
{recentEvents.map((event) => (
<div
key={event.id}
className='rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-dark-700 dark:bg-dark-800'
>
<div className='flex items-start justify-between gap-3'>
<div>
<p className='font-black text-slate-900 dark:text-white'>
{(event.provider || 'provider').toUpperCase()} ·{' '}
{event.provider_event_type ||
event.event_type ||
'unknown event'}
</p>
<p className='text-sm text-slate-500'>
{event.business?.name || 'Business'} ·{' '}
{formatDate(event.createdAt)}
</p>
{event.processing_error && (
<p className='mt-1 text-xs text-amber-700'>
{event.processing_error}
</p>
)}
</div>
<span
className={`rounded-full px-3 py-1 text-xs font-bold ${event.processed ? 'bg-emerald-100 text-emerald-800' : 'bg-amber-100 text-amber-800'}`}
>
{event.processed ? 'processed' : 'pending'}
</span>
</div>
</div>
))}
</div>
)}
</CardBox>
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
<div className='mb-5 flex items-center justify-between'>
<div>
<p className='text-sm font-bold uppercase tracking-[0.25em] text-emerald-500'>
Payments
</p>
<h3 className='text-2xl font-black text-slate-900 dark:text-white'>
Recent transactions
</h3>
</div>
</div>
{recentTransactions.length === 0 ? (
<div className='rounded-2xl border border-dashed border-slate-200 p-8 text-center text-slate-500'>
No transactions created from webhooks yet.
</div>
) : (
<div className='space-y-3'>
{recentTransactions.map((transaction) => (
<div
key={transaction.id}
className='rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-dark-700 dark:bg-dark-800'
>
<div className='flex items-start justify-between gap-3'>
<div>
<p className='font-black text-slate-900 dark:text-white'>
{formatAmount(
transaction.amount,
transaction.currency,
)}
</p>
<p className='text-sm text-slate-500'>
{transaction.payment_provider || 'provider'} ·{' '}
{transaction.customer?.email ||
transaction.receipt_email ||
'No email'}{' '}
· {formatDate(transaction.paid_at)}
</p>
</div>
<span className='rounded-full bg-slate-900 px-3 py-1 text-xs font-bold text-white dark:bg-white dark:text-slate-900'>
{transaction.currency || 'USD'}
</span>
</div>
</div>
))}
</div>
)}
</CardBox>
</div>
</SectionMain>
</>
);
}
ReviewFlowWorkspace.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated portal='customer'>{page}</LayoutAuthenticated>;
};

View File

@ -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'}

View File

@ -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'}

View File

@ -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'}

View File

@ -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'}

View File

@ -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'}

View File

@ -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'}

View File

@ -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'}

View File

@ -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'}

View File

@ -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'}

View File

@ -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'}

View File

@ -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'}

View File

@ -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'}

View File

@ -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 Starter and Pro, paste their Price IDs into the matching variables, add your webhook secret, then reload the backend.
</p>
</CardBox>
)}
{overLimitItems.length > 0 && (
<CardBox className='mb-6 border-0 bg-rose-50 text-rose-950 shadow-xl ring-1 ring-rose-200 dark:bg-rose-950 dark:text-rose-50 dark:ring-rose-800'>
<p className='text-lg font-black'>Plan limit attention needed</p>
<p className='mt-2 leading-7'>
This account is currently over the {status.subscription.planName} limit for{' '}
<strong>{overLimitItems.map((item) => item.label.toLowerCase()).join(', ')}</strong>.
Existing data stays available, but creating more items in those areas will be blocked until usage is reduced or the account moves to a higher plan.
</p>
</CardBox>
)}
<CardBox className='mb-6 overflow-hidden border-0 bg-gradient-to-br from-slate-950 via-indigo-950 to-emerald-950 text-white shadow-2xl'>
<div className='grid gap-6 lg:grid-cols-[1fr_0.8fr] lg:items-center'>
<div>
<p className='text-sm font-black uppercase tracking-[0.3em] text-emerald-300'>
Current plan
</p>
<h2 className='mt-3 text-4xl font-black tracking-tight md:text-5xl'>
{status.subscription.planName}
</h2>
<p className='mt-3 max-w-2xl text-slate-200'>
Status: <strong>{status.subscription.effectiveStatus}</strong>. Trial ends {formatDate(status.subscription.trialEndsAt)}
{status.subscription.trialDaysLeft !== null && status.subscription.trialDaysLeft !== undefined
? ` (${status.subscription.trialDaysLeft} days left)`
: ''}
.
</p>
{status.subscription.currentPeriodEndsAt && (
<p className='mt-2 text-sm font-semibold text-slate-300'>
Current Stripe billing period ends {formatDate(status.subscription.currentPeriodEndsAt)}.
</p>
)}
{status.billing?.hasStripeCustomer && (
<BaseButton
icon={mdiCreditCardOutline}
label='Manage billing'
color='info'
className='mt-6'
disabled={isOpeningPortal || Boolean(billingActionPlanId)}
onClick={openBillingPortal}
/>
)}
</div>
<div className='rounded-3xl bg-white/10 p-6 ring-1 ring-white/15'>
<p className='text-sm font-bold text-slate-300'>Monthly price</p>
<p className='mt-2 text-5xl font-black'>${status.subscription.priceMonthly}</p>
<p className='mt-1 text-sm text-slate-300'>per month after trial</p>
</div>
</div>
</CardBox>
<div className='mb-6 grid gap-6 lg:grid-cols-2'>
{usageLabels.map((item) => {
const used = Number(status.usage[item.key]) || 0
const limit = Number(status.limits[item.limitKey]) || 1
const percent = Math.min(100, Math.round((used / limit) * 100))
const isOverLimit = used > limit
const isNearLimit = !isOverLimit && percent >= 80
const usageTextClass = isOverLimit
? 'font-black text-rose-600'
: isNearLimit ? 'font-black text-amber-600' : 'font-black text-emerald-600'
const progressClass = isOverLimit
? 'h-full rounded-full bg-rose-500'
: isNearLimit ? 'h-full rounded-full bg-amber-500' : 'h-full rounded-full bg-emerald-500'
return (
<CardBox key={item.key} className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
<div className='mb-3 flex items-center justify-between gap-3'>
<p className='font-black text-slate-900 dark:text-white'>{item.label}</p>
<p className={usageTextClass}>
{item.limitKey === 'businesses'
? getBusinessProfileUsageLabel(used, limit)
: `${formatLimit(used)} / ${formatLimit(limit)}`}
</p>
</div>
<div className='h-3 overflow-hidden rounded-full bg-slate-100 dark:bg-dark-800'>
<div
className={progressClass}
style={{ width: `${percent}%` }}
/>
</div>
{isOverLimit && (
<p className='mt-2 text-sm font-semibold text-rose-600'>
Over this plan limit. Upgrade or reduce usage before adding more.
</p>
)}
</CardBox>
)
})}
</div>
<div className='grid gap-6 lg:grid-cols-2'>
{status.plans.map((plan) => {
const isCurrent = currentPlanId === plan.id
const isPro = plan.id === 'pro'
const isBusy = billingActionPlanId === plan.id || isOpeningPortal
const buttonLabel = isPaidStripeSubscription
? isCurrent ? 'Manage billing' : 'Change in billing portal'
: isCurrent ? `Start paid ${plan.name}` : `Checkout for ${plan.name}`
return (
<CardBox
key={plan.id}
className={`relative overflow-hidden border-0 shadow-2xl ${isPro ? 'ring-2 ring-indigo-600' : 'ring-1 ring-slate-200 dark:ring-dark-700'}`}
cardBoxClassName='p-0'
>
{isPro && (
<div className='absolute right-6 top-6 rounded-full bg-indigo-600 px-4 py-1 text-sm font-black text-white'>
Pro growth tools
</div>
)}
<div className='p-8'>
<p className='text-sm font-black uppercase tracking-[0.3em] text-slate-400'>Review Flow</p>
<h3 className='mt-3 text-3xl font-black text-slate-900 dark:text-white'>{plan.name}</h3>
<p className='mt-3 min-h-[56px] leading-7 text-slate-500 dark:text-slate-400'>{plan.tagline}</p>
<div className='mt-8 flex items-end gap-2'>
<span className='text-5xl font-black tracking-tight text-slate-900 dark:text-white'>${plan.priceMonthly}</span>
<span className='pb-2 font-bold text-slate-500'>/month</span>
</div>
<div className='mt-8 grid gap-3 rounded-3xl bg-slate-50 p-5 dark:bg-dark-800 sm:grid-cols-2'>
<div>
<p className='text-2xl font-black'>{formatLimit(plan.limits.monthlyReviewRequests)}</p>
<p className='text-sm text-slate-500'>requests/month</p>
</div>
<div>
<p className='text-2xl font-black'>{formatLimit(plan.limits.businesses)}</p>
<p className='text-sm text-slate-500'>{getBusinessProfileNoun(plan.limits.businesses)}</p>
</div>
<div>
<p className='text-2xl font-black'>{formatLimit(plan.limits.teamMembers)}</p>
<p className='text-sm text-slate-500'>team members</p>
</div>
<div>
<p className='text-2xl font-black'>{formatLimit(plan.limits.paymentConnectors)}</p>
<p className='text-sm text-slate-500'>connectors</p>
</div>
</div>
<BaseButton
icon={isCurrent ? mdiCheckCircleOutline : mdiArrowUpBoldCircleOutline}
label={buttonLabel}
color={isCurrent ? 'success' : isPro ? 'info' : 'whiteDark'}
className='mt-8 w-full'
disabled={isBusy}
onClick={() => (isPaidStripeSubscription ? openBillingPortal() : startCheckout(plan.id))}
/>
</div>
<div className={isPro ? 'bg-indigo-950 p-8 text-white' : 'bg-slate-950 p-8 text-white'}>
<p className='mb-5 text-sm font-black uppercase tracking-[0.25em] text-emerald-300'>Included</p>
<div className='grid gap-3'>
{plan.features.map((feature) => (
<div key={feature} className='flex items-start gap-3'>
<span className='mt-1 text-emerald-300'></span>
<span className='font-semibold text-slate-100'>{feature}</span>
</div>
))}
</div>
</div>
</CardBox>
)
})}
</div>
</>
) : null}
</SectionMain>
</>
)
}
SubscriptionPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated portal='customer'>{page}</LayoutAuthenticated>
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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'
@ -181,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={
@ -495,7 +490,6 @@ const UsersNew = () => {
UsersNew.getLayout = function getLayout(page: ReactElement) { UsersNew.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'CREATE_USERS'} permission={'CREATE_USERS'}

View File

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

View File

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

View File

@ -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;

View File

@ -1,81 +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: 'Starter',
priceMonthly: 49,
currency: 'USD',
trialDays,
tagline: 'For small teams that want automated review collection without extra marketing automation.',
ctaLabel: 'Start Starter trial',
limits: {
monthlyReviewRequests: 250,
businesses: 1,
teamMembers: 2,
paymentConnectors: 5,
},
features: [
'Review Flow dashboard',
'Manual review request creation',
'Hosted public review form',
'Customer management',
'Business profile management',
'Transaction tracking',
'Stripe, Square, PayPal, Shopify, and WooCommerce webhook intake',
'Review request status tracking',
'Email delivery logs',
'Basic reporting',
'Standard support',
],
},
{
id: 'pro',
name: 'Pro',
priceMonthly: 99,
currency: 'USD',
trialDays,
tagline: 'For growing businesses that want automation, AI assistance, and reputation marketing tools.',
highlight: 'Best value',
ctaLabel: 'Start Pro trial',
limits: {
monthlyReviewRequests: 2500,
businesses: 10,
teamMembers: 10,
paymentConnectors: 5,
},
features: [
'Everything in Starter',
'Advanced automation rules',
'AI review reply assistant',
'Social proof widgets',
'Review monitoring workspace',
'Referral campaigns',
'Repeat booking reminders',
'NPS surveys',
'Competitor/reputation insights',
'Broadcast campaigns',
'Advanced reporting',
'Branding customization',
'Priority support',
],
},
];