Compare commits

..

4 Commits

Author SHA1 Message Date
Flatlogic Bot
c7ec13b78b Autosave: 20260629-175315 2026-06-29 17:53:10 +00:00
Flatlogic Bot
3dfd47bae8 Autosave: 20260629-071644 2026-06-29 07:16:39 +00:00
Flatlogic Bot
df9c6cb725 Autosave: 20260629-060213 2026-06-29 06:02:08 +00:00
Flatlogic Bot
e4186ae090 Autosave: 20260629-035900 2026-06-29 03:58:56 +00:00
53 changed files with 8874 additions and 296 deletions

View File

@ -36,6 +36,7 @@
"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,6 +65,12 @@ 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,7 +1,5 @@
const db = require('../models'); const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils'); const Utils = require('../utils');
@ -382,15 +380,12 @@ 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]: [
@ -408,6 +403,7 @@ 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]: [
@ -425,6 +421,7 @@ 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,6 +793,13 @@ 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

@ -0,0 +1,112 @@
'use strict';
const businessColumns = {
stripe_webhook_token: { type: 'TEXT' },
square_account_reference: { type: 'TEXT' },
square_connected: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
square_connected_at: { type: 'DATE' },
square_webhook_token: { type: 'TEXT' },
paypal_merchant_reference: { type: 'TEXT' },
paypal_connected: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
paypal_connected_at: { type: 'DATE' },
paypal_webhook_token: { type: 'TEXT' },
};
const customerColumns = {
square_customer_reference: { type: 'TEXT' },
paypal_customer_reference: { type: 'TEXT' },
};
const transactionColumns = {
businessId: { type: 'UUID', references: { model: 'businesses', key: 'id' } },
payment_provider: { type: 'TEXT' },
square_payment_reference: { type: 'TEXT' },
paypal_payment_reference: { type: 'TEXT' },
provider_event_reference: { type: 'TEXT' },
};
const eventColumns = {
provider: { type: 'TEXT' },
provider_event_type: { type: 'TEXT' },
};
function normalizeColumnDefinition(Sequelize, definition) {
const normalized = { ...definition };
if (definition.type === 'TEXT') {
normalized.type = Sequelize.DataTypes.TEXT;
}
if (definition.type === 'BOOLEAN') {
normalized.type = Sequelize.DataTypes.BOOLEAN;
}
if (definition.type === 'DATE') {
normalized.type = Sequelize.DataTypes.DATE;
}
if (definition.type === 'UUID') {
normalized.type = Sequelize.DataTypes.UUID;
}
return normalized;
}
async function addColumnsIfMissing(queryInterface, Sequelize, transaction, tableName, columns) {
const table = await queryInterface.describeTable(tableName);
for (const [columnName, definition] of Object.entries(columns)) {
if (!table[columnName]) {
await queryInterface.addColumn(
tableName,
columnName,
normalizeColumnDefinition(Sequelize, definition),
{ transaction },
);
}
}
}
async function removeColumnsIfPresent(queryInterface, transaction, tableName, columns) {
const table = await queryInterface.describeTable(tableName);
for (const columnName of Object.keys(columns).reverse()) {
if (table[columnName]) {
await queryInterface.removeColumn(tableName, columnName, { transaction });
}
}
}
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'businesses', businessColumns);
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'customers', customerColumns);
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'transactions', transactionColumns);
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'stripe_events', eventColumns);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
async down(queryInterface) {
const transaction = await queryInterface.sequelize.transaction();
try {
await removeColumnsIfPresent(queryInterface, transaction, 'stripe_events', eventColumns);
await removeColumnsIfPresent(queryInterface, transaction, 'transactions', transactionColumns);
await removeColumnsIfPresent(queryInterface, transaction, 'customers', customerColumns);
await removeColumnsIfPresent(queryInterface, transaction, 'businesses', businessColumns);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
};

View File

@ -0,0 +1,97 @@
'use strict';
const businessColumns = {
shopify_store_reference: { type: 'TEXT' },
shopify_connected: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
shopify_connected_at: { type: 'DATE' },
shopify_webhook_token: { type: 'TEXT' },
woocommerce_store_reference: { type: 'TEXT' },
woocommerce_connected: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
woocommerce_connected_at: { type: 'DATE' },
woocommerce_webhook_token: { type: 'TEXT' },
};
const customerColumns = {
shopify_customer_reference: { type: 'TEXT' },
woocommerce_customer_reference: { type: 'TEXT' },
};
const transactionColumns = {
shopify_order_reference: { type: 'TEXT' },
woocommerce_order_reference: { type: 'TEXT' },
};
function normalizeColumnDefinition(Sequelize, definition) {
const normalized = { ...definition };
if (definition.type === 'TEXT') {
normalized.type = Sequelize.DataTypes.TEXT;
}
if (definition.type === 'BOOLEAN') {
normalized.type = Sequelize.DataTypes.BOOLEAN;
}
if (definition.type === 'DATE') {
normalized.type = Sequelize.DataTypes.DATE;
}
return normalized;
}
async function addColumnsIfMissing(queryInterface, Sequelize, transaction, tableName, columns) {
const table = await queryInterface.describeTable(tableName);
for (const [columnName, definition] of Object.entries(columns)) {
if (!table[columnName]) {
await queryInterface.addColumn(
tableName,
columnName,
normalizeColumnDefinition(Sequelize, definition),
{ transaction },
);
}
}
}
async function removeColumnsIfPresent(queryInterface, transaction, tableName, columns) {
const table = await queryInterface.describeTable(tableName);
for (const columnName of Object.keys(columns).reverse()) {
if (table[columnName]) {
await queryInterface.removeColumn(tableName, columnName, { transaction });
}
}
}
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'businesses', businessColumns);
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'customers', customerColumns);
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'transactions', transactionColumns);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
async down(queryInterface) {
const transaction = await queryInterface.sequelize.transaction();
try {
await removeColumnsIfPresent(queryInterface, transaction, 'transactions', transactionColumns);
await removeColumnsIfPresent(queryInterface, transaction, 'customers', customerColumns);
await removeColumnsIfPresent(queryInterface, transaction, 'businesses', businessColumns);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
};

View File

@ -0,0 +1,96 @@
'use strict';
const businessColumns = {
review_destination: { type: 'TEXT' },
trustpilot_review_link: { type: 'TEXT' },
angi_review_link: { type: 'TEXT' },
opentable_review_link: { type: 'TEXT' },
shopify_hosted_reviews_enabled: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
};
const reviewRequestColumns = {
review_platform: { type: 'TEXT' },
review_rating: { type: 'INTEGER' },
review_title: { type: 'TEXT' },
review_content: { type: 'TEXT' },
reviewer_display_name: { type: 'TEXT' },
review_payload_json: { type: 'TEXT' },
submitted_at: { type: 'DATE' },
};
function normalizeColumnDefinition(Sequelize, definition) {
const normalized = { ...definition };
if (definition.type === 'TEXT') {
normalized.type = Sequelize.DataTypes.TEXT;
}
if (definition.type === 'BOOLEAN') {
normalized.type = Sequelize.DataTypes.BOOLEAN;
}
if (definition.type === 'DATE') {
normalized.type = Sequelize.DataTypes.DATE;
}
if (definition.type === 'INTEGER') {
normalized.type = Sequelize.DataTypes.INTEGER;
}
return normalized;
}
async function addColumnsIfMissing(queryInterface, Sequelize, transaction, tableName, columns) {
const table = await queryInterface.describeTable(tableName);
for (const [columnName, definition] of Object.entries(columns)) {
if (!table[columnName]) {
await queryInterface.addColumn(
tableName,
columnName,
normalizeColumnDefinition(Sequelize, definition),
{ transaction },
);
}
}
}
async function removeColumnsIfPresent(queryInterface, transaction, tableName, columns) {
const table = await queryInterface.describeTable(tableName);
for (const columnName of Object.keys(columns).reverse()) {
if (table[columnName]) {
await queryInterface.removeColumn(tableName, columnName, { transaction });
}
}
}
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'businesses', businessColumns);
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'review_requests', reviewRequestColumns);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
async down(queryInterface) {
const transaction = await queryInterface.sequelize.transaction();
try {
await removeColumnsIfPresent(queryInterface, transaction, 'review_requests', reviewRequestColumns);
await removeColumnsIfPresent(queryInterface, transaction, 'businesses', businessColumns);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
};

View File

@ -0,0 +1,87 @@
'use strict';
const userColumns = {
subscriptionPlanId: { type: 'TEXT', allowNull: false, defaultValue: 'starter' },
subscriptionStatus: { type: 'TEXT', allowNull: false, defaultValue: 'trialing' },
trialStartedAt: { type: 'DATE' },
trialEndsAt: { type: 'DATE' },
subscriptionStartedAt: { type: 'DATE' },
subscriptionEndsAt: { type: 'DATE' },
subscriptionCanceledAt: { type: 'DATE' },
};
function normalizeColumnDefinition(Sequelize, definition) {
const normalized = { ...definition };
if (definition.type === 'TEXT') {
normalized.type = Sequelize.DataTypes.TEXT;
}
if (definition.type === 'DATE') {
normalized.type = Sequelize.DataTypes.DATE;
}
return normalized;
}
async function addColumnsIfMissing(queryInterface, Sequelize, transaction, tableName, columns) {
const table = await queryInterface.describeTable(tableName);
for (const [columnName, definition] of Object.entries(columns)) {
if (!table[columnName]) {
await queryInterface.addColumn(
tableName,
columnName,
normalizeColumnDefinition(Sequelize, definition),
{ transaction },
);
}
}
}
async function removeColumnsIfPresent(queryInterface, transaction, tableName, columns) {
const table = await queryInterface.describeTable(tableName);
for (const columnName of Object.keys(columns).reverse()) {
if (table[columnName]) {
await queryInterface.removeColumn(tableName, columnName, { transaction });
}
}
}
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'users', userColumns);
await queryInterface.sequelize.query(
`UPDATE "users"
SET "subscriptionPlanId" = COALESCE("subscriptionPlanId", 'starter'),
"subscriptionStatus" = COALESCE("subscriptionStatus", 'trialing'),
"trialStartedAt" = COALESCE("trialStartedAt", NOW()),
"trialEndsAt" = COALESCE("trialEndsAt", NOW() + INTERVAL '14 days')
WHERE "deletedAt" IS NULL`,
{ transaction },
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
async down(queryInterface) {
const transaction = await queryInterface.sequelize.transaction();
try {
await removeColumnsIfPresent(queryInterface, transaction, 'users', userColumns);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
};

View File

@ -0,0 +1,74 @@
'use strict';
const userColumns = {
stripeCustomerId: { type: 'TEXT' },
stripeSubscriptionId: { type: 'TEXT' },
stripePriceId: { type: 'TEXT' },
stripeCheckoutSessionId: { type: 'TEXT' },
stripeCurrentPeriodEndAt: { type: 'DATE' },
};
function normalizeColumnDefinition(Sequelize, definition) {
const normalized = { ...definition };
if (definition.type === 'TEXT') {
normalized.type = Sequelize.DataTypes.TEXT;
}
if (definition.type === 'DATE') {
normalized.type = Sequelize.DataTypes.DATE;
}
return normalized;
}
async function addColumnsIfMissing(queryInterface, Sequelize, transaction, tableName, columns) {
const table = await queryInterface.describeTable(tableName);
for (const [columnName, definition] of Object.entries(columns)) {
if (!table[columnName]) {
await queryInterface.addColumn(
tableName,
columnName,
normalizeColumnDefinition(Sequelize, definition),
{ transaction },
);
}
}
}
async function removeColumnsIfPresent(queryInterface, transaction, tableName, columns) {
const table = await queryInterface.describeTable(tableName);
for (const columnName of Object.keys(columns).reverse()) {
if (table[columnName]) {
await queryInterface.removeColumn(tableName, columnName, { transaction });
}
}
}
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'users', userColumns);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
async down(queryInterface) {
const transaction = await queryInterface.sequelize.transaction();
try {
await removeColumnsIfPresent(queryInterface, transaction, 'users', userColumns);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,6 @@ const config = require('../../config');
const providers = config.providers; const 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(
@ -104,6 +103,72 @@ provider: {
}, },
subscriptionPlanId: {
type: DataTypes.TEXT,
allowNull: false,
defaultValue: 'starter',
},
subscriptionStatus: {
type: DataTypes.TEXT,
allowNull: false,
defaultValue: 'trialing',
},
trialStartedAt: {
type: DataTypes.DATE,
},
trialEndsAt: {
type: DataTypes.DATE,
},
subscriptionStartedAt: {
type: DataTypes.DATE,
},
subscriptionEndsAt: {
type: DataTypes.DATE,
},
subscriptionCanceledAt: {
type: DataTypes.DATE,
},
stripeCustomerId: {
type: DataTypes.TEXT,
},
stripeSubscriptionId: {
type: DataTypes.TEXT,
},
stripePriceId: {
type: DataTypes.TEXT,
},
stripeCheckoutSessionId: {
type: DataTypes.TEXT,
},
stripeCurrentPeriodEndAt: {
type: DataTypes.DATE,
},
importHash: { importHash: {
type: DataTypes.STRING(255), type: DataTypes.STRING(255),
allowNull: true, allowNull: true,
@ -195,8 +260,8 @@ provider: {
}; };
users.beforeCreate((users, options) => { users.beforeCreate((users) => {
users = trimStringFields(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;
@ -216,8 +281,8 @@ provider: {
} }
}); });
users.beforeUpdate((users, options) => { users.beforeUpdate((users) => {
users = trimStringFields(users); trimStringFields(users);
}); });

View File

@ -6,7 +6,6 @@ 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');
@ -16,6 +15,9 @@ 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');
@ -35,6 +37,10 @@ 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');
@ -91,11 +97,15 @@ 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');
@ -113,6 +123,12 @@ app.use('/api/transactions', passport.authenticate('jwt', {session: false}), tra
app.use('/api/review_requests', passport.authenticate('jwt', {session: false}), review_requestsRoutes); app.use('/api/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

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

View File

@ -0,0 +1,24 @@
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

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

View File

@ -0,0 +1,302 @@
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

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

View File

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

View File

@ -1,4 +1,5 @@
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');
@ -8,6 +9,7 @@ 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) {
@ -54,11 +56,16 @@ 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,6 +6,7 @@ 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');
@ -15,6 +16,8 @@ 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,
{ {
@ -51,6 +54,8 @@ 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,6 +6,7 @@ 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');
@ -15,6 +16,8 @@ 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,
{ {
@ -51,6 +54,8 @@ 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

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

View File

@ -0,0 +1,753 @@
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 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 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 (!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 (!subscription.isActive) {
return {
allowed: false,
code: 403,
message: 'Your Review Flow trial has ended. Choose a plan to keep adding businesses.',
};
}
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,
'businesses/locations',
{ remediation: 'remove an existing business/location 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 (!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 (!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 (!subscription.isActive) {
throw httpError('Your Review Flow trial has ended. Choose a plan to keep using this feature.', 403);
}
if (!subscription.plan.includedFeatureKeys.includes(featureKey)) {
throw httpError(`${subscription.plan.name} does not include this feature. Upgrade to Pro to unlock it.`, 403);
}
return true;
}
};

View File

@ -0,0 +1,113 @@
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/location 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,14 +3,12 @@ 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) {
@ -26,6 +24,7 @@ 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},
@ -79,6 +78,8 @@ 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,
@ -134,7 +135,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

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

View File

@ -1,6 +1,5 @@
import React, {useEffect, useRef} from 'react' import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link' import 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'

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -0,0 +1,135 @@
import { mdiCreditCardOutline } from '@mdi/js'
import axios from 'axios'
import React, { useEffect, useState } from 'react'
import BaseButton from './BaseButton'
import CardBox from './CardBox'
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: 'businesses/locations',
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 [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.')
}
}
}
loadStatus()
return () => {
isMounted = false
}
}, [])
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/location'
: defaultLabels[limitKey])
const percent = limit > 0 ? Math.round((used / limit) * 100) : 0
const isInactive = !status.subscription.isActive
const isBlocked = isInactive || (limit > 0 && used >= limit)
const isNearLimit = !isBlocked && percent >= nearLimitPercent
if (!isBlocked && !isNearLimit) {
return null
}
const cardClass = isBlocked
? 'border-0 bg-rose-50 text-rose-950 ring-1 ring-rose-200 dark:bg-rose-950 dark:text-rose-50 dark:ring-rose-800'
: 'border-0 bg-amber-50 text-amber-950 ring-1 ring-amber-200 dark:bg-amber-950 dark:text-amber-50 dark:ring-amber-800'
const buttonLabel = status.subscription.planId === 'starter' ? 'Upgrade to Pro' : 'Manage plan'
return (
<CardBox className={`${className} ${cardClass}`}>
<div className='flex flex-col gap-4 md:flex-row md:items-center md:justify-between'>
<div>
<p className='text-sm font-black uppercase tracking-[0.25em]'>
{isBlocked ? 'Plan limit reached' : 'Plan limit almost reached'}
</p>
<h3 className='mt-2 text-xl font-black'>
{actionLabel} {isBlocked ? 'may be blocked' : 'is getting close to the limit'}
</h3>
<p className='mt-2 text-sm leading-6'>
{isInactive
? `Your ${status.subscription.planName} plan is ${status.subscription.effectiveStatus}. Reactivate or choose a plan before continuing.`
: `${status.subscription.planName} includes ${formatNumber(limit)} ${limitLabel}. This account is using ${formatNumber(used)}.`}
{' '}Existing data stays available.
</p>
</div>
<BaseButton
href='/subscription'
icon={mdiCreditCardOutline}
label={buttonLabel}
color={isBlocked ? 'danger' : 'warning'}
className='self-start md:self-center'
/>
</div>
</CardBox>
)
}

View File

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

View File

@ -1,5 +1,4 @@
import React, { ReactNode, useEffect } from 'react' import React, { ReactNode, useEffect, useState } 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 menuAside from '../menuAside' import menuAside from '../menuAside'

View File

@ -1,5 +1,5 @@
import * as icon from '@mdi/js'; import * as icon from '@mdi/js';
import { MenuAsideItem } from './interfaces' import { MenuAsideItem } from './interfaces';
const menuAside: MenuAsideItem[] = [ const menuAside: MenuAsideItem[] = [
{ {
@ -8,100 +8,108 @@ const menuAside: MenuAsideItem[] = [
label: 'Dashboard', label: 'Dashboard',
}, },
{
href: '/reviewflow',
icon: icon.mdiStarOutline,
label: 'Review Flow',
},
{
href: '/subscription',
icon: icon.mdiCreditCardOutline,
label: 'Subscription',
},
{ {
href: '/users/users-list', href: '/users/users-list',
label: 'Users', label: 'Users',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
icon: icon.mdiAccountGroup ?? icon.mdiTable, icon: icon.mdiAccountGroup ?? icon.mdiTable,
permissions: 'READ_USERS' permissions: 'READ_USERS',
},
{
href: '/roles/roles-list',
label: 'Roles',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable,
permissions: 'READ_ROLES'
},
{
href: '/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',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
icon: 'mdiStore' in icon ? icon['mdiStore' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, icon:
permissions: 'READ_BUSINESSES' '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',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
icon: 'mdiAccountMultiple' in icon ? icon['mdiAccountMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, icon:
permissions: 'READ_CUSTOMERS' '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',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
icon: 'mdiCreditCardOutline' in icon ? icon['mdiCreditCardOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, icon:
permissions: 'READ_TRANSACTIONS' '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',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
icon: 'mdiEmailFastOutline' in icon ? icon['mdiEmailFastOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, icon:
permissions: 'READ_REVIEW_REQUESTS' 'mdiEmailFastOutline' in icon
? icon['mdiEmailFastOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_REVIEW_REQUESTS',
}, },
{ {
href: '/stripe_events/stripe_events-list', href: '/stripe_events/stripe_events-list',
label: 'Stripe events', label: 'Payment events',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
icon: 'mdiWebhook' in icon ? icon['mdiWebhook' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, icon:
permissions: 'READ_STRIPE_EVENTS' '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 logs', label: 'Email delivery logs',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
icon: 'mdiEmailCheckOutline' in icon ? icon['mdiEmailCheckOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, icon:
permissions: 'READ_EMAIL_DELIVERY_LOGS' 'mdiEmailCheckOutline' in icon
? icon['mdiEmailCheckOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_EMAIL_DELIVERY_LOGS',
}, },
{ {
href: '/cron_runs/cron_runs-list', href: '/cron_runs/cron_runs-list',
label: 'Cron runs', label: 'Cron runs',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
icon: 'mdiClockOutline' in icon ? icon['mdiClockOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, icon:
permissions: 'READ_CRON_RUNS' '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 default menuAside;
{
href: '/api-docs',
target: '_blank',
label: 'Swagger API',
icon: icon.mdiFileCode,
permissions: 'READ_API_DOCS'
},
]
export default menuAside

View File

@ -2,6 +2,7 @@ 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'
@ -277,6 +278,10 @@ const BusinessesNew = () => {
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main> <SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<SubscriptionLimitGate
limitKey='businesses'
actionLabel='Adding another business/location'
/>
<CardBox> <CardBox>
<Formik <Formik
initialValues={ initialValues={

View File

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

View File

@ -1,161 +1,260 @@
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 { useAppSelector } from '../stores/hooks'; import { subscriptionPlans, trialDays } from '../subscriptionPlans';
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',
'Admin CRUD and API docs still available',
];
export default function Starter() { export default function Starter() {
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('video');
const [contentPosition, setContentPosition] = useState('left');
const textColor = useAppSelector((state) => state.style.linkColor);
const title = 'ReviewFlow'
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return ( return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'> <div className="min-h-screen bg-[#F7F8FC] text-slate-950">
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<Head> <Head>
<title>{getPageTitle('Starter Page')}</title> <title>{getPageTitle('Review Flow')}</title>
<meta
name="description"
content="Review Flow helps businesses queue and track review requests after customer purchases or visits."
/>
</Head> </Head>
<SectionFullScreen bg='violet'> <header className="sticky top-0 z-20 border-b border-white/70 bg-white/80 backdrop-blur-xl">
<div <div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
className={`flex ${ <Link href="/" className="flex items-center gap-3 font-black tracking-tight">
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row' <span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#101828] text-white shadow-lg shadow-indigo-950/20">
} min-h-screen w-full`}
> </span>
{contentType === 'image' && contentPosition !== 'background' <span className="text-xl">Review Flow</span>
? imageBlock(illustrationImage) </Link>
: null} <nav className="flex items-center gap-3">
{contentType === 'video' && contentPosition !== 'background' <BaseButton href="/#pricing" label="Pricing" color="whiteDark" />
? videoBlock(illustrationVideo) <BaseButton href="/login" icon={mdiLogin} label="Login" color="whiteDark" />
: null} <BaseButton href="/reviewflow" icon={mdiArrowRight} label="Admin interface" color="info" />
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'> </nav>
<CardBox className='w-full md:w-3/5 lg:w-2/3'> </div>
<CardBoxComponentTitle title="Welcome to your ReviewFlow app!"/> </header>
<div className="space-y-3"> <main>
<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> <section className="relative isolate overflow-hidden px-6 py-20 md:py-28">
<p className='text-center text-gray-500'>For guides and documentation please check <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" />
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p> <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="Login to admin" 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> </div>
<BaseButtons> <CardBox className="border-0 bg-white/90 shadow-2xl shadow-indigo-950/10 ring-1 ring-slate-200/70" cardBoxClassName="p-0">
<BaseButton <div className="rounded-t-3xl bg-[#101828] p-5 text-white">
href='/login' <div className="flex items-center justify-between">
label='Login' <div>
color='info' <p className="text-sm font-bold uppercase tracking-[0.25em] text-emerald-300">Live workflow</p>
className='w-full' <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">
</BaseButtons> 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> </CardBox>
</div> </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> </div>
</SectionFullScreen> <h3 className="text-2xl font-black">{title}</h3>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'> <p className="mt-3 leading-7 text-slate-600">{copy}</p>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p> </div>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'> ))}
Privacy Policy </div>
</Link> </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>
<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">businesses/locations</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 admin workspace lets a user connect payment webhooks, receive events, create transactions and customers, queue review requests, browse recent activity, and inspect the generated message.
</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>
); );
} }
@ -163,4 +262,3 @@ 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, getPexelsVideo } from '../helpers/pexels' import { getPexelsImage } from '../helpers/pexels'
export default function Login() { export default function Login() {
const router = useRouter(); const router = useRouter();
@ -33,9 +33,7 @@ export default function Login() {
photographer: undefined, photographer: undefined,
photographer_url: undefined, photographer_url: undefined,
}) })
const [ illustrationVideo, setIllustrationVideo ] = useState({video_files: []}) const [contentPosition] = useState<'left' | 'right' | 'background'>('left');
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,
@ -46,13 +44,88 @@ export default function Login() {
const title = 'Review Flow' const title = 'Review Flow'
// Fetch Pexels image/video const appHighlights = [
'Automated review requests after payments, jobs, or service milestones.',
'Customer, business, transaction, and delivery follow-up data in one admin workspace.',
'Dashboards, CRM records, payment events, email logs, and 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 or location.',
'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 businesses or locations.',
'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();
}, []); }, []);
@ -115,32 +188,7 @@ 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' ? {
@ -159,8 +207,7 @@ 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`}>
{contentType === 'image' && contentPosition !== 'background' ? imageBlock(illustrationImage) : null} {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'>
@ -257,6 +304,95 @@ 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
admin panel.
</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

@ -12,12 +12,15 @@ 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"});
@ -25,7 +28,7 @@ export default function Register() {
setLoading(true) setLoading(true)
try { try {
const { data: response } = await axios.post('/auth/signup',value); const { data: response } = await axios.post('/auth/signup',{ ...value, planId: selectedPlan.id });
await router.push('/login') 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')
@ -44,6 +47,10 @@ 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

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

View File

@ -0,0 +1,900 @@
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';
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 {businessesRemaining.toLocaleString()} business slots 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 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>{page}</LayoutAuthenticated>;
};

View File

@ -0,0 +1,409 @@
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'
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: 'Businesses / locations' },
{ 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}>
{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'>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>{page}</LayoutAuthenticated>
}

View File

@ -2,6 +2,7 @@ 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'
@ -180,6 +181,10 @@ 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={

View File

@ -0,0 +1,81 @@
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/location 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',
],
},
];