Compare commits
No commits in common. "ai-dev" and "master" have entirely different histories.
@ -36,7 +36,6 @@
|
|||||||
"sequelize": "6.35.2",
|
"sequelize": "6.35.2",
|
||||||
"sequelize-json-schema": "^2.1.1",
|
"sequelize-json-schema": "^2.1.1",
|
||||||
"sqlite": "4.0.15",
|
"sqlite": "4.0.15",
|
||||||
"stripe": "^22.3.0",
|
|
||||||
"swagger-jsdoc": "^6.2.8",
|
"swagger-jsdoc": "^6.2.8",
|
||||||
"swagger-ui-express": "^5.0.0",
|
"swagger-ui-express": "^5.0.0",
|
||||||
"tedious": "^18.2.4"
|
"tedious": "^18.2.4"
|
||||||
|
|||||||
@ -65,12 +65,6 @@ const config = {
|
|||||||
|
|
||||||
|
|
||||||
gpt_key: process.env.GPT_KEY || '',
|
gpt_key: process.env.GPT_KEY || '',
|
||||||
stripe: {
|
|
||||||
secretKey: process.env.STRIPE_SECRET_KEY || '',
|
|
||||||
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
|
|
||||||
starterPriceId: process.env.STRIPE_STARTER_PRICE_ID || '',
|
|
||||||
proPriceId: process.env.STRIPE_PRO_PRICE_ID || '',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
config.pexelsKey = process.env.PEXELS_KEY || '';
|
config.pexelsKey = process.env.PEXELS_KEY || '';
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
|
const FileDBApi = require('./file');
|
||||||
|
const crypto = require('crypto');
|
||||||
const Utils = require('../utils');
|
const Utils = require('../utils');
|
||||||
|
|
||||||
|
|
||||||
@ -380,12 +382,15 @@ module.exports = class Review_requestsDBApi {
|
|||||||
|
|
||||||
offset = currentPage * limit;
|
offset = currentPage * limit;
|
||||||
|
|
||||||
|
const orderBy = null;
|
||||||
|
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
let include = [
|
let include = [
|
||||||
|
|
||||||
{
|
{
|
||||||
model: db.businesses,
|
model: db.businesses,
|
||||||
as: 'business',
|
as: 'business',
|
||||||
required: Boolean(filter.business),
|
|
||||||
|
|
||||||
where: filter.business ? {
|
where: filter.business ? {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
@ -403,7 +408,6 @@ module.exports = class Review_requestsDBApi {
|
|||||||
{
|
{
|
||||||
model: db.customers,
|
model: db.customers,
|
||||||
as: 'customer',
|
as: 'customer',
|
||||||
required: Boolean(filter.customer),
|
|
||||||
|
|
||||||
where: filter.customer ? {
|
where: filter.customer ? {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
@ -421,7 +425,6 @@ module.exports = class Review_requestsDBApi {
|
|||||||
{
|
{
|
||||||
model: db.transactions,
|
model: db.transactions,
|
||||||
as: 'transaction',
|
as: 'transaction',
|
||||||
required: Boolean(filter.transaction),
|
|
||||||
|
|
||||||
where: filter.transaction ? {
|
where: filter.transaction ? {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
|
|||||||
@ -793,13 +793,6 @@ module.exports = class UsersDBApi {
|
|||||||
firstName: data.firstName,
|
firstName: data.firstName,
|
||||||
authenticationUid: data.authenticationUid,
|
authenticationUid: data.authenticationUid,
|
||||||
password: data.password,
|
password: data.password,
|
||||||
subscriptionPlanId: data.subscriptionPlanId || 'starter',
|
|
||||||
subscriptionStatus: data.subscriptionStatus || 'trialing',
|
|
||||||
trialStartedAt: data.trialStartedAt || null,
|
|
||||||
trialEndsAt: data.trialEndsAt || null,
|
|
||||||
subscriptionStartedAt: data.subscriptionStartedAt || null,
|
|
||||||
subscriptionEndsAt: data.subscriptionEndsAt || null,
|
|
||||||
subscriptionCanceledAt: data.subscriptionCanceledAt || null,
|
|
||||||
|
|
||||||
},
|
},
|
||||||
{ transaction },
|
{ transaction },
|
||||||
|
|||||||
@ -1,112 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const businessColumns = {
|
|
||||||
stripe_webhook_token: { type: 'TEXT' },
|
|
||||||
square_account_reference: { type: 'TEXT' },
|
|
||||||
square_connected: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
|
|
||||||
square_connected_at: { type: 'DATE' },
|
|
||||||
square_webhook_token: { type: 'TEXT' },
|
|
||||||
paypal_merchant_reference: { type: 'TEXT' },
|
|
||||||
paypal_connected: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
|
|
||||||
paypal_connected_at: { type: 'DATE' },
|
|
||||||
paypal_webhook_token: { type: 'TEXT' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const customerColumns = {
|
|
||||||
square_customer_reference: { type: 'TEXT' },
|
|
||||||
paypal_customer_reference: { type: 'TEXT' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const transactionColumns = {
|
|
||||||
businessId: { type: 'UUID', references: { model: 'businesses', key: 'id' } },
|
|
||||||
payment_provider: { type: 'TEXT' },
|
|
||||||
square_payment_reference: { type: 'TEXT' },
|
|
||||||
paypal_payment_reference: { type: 'TEXT' },
|
|
||||||
provider_event_reference: { type: 'TEXT' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const eventColumns = {
|
|
||||||
provider: { type: 'TEXT' },
|
|
||||||
provider_event_type: { type: 'TEXT' },
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeColumnDefinition(Sequelize, definition) {
|
|
||||||
const normalized = { ...definition };
|
|
||||||
|
|
||||||
if (definition.type === 'TEXT') {
|
|
||||||
normalized.type = Sequelize.DataTypes.TEXT;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (definition.type === 'BOOLEAN') {
|
|
||||||
normalized.type = Sequelize.DataTypes.BOOLEAN;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (definition.type === 'DATE') {
|
|
||||||
normalized.type = Sequelize.DataTypes.DATE;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (definition.type === 'UUID') {
|
|
||||||
normalized.type = Sequelize.DataTypes.UUID;
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addColumnsIfMissing(queryInterface, Sequelize, transaction, tableName, columns) {
|
|
||||||
const table = await queryInterface.describeTable(tableName);
|
|
||||||
|
|
||||||
for (const [columnName, definition] of Object.entries(columns)) {
|
|
||||||
if (!table[columnName]) {
|
|
||||||
await queryInterface.addColumn(
|
|
||||||
tableName,
|
|
||||||
columnName,
|
|
||||||
normalizeColumnDefinition(Sequelize, definition),
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeColumnsIfPresent(queryInterface, transaction, tableName, columns) {
|
|
||||||
const table = await queryInterface.describeTable(tableName);
|
|
||||||
|
|
||||||
for (const columnName of Object.keys(columns).reverse()) {
|
|
||||||
if (table[columnName]) {
|
|
||||||
await queryInterface.removeColumn(tableName, columnName, { transaction });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
async up(queryInterface, Sequelize) {
|
|
||||||
const transaction = await queryInterface.sequelize.transaction();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'businesses', businessColumns);
|
|
||||||
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'customers', customerColumns);
|
|
||||||
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'transactions', transactionColumns);
|
|
||||||
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'stripe_events', eventColumns);
|
|
||||||
|
|
||||||
await transaction.commit();
|
|
||||||
} catch (error) {
|
|
||||||
await transaction.rollback();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async down(queryInterface) {
|
|
||||||
const transaction = await queryInterface.sequelize.transaction();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await removeColumnsIfPresent(queryInterface, transaction, 'stripe_events', eventColumns);
|
|
||||||
await removeColumnsIfPresent(queryInterface, transaction, 'transactions', transactionColumns);
|
|
||||||
await removeColumnsIfPresent(queryInterface, transaction, 'customers', customerColumns);
|
|
||||||
await removeColumnsIfPresent(queryInterface, transaction, 'businesses', businessColumns);
|
|
||||||
|
|
||||||
await transaction.commit();
|
|
||||||
} catch (error) {
|
|
||||||
await transaction.rollback();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const businessColumns = {
|
|
||||||
shopify_store_reference: { type: 'TEXT' },
|
|
||||||
shopify_connected: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
|
|
||||||
shopify_connected_at: { type: 'DATE' },
|
|
||||||
shopify_webhook_token: { type: 'TEXT' },
|
|
||||||
woocommerce_store_reference: { type: 'TEXT' },
|
|
||||||
woocommerce_connected: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
|
|
||||||
woocommerce_connected_at: { type: 'DATE' },
|
|
||||||
woocommerce_webhook_token: { type: 'TEXT' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const customerColumns = {
|
|
||||||
shopify_customer_reference: { type: 'TEXT' },
|
|
||||||
woocommerce_customer_reference: { type: 'TEXT' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const transactionColumns = {
|
|
||||||
shopify_order_reference: { type: 'TEXT' },
|
|
||||||
woocommerce_order_reference: { type: 'TEXT' },
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeColumnDefinition(Sequelize, definition) {
|
|
||||||
const normalized = { ...definition };
|
|
||||||
|
|
||||||
if (definition.type === 'TEXT') {
|
|
||||||
normalized.type = Sequelize.DataTypes.TEXT;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (definition.type === 'BOOLEAN') {
|
|
||||||
normalized.type = Sequelize.DataTypes.BOOLEAN;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (definition.type === 'DATE') {
|
|
||||||
normalized.type = Sequelize.DataTypes.DATE;
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addColumnsIfMissing(queryInterface, Sequelize, transaction, tableName, columns) {
|
|
||||||
const table = await queryInterface.describeTable(tableName);
|
|
||||||
|
|
||||||
for (const [columnName, definition] of Object.entries(columns)) {
|
|
||||||
if (!table[columnName]) {
|
|
||||||
await queryInterface.addColumn(
|
|
||||||
tableName,
|
|
||||||
columnName,
|
|
||||||
normalizeColumnDefinition(Sequelize, definition),
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeColumnsIfPresent(queryInterface, transaction, tableName, columns) {
|
|
||||||
const table = await queryInterface.describeTable(tableName);
|
|
||||||
|
|
||||||
for (const columnName of Object.keys(columns).reverse()) {
|
|
||||||
if (table[columnName]) {
|
|
||||||
await queryInterface.removeColumn(tableName, columnName, { transaction });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
async up(queryInterface, Sequelize) {
|
|
||||||
const transaction = await queryInterface.sequelize.transaction();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'businesses', businessColumns);
|
|
||||||
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'customers', customerColumns);
|
|
||||||
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'transactions', transactionColumns);
|
|
||||||
|
|
||||||
await transaction.commit();
|
|
||||||
} catch (error) {
|
|
||||||
await transaction.rollback();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async down(queryInterface) {
|
|
||||||
const transaction = await queryInterface.sequelize.transaction();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await removeColumnsIfPresent(queryInterface, transaction, 'transactions', transactionColumns);
|
|
||||||
await removeColumnsIfPresent(queryInterface, transaction, 'customers', customerColumns);
|
|
||||||
await removeColumnsIfPresent(queryInterface, transaction, 'businesses', businessColumns);
|
|
||||||
|
|
||||||
await transaction.commit();
|
|
||||||
} catch (error) {
|
|
||||||
await transaction.rollback();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const businessColumns = {
|
|
||||||
review_destination: { type: 'TEXT' },
|
|
||||||
trustpilot_review_link: { type: 'TEXT' },
|
|
||||||
angi_review_link: { type: 'TEXT' },
|
|
||||||
opentable_review_link: { type: 'TEXT' },
|
|
||||||
shopify_hosted_reviews_enabled: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
|
|
||||||
};
|
|
||||||
|
|
||||||
const reviewRequestColumns = {
|
|
||||||
review_platform: { type: 'TEXT' },
|
|
||||||
review_rating: { type: 'INTEGER' },
|
|
||||||
review_title: { type: 'TEXT' },
|
|
||||||
review_content: { type: 'TEXT' },
|
|
||||||
reviewer_display_name: { type: 'TEXT' },
|
|
||||||
review_payload_json: { type: 'TEXT' },
|
|
||||||
submitted_at: { type: 'DATE' },
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeColumnDefinition(Sequelize, definition) {
|
|
||||||
const normalized = { ...definition };
|
|
||||||
|
|
||||||
if (definition.type === 'TEXT') {
|
|
||||||
normalized.type = Sequelize.DataTypes.TEXT;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (definition.type === 'BOOLEAN') {
|
|
||||||
normalized.type = Sequelize.DataTypes.BOOLEAN;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (definition.type === 'DATE') {
|
|
||||||
normalized.type = Sequelize.DataTypes.DATE;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (definition.type === 'INTEGER') {
|
|
||||||
normalized.type = Sequelize.DataTypes.INTEGER;
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addColumnsIfMissing(queryInterface, Sequelize, transaction, tableName, columns) {
|
|
||||||
const table = await queryInterface.describeTable(tableName);
|
|
||||||
|
|
||||||
for (const [columnName, definition] of Object.entries(columns)) {
|
|
||||||
if (!table[columnName]) {
|
|
||||||
await queryInterface.addColumn(
|
|
||||||
tableName,
|
|
||||||
columnName,
|
|
||||||
normalizeColumnDefinition(Sequelize, definition),
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeColumnsIfPresent(queryInterface, transaction, tableName, columns) {
|
|
||||||
const table = await queryInterface.describeTable(tableName);
|
|
||||||
|
|
||||||
for (const columnName of Object.keys(columns).reverse()) {
|
|
||||||
if (table[columnName]) {
|
|
||||||
await queryInterface.removeColumn(tableName, columnName, { transaction });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
async up(queryInterface, Sequelize) {
|
|
||||||
const transaction = await queryInterface.sequelize.transaction();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'businesses', businessColumns);
|
|
||||||
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'review_requests', reviewRequestColumns);
|
|
||||||
|
|
||||||
await transaction.commit();
|
|
||||||
} catch (error) {
|
|
||||||
await transaction.rollback();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async down(queryInterface) {
|
|
||||||
const transaction = await queryInterface.sequelize.transaction();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await removeColumnsIfPresent(queryInterface, transaction, 'review_requests', reviewRequestColumns);
|
|
||||||
await removeColumnsIfPresent(queryInterface, transaction, 'businesses', businessColumns);
|
|
||||||
|
|
||||||
await transaction.commit();
|
|
||||||
} catch (error) {
|
|
||||||
await transaction.rollback();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const userColumns = {
|
|
||||||
subscriptionPlanId: { type: 'TEXT', allowNull: false, defaultValue: 'starter' },
|
|
||||||
subscriptionStatus: { type: 'TEXT', allowNull: false, defaultValue: 'trialing' },
|
|
||||||
trialStartedAt: { type: 'DATE' },
|
|
||||||
trialEndsAt: { type: 'DATE' },
|
|
||||||
subscriptionStartedAt: { type: 'DATE' },
|
|
||||||
subscriptionEndsAt: { type: 'DATE' },
|
|
||||||
subscriptionCanceledAt: { type: 'DATE' },
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeColumnDefinition(Sequelize, definition) {
|
|
||||||
const normalized = { ...definition };
|
|
||||||
|
|
||||||
if (definition.type === 'TEXT') {
|
|
||||||
normalized.type = Sequelize.DataTypes.TEXT;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (definition.type === 'DATE') {
|
|
||||||
normalized.type = Sequelize.DataTypes.DATE;
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addColumnsIfMissing(queryInterface, Sequelize, transaction, tableName, columns) {
|
|
||||||
const table = await queryInterface.describeTable(tableName);
|
|
||||||
|
|
||||||
for (const [columnName, definition] of Object.entries(columns)) {
|
|
||||||
if (!table[columnName]) {
|
|
||||||
await queryInterface.addColumn(
|
|
||||||
tableName,
|
|
||||||
columnName,
|
|
||||||
normalizeColumnDefinition(Sequelize, definition),
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeColumnsIfPresent(queryInterface, transaction, tableName, columns) {
|
|
||||||
const table = await queryInterface.describeTable(tableName);
|
|
||||||
|
|
||||||
for (const columnName of Object.keys(columns).reverse()) {
|
|
||||||
if (table[columnName]) {
|
|
||||||
await queryInterface.removeColumn(tableName, columnName, { transaction });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
async up(queryInterface, Sequelize) {
|
|
||||||
const transaction = await queryInterface.sequelize.transaction();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'users', userColumns);
|
|
||||||
|
|
||||||
await queryInterface.sequelize.query(
|
|
||||||
`UPDATE "users"
|
|
||||||
SET "subscriptionPlanId" = COALESCE("subscriptionPlanId", 'starter'),
|
|
||||||
"subscriptionStatus" = COALESCE("subscriptionStatus", 'trialing'),
|
|
||||||
"trialStartedAt" = COALESCE("trialStartedAt", NOW()),
|
|
||||||
"trialEndsAt" = COALESCE("trialEndsAt", NOW() + INTERVAL '14 days')
|
|
||||||
WHERE "deletedAt" IS NULL`,
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
|
|
||||||
await transaction.commit();
|
|
||||||
} catch (error) {
|
|
||||||
await transaction.rollback();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async down(queryInterface) {
|
|
||||||
const transaction = await queryInterface.sequelize.transaction();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await removeColumnsIfPresent(queryInterface, transaction, 'users', userColumns);
|
|
||||||
await transaction.commit();
|
|
||||||
} catch (error) {
|
|
||||||
await transaction.rollback();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const userColumns = {
|
|
||||||
stripeCustomerId: { type: 'TEXT' },
|
|
||||||
stripeSubscriptionId: { type: 'TEXT' },
|
|
||||||
stripePriceId: { type: 'TEXT' },
|
|
||||||
stripeCheckoutSessionId: { type: 'TEXT' },
|
|
||||||
stripeCurrentPeriodEndAt: { type: 'DATE' },
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeColumnDefinition(Sequelize, definition) {
|
|
||||||
const normalized = { ...definition };
|
|
||||||
|
|
||||||
if (definition.type === 'TEXT') {
|
|
||||||
normalized.type = Sequelize.DataTypes.TEXT;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (definition.type === 'DATE') {
|
|
||||||
normalized.type = Sequelize.DataTypes.DATE;
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addColumnsIfMissing(queryInterface, Sequelize, transaction, tableName, columns) {
|
|
||||||
const table = await queryInterface.describeTable(tableName);
|
|
||||||
|
|
||||||
for (const [columnName, definition] of Object.entries(columns)) {
|
|
||||||
if (!table[columnName]) {
|
|
||||||
await queryInterface.addColumn(
|
|
||||||
tableName,
|
|
||||||
columnName,
|
|
||||||
normalizeColumnDefinition(Sequelize, definition),
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeColumnsIfPresent(queryInterface, transaction, tableName, columns) {
|
|
||||||
const table = await queryInterface.describeTable(tableName);
|
|
||||||
|
|
||||||
for (const columnName of Object.keys(columns).reverse()) {
|
|
||||||
if (table[columnName]) {
|
|
||||||
await queryInterface.removeColumn(tableName, columnName, { transaction });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
async up(queryInterface, Sequelize) {
|
|
||||||
const transaction = await queryInterface.sequelize.transaction();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'users', userColumns);
|
|
||||||
await transaction.commit();
|
|
||||||
} catch (error) {
|
|
||||||
await transaction.rollback();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async down(queryInterface) {
|
|
||||||
const transaction = await queryInterface.sequelize.transaction();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await removeColumnsIfPresent(queryInterface, transaction, 'users', userColumns);
|
|
||||||
await transaction.commit();
|
|
||||||
} catch (error) {
|
|
||||||
await transaction.rollback();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,3 +1,9 @@
|
|||||||
|
const config = require('../../config');
|
||||||
|
const providers = config.providers;
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const moment = require('moment');
|
||||||
|
|
||||||
module.exports = function(sequelize, DataTypes) {
|
module.exports = function(sequelize, DataTypes) {
|
||||||
const businesses = sequelize.define(
|
const businesses = sequelize.define(
|
||||||
'businesses',
|
'businesses',
|
||||||
@ -89,138 +95,6 @@ stripe_connected_at: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
stripe_webhook_token: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
square_account_reference: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
square_connected: {
|
|
||||||
type: DataTypes.BOOLEAN,
|
|
||||||
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: false,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
square_connected_at: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
square_webhook_token: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
paypal_merchant_reference: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
paypal_connected: {
|
|
||||||
type: DataTypes.BOOLEAN,
|
|
||||||
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: false,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
paypal_connected_at: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
paypal_webhook_token: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
|
|
||||||
shopify_store_reference: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
shopify_connected: {
|
|
||||||
type: DataTypes.BOOLEAN,
|
|
||||||
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: false,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
shopify_connected_at: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
shopify_webhook_token: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
woocommerce_store_reference: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
woocommerce_connected: {
|
|
||||||
type: DataTypes.BOOLEAN,
|
|
||||||
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: false,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
woocommerce_connected_at: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
woocommerce_webhook_token: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
default_review_platform: {
|
default_review_platform: {
|
||||||
@ -250,44 +124,6 @@ custom_review_link: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
review_destination: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
trustpilot_review_link: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
angi_review_link: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
opentable_review_link: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
shopify_hosted_reviews_enabled: {
|
|
||||||
type: DataTypes.BOOLEAN,
|
|
||||||
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: false,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
importHash: {
|
importHash: {
|
||||||
@ -340,14 +176,6 @@ custom_review_link: {
|
|||||||
constraints: false,
|
constraints: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
db.businesses.hasMany(db.transactions, {
|
|
||||||
as: 'transactions_business',
|
|
||||||
foreignKey: {
|
|
||||||
name: 'businessId',
|
|
||||||
},
|
|
||||||
constraints: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,9 @@
|
|||||||
|
const config = require('../../config');
|
||||||
|
const providers = config.providers;
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const moment = require('moment');
|
||||||
|
|
||||||
module.exports = function(sequelize, DataTypes) {
|
module.exports = function(sequelize, DataTypes) {
|
||||||
const customers = sequelize.define(
|
const customers = sequelize.define(
|
||||||
'customers',
|
'customers',
|
||||||
@ -34,35 +40,6 @@ stripe_customer_reference: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
square_customer_reference: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
paypal_customer_reference: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
|
|
||||||
shopify_customer_reference: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
woocommerce_customer_reference: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
contact_status: {
|
contact_status: {
|
||||||
|
|||||||
@ -1,3 +1,9 @@
|
|||||||
|
const config = require('../../config');
|
||||||
|
const providers = config.providers;
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const moment = require('moment');
|
||||||
|
|
||||||
module.exports = function(sequelize, DataTypes) {
|
module.exports = function(sequelize, DataTypes) {
|
||||||
const review_requests = sequelize.define(
|
const review_requests = sequelize.define(
|
||||||
'review_requests',
|
'review_requests',
|
||||||
@ -107,55 +113,6 @@ tracking_token: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
review_platform: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
review_rating: {
|
|
||||||
type: DataTypes.INTEGER,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
review_title: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
review_content: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
reviewer_display_name: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
review_payload_json: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
submitted_at: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
importHash: {
|
importHash: {
|
||||||
|
|||||||
@ -1,3 +1,9 @@
|
|||||||
|
const config = require('../../config');
|
||||||
|
const providers = config.providers;
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const moment = require('moment');
|
||||||
|
|
||||||
module.exports = function(sequelize, DataTypes) {
|
module.exports = function(sequelize, DataTypes) {
|
||||||
const stripe_events = sequelize.define(
|
const stripe_events = sequelize.define(
|
||||||
'stripe_events',
|
'stripe_events',
|
||||||
@ -13,20 +19,6 @@ stripe_event_reference: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
provider: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
provider_event_type: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
event_type: {
|
event_type: {
|
||||||
|
|||||||
@ -1,3 +1,9 @@
|
|||||||
|
const config = require('../../config');
|
||||||
|
const providers = config.providers;
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const moment = require('moment');
|
||||||
|
|
||||||
module.exports = function(sequelize, DataTypes) {
|
module.exports = function(sequelize, DataTypes) {
|
||||||
const transactions = sequelize.define(
|
const transactions = sequelize.define(
|
||||||
'transactions',
|
'transactions',
|
||||||
@ -13,49 +19,6 @@ stripe_payment_reference: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
payment_provider: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
square_payment_reference: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
paypal_payment_reference: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
|
|
||||||
shopify_order_reference: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
woocommerce_order_reference: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
provider_event_reference: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
amount: {
|
amount: {
|
||||||
@ -168,14 +131,6 @@ receipt_email: {
|
|||||||
constraints: false,
|
constraints: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
db.transactions.belongsTo(db.businesses, {
|
|
||||||
as: 'business',
|
|
||||||
foreignKey: {
|
|
||||||
name: 'businessId',
|
|
||||||
},
|
|
||||||
constraints: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ const config = require('../../config');
|
|||||||
const providers = config.providers;
|
const providers = config.providers;
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
|
const moment = require('moment');
|
||||||
|
|
||||||
module.exports = function(sequelize, DataTypes) {
|
module.exports = function(sequelize, DataTypes) {
|
||||||
const users = sequelize.define(
|
const users = sequelize.define(
|
||||||
@ -103,72 +104,6 @@ provider: {
|
|||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
subscriptionPlanId: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: 'starter',
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
subscriptionStatus: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: 'trialing',
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
trialStartedAt: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
trialEndsAt: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
subscriptionStartedAt: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
subscriptionEndsAt: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
subscriptionCanceledAt: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
stripeCustomerId: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
stripeSubscriptionId: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
stripePriceId: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
stripeCheckoutSessionId: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
stripeCurrentPeriodEndAt: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
importHash: {
|
importHash: {
|
||||||
type: DataTypes.STRING(255),
|
type: DataTypes.STRING(255),
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
@ -260,8 +195,8 @@ stripeCurrentPeriodEndAt: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
users.beforeCreate((users) => {
|
users.beforeCreate((users, options) => {
|
||||||
trimStringFields(users);
|
users = trimStringFields(users);
|
||||||
|
|
||||||
if (users.provider !== providers.LOCAL && Object.values(providers).indexOf(users.provider) > -1) {
|
if (users.provider !== providers.LOCAL && Object.values(providers).indexOf(users.provider) > -1) {
|
||||||
users.emailVerified = true;
|
users.emailVerified = true;
|
||||||
@ -281,8 +216,8 @@ stripeCurrentPeriodEndAt: {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
users.beforeUpdate((users) => {
|
users.beforeUpdate((users, options) => {
|
||||||
trimStringFields(users);
|
users = trimStringFields(users);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ const passport = require('passport');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const bodyParser = require('body-parser');
|
const bodyParser = require('body-parser');
|
||||||
|
const db = require('./db/models');
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
const swaggerUI = require('swagger-ui-express');
|
const swaggerUI = require('swagger-ui-express');
|
||||||
const swaggerJsDoc = require('swagger-jsdoc');
|
const swaggerJsDoc = require('swagger-jsdoc');
|
||||||
@ -15,9 +16,6 @@ const fileRoutes = require('./routes/file');
|
|||||||
const searchRoutes = require('./routes/search');
|
const searchRoutes = require('./routes/search');
|
||||||
const sqlRoutes = require('./routes/sql');
|
const sqlRoutes = require('./routes/sql');
|
||||||
const pexelsRoutes = require('./routes/pexels');
|
const pexelsRoutes = require('./routes/pexels');
|
||||||
const plansRoutes = require('./routes/plans');
|
|
||||||
const subscriptionRoutes = require('./routes/subscription');
|
|
||||||
const subscriptionWebhookRoutes = require('./routes/subscription-webhooks');
|
|
||||||
|
|
||||||
const openaiRoutes = require('./routes/openai');
|
const openaiRoutes = require('./routes/openai');
|
||||||
|
|
||||||
@ -37,10 +35,6 @@ const transactionsRoutes = require('./routes/transactions');
|
|||||||
|
|
||||||
const review_requestsRoutes = require('./routes/review_requests');
|
const review_requestsRoutes = require('./routes/review_requests');
|
||||||
|
|
||||||
const reviewflowRoutes = require('./routes/reviewflow');
|
|
||||||
const reviewflowWebhooksRoutes = require('./routes/reviewflow-webhooks');
|
|
||||||
const reviewflowPublicRoutes = require('./routes/reviewflow-public');
|
|
||||||
|
|
||||||
const stripe_eventsRoutes = require('./routes/stripe_events');
|
const stripe_eventsRoutes = require('./routes/stripe_events');
|
||||||
|
|
||||||
const email_delivery_logsRoutes = require('./routes/email_delivery_logs');
|
const email_delivery_logsRoutes = require('./routes/email_delivery_logs');
|
||||||
@ -97,15 +91,11 @@ app.use('/api-docs', function (req, res, next) {
|
|||||||
app.use(cors({origin: true}));
|
app.use(cors({origin: true}));
|
||||||
require('./auth/auth');
|
require('./auth/auth');
|
||||||
|
|
||||||
app.use('/api/subscription/stripe-webhook', bodyParser.raw({type: 'application/json'}), subscriptionWebhookRoutes);
|
|
||||||
|
|
||||||
app.use(bodyParser.json());
|
app.use(bodyParser.json());
|
||||||
|
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/file', fileRoutes);
|
app.use('/api/file', fileRoutes);
|
||||||
app.use('/api/pexels', pexelsRoutes);
|
app.use('/api/pexels', pexelsRoutes);
|
||||||
app.use('/api/plans', plansRoutes);
|
|
||||||
app.use('/api/subscription', passport.authenticate('jwt', {session: false}), subscriptionRoutes);
|
|
||||||
app.enable('trust proxy');
|
app.enable('trust proxy');
|
||||||
|
|
||||||
|
|
||||||
@ -123,12 +113,6 @@ app.use('/api/transactions', passport.authenticate('jwt', {session: false}), tra
|
|||||||
|
|
||||||
app.use('/api/review_requests', passport.authenticate('jwt', {session: false}), review_requestsRoutes);
|
app.use('/api/review_requests', passport.authenticate('jwt', {session: false}), review_requestsRoutes);
|
||||||
|
|
||||||
app.use('/api/reviewflow', passport.authenticate('jwt', {session: false}), reviewflowRoutes);
|
|
||||||
|
|
||||||
app.use('/api/reviewflow-webhooks', reviewflowWebhooksRoutes);
|
|
||||||
|
|
||||||
app.use('/api/reviewflow-public', reviewflowPublicRoutes);
|
|
||||||
|
|
||||||
app.use('/api/stripe_events', passport.authenticate('jwt', {session: false}), stripe_eventsRoutes);
|
app.use('/api/stripe_events', passport.authenticate('jwt', {session: false}), stripe_eventsRoutes);
|
||||||
|
|
||||||
app.use('/api/email_delivery_logs', passport.authenticate('jwt', {session: false}), email_delivery_logsRoutes);
|
app.use('/api/email_delivery_logs', passport.authenticate('jwt', {session: false}), email_delivery_logsRoutes);
|
||||||
|
|||||||
@ -1,12 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const { getSubscriptionPlans } = require('../services/subscriptionPlans');
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
router.get('/', (req, res) => {
|
|
||||||
res.status(200).send({
|
|
||||||
plans: getSubscriptionPlans(),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const ReviewFlowService = require('../services/reviewflow');
|
|
||||||
const wrapAsync = require('../helpers').wrapAsync;
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
router.get('/reviews/:trackingToken', wrapAsync(async (req, res) => {
|
|
||||||
const review = await ReviewFlowService.getHostedReviewRequest(req.params.trackingToken);
|
|
||||||
|
|
||||||
res.status(200).send({ review });
|
|
||||||
}));
|
|
||||||
|
|
||||||
router.post('/reviews/:trackingToken', wrapAsync(async (req, res) => {
|
|
||||||
const review = await ReviewFlowService.submitHostedReview(
|
|
||||||
req.params.trackingToken,
|
|
||||||
req.body || {},
|
|
||||||
);
|
|
||||||
|
|
||||||
res.status(200).send({ review });
|
|
||||||
}));
|
|
||||||
|
|
||||||
router.use('/', require('../helpers').commonErrorHandler);
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const ReviewFlowService = require('../services/reviewflow');
|
|
||||||
const wrapAsync = require('../helpers').wrapAsync;
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
router.post('/:provider/:businessId/:secretToken', wrapAsync(async (req, res) => {
|
|
||||||
const result = await ReviewFlowService.processPaymentWebhook(
|
|
||||||
req.params.provider,
|
|
||||||
req.params.businessId,
|
|
||||||
req.params.secretToken,
|
|
||||||
req.body,
|
|
||||||
req.headers,
|
|
||||||
);
|
|
||||||
|
|
||||||
res.status(200).send({ received: true, ...result });
|
|
||||||
}));
|
|
||||||
|
|
||||||
router.get('/:provider/:businessId/:secretToken', wrapAsync(async (req, res) => {
|
|
||||||
ReviewFlowService.getProviderConfig(req.params.provider);
|
|
||||||
|
|
||||||
res.status(200).send({
|
|
||||||
ok: true,
|
|
||||||
message: 'ReviewFlow webhook URL is reachable. Configure your payment provider to POST JSON events to this same URL.',
|
|
||||||
});
|
|
||||||
}));
|
|
||||||
|
|
||||||
router.use('/', require('../helpers').commonErrorHandler);
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@ -1,302 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const db = require('../db/models');
|
|
||||||
const ReviewFlowService = require('../services/reviewflow');
|
|
||||||
const SubscriptionService = require('../services/subscription');
|
|
||||||
const wrapAsync = require('../helpers').wrapAsync;
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
|
|
||||||
function normalizeString(value) {
|
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireField(value, message) {
|
|
||||||
if (!normalizeString(value)) {
|
|
||||||
const error = new Error(message);
|
|
||||||
error.code = 400;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateUrl(value, message) {
|
|
||||||
try {
|
|
||||||
const parsed = new URL(value);
|
|
||||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
const validationError = new Error(message);
|
|
||||||
validationError.code = 400;
|
|
||||||
throw validationError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const REVIEW_LINK_FIELDS = {
|
|
||||||
google: 'google_review_link',
|
|
||||||
yelp: 'yelp_review_link',
|
|
||||||
facebook: 'facebook_review_link',
|
|
||||||
trustpilot: 'trustpilot_review_link',
|
|
||||||
angi: 'angi_review_link',
|
|
||||||
opentable: 'opentable_review_link',
|
|
||||||
custom: 'custom_review_link',
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeReviewDestination(value) {
|
|
||||||
const destination = normalizeString(value).toLowerCase();
|
|
||||||
|
|
||||||
if (ReviewFlowService.REVIEW_CHANNELS[destination]) {
|
|
||||||
return destination;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'google';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getReviewLinkField(reviewDestination) {
|
|
||||||
return REVIEW_LINK_FIELDS[reviewDestination] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildEmailBody(customerName, businessName, reviewLink) {
|
|
||||||
const greetingName = customerName || 'there';
|
|
||||||
return [
|
|
||||||
`Hi ${greetingName},`,
|
|
||||||
'',
|
|
||||||
`Thank you for choosing ${businessName}. We would love to hear about your experience.`,
|
|
||||||
'',
|
|
||||||
`Leave a review: ${reviewLink}`,
|
|
||||||
'',
|
|
||||||
`Thank you,`,
|
|
||||||
businessName,
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
router.get('/review-channels', wrapAsync(async (req, res) => {
|
|
||||||
res.status(200).send({ channels: ReviewFlowService.serializeReviewChannels() });
|
|
||||||
}));
|
|
||||||
|
|
||||||
router.get('/connectors', wrapAsync(async (req, res) => {
|
|
||||||
const businesses = await ReviewFlowService.listConnectorBusinesses(req.currentUser, req);
|
|
||||||
|
|
||||||
res.status(200).send({ businesses });
|
|
||||||
}));
|
|
||||||
|
|
||||||
router.post('/connectors', wrapAsync(async (req, res) => {
|
|
||||||
const business = await ReviewFlowService.connectProvider(req.currentUser, req.body || {}, req);
|
|
||||||
|
|
||||||
res.status(200).send({ business });
|
|
||||||
}));
|
|
||||||
|
|
||||||
router.post('/connectors/:businessId/:provider/rotate', wrapAsync(async (req, res) => {
|
|
||||||
const business = await ReviewFlowService.rotateWebhookToken(
|
|
||||||
req.currentUser,
|
|
||||||
req.params.businessId,
|
|
||||||
req.params.provider,
|
|
||||||
req,
|
|
||||||
);
|
|
||||||
|
|
||||||
res.status(200).send({ business });
|
|
||||||
}));
|
|
||||||
|
|
||||||
router.get('/summary', wrapAsync(async (req, res) => {
|
|
||||||
const currentUser = req.currentUser;
|
|
||||||
const limit = Math.min(Number(req.query.limit) || 8, 25);
|
|
||||||
|
|
||||||
const requests = await db.review_requests.findAll({
|
|
||||||
where: { createdById: currentUser.id },
|
|
||||||
include: [
|
|
||||||
{ model: db.businesses, as: 'business' },
|
|
||||||
{ model: db.customers, as: 'customer' },
|
|
||||||
{ model: db.transactions, as: 'transaction' },
|
|
||||||
],
|
|
||||||
order: [['createdAt', 'DESC']],
|
|
||||||
limit,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [
|
|
||||||
pending,
|
|
||||||
sent,
|
|
||||||
clicked,
|
|
||||||
reviewed,
|
|
||||||
customers,
|
|
||||||
transactions,
|
|
||||||
paymentEvents,
|
|
||||||
recentTransactions,
|
|
||||||
recentEvents,
|
|
||||||
] = await Promise.all([
|
|
||||||
db.review_requests.count({ where: { createdById: currentUser.id, status: 'pending' } }),
|
|
||||||
db.review_requests.count({ where: { createdById: currentUser.id, status: 'sent' } }),
|
|
||||||
db.review_requests.count({ where: { createdById: currentUser.id, status: 'clicked' } }),
|
|
||||||
db.review_requests.count({ where: { createdById: currentUser.id, status: 'reviewed' } }),
|
|
||||||
db.customers.count({ where: { createdById: currentUser.id } }),
|
|
||||||
db.transactions.count({ where: { createdById: currentUser.id } }),
|
|
||||||
db.stripe_events.count({ where: { createdById: currentUser.id } }),
|
|
||||||
db.transactions.findAll({
|
|
||||||
where: { createdById: currentUser.id },
|
|
||||||
include: [
|
|
||||||
{ model: db.businesses, as: 'business' },
|
|
||||||
{ model: db.customers, as: 'customer' },
|
|
||||||
],
|
|
||||||
order: [['createdAt', 'DESC']],
|
|
||||||
limit: 6,
|
|
||||||
}),
|
|
||||||
db.stripe_events.findAll({
|
|
||||||
where: { createdById: currentUser.id },
|
|
||||||
include: [
|
|
||||||
{ model: db.businesses, as: 'business' },
|
|
||||||
],
|
|
||||||
order: [['createdAt', 'DESC']],
|
|
||||||
limit: 6,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
res.status(200).send({
|
|
||||||
stats: { pending, sent, clicked, reviewed, customers, transactions, paymentEvents },
|
|
||||||
requests,
|
|
||||||
recentTransactions,
|
|
||||||
recentEvents,
|
|
||||||
});
|
|
||||||
}));
|
|
||||||
|
|
||||||
router.post('/request', wrapAsync(async (req, res) => {
|
|
||||||
const currentUser = req.currentUser;
|
|
||||||
const body = req.body || {};
|
|
||||||
const businessName = normalizeString(body.businessName);
|
|
||||||
const reviewLink = normalizeString(body.reviewLink);
|
|
||||||
const reviewDestination = normalizeReviewDestination(body.reviewDestination || body.reviewPlatform || 'google');
|
|
||||||
const isHostedReviewDestination = reviewDestination === 'shopify_hosted';
|
|
||||||
const reviewLinkField = getReviewLinkField(reviewDestination);
|
|
||||||
const customerEmail = normalizeString(body.customerEmail).toLowerCase();
|
|
||||||
const customerName = normalizeString(body.customerName);
|
|
||||||
const phone = normalizeString(body.phone);
|
|
||||||
const delayDays = Math.max(0, Math.min(Number(body.delayDays) || 0, 30));
|
|
||||||
|
|
||||||
requireField(businessName, 'Business name is required.');
|
|
||||||
if (!isHostedReviewDestination) {
|
|
||||||
requireField(reviewLink, 'Review link is required.');
|
|
||||||
}
|
|
||||||
requireField(customerEmail, 'Customer email is required.');
|
|
||||||
|
|
||||||
if (!EMAIL_PATTERN.test(customerEmail)) {
|
|
||||||
const error = new Error('Enter a valid customer email address.');
|
|
||||||
error.code = 400;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reviewLink) {
|
|
||||||
validateUrl(reviewLink, 'Enter a valid review destination URL.');
|
|
||||||
}
|
|
||||||
|
|
||||||
await SubscriptionService.assertCanCreateReviewRequests(currentUser, 1);
|
|
||||||
|
|
||||||
const existingBusiness = await db.businesses.findOne({
|
|
||||||
where: { name: businessName, createdById: currentUser.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!existingBusiness) {
|
|
||||||
await SubscriptionService.assertCanCreateBusinesses(currentUser, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const scheduledFor = new Date(Date.now() + delayDays * 24 * 60 * 60 * 1000);
|
|
||||||
const trackingToken = crypto.randomBytes(18).toString('hex');
|
|
||||||
const effectiveReviewLink = isHostedReviewDestination
|
|
||||||
? ReviewFlowService.getHostedReviewUrl(req, trackingToken)
|
|
||||||
: reviewLink;
|
|
||||||
const transaction = await db.sequelize.transaction();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const businessDefaults = {
|
|
||||||
name: businessName,
|
|
||||||
review_destination: reviewDestination,
|
|
||||||
shopify_hosted_reviews_enabled: isHostedReviewDestination,
|
|
||||||
delay_days: delayDays,
|
|
||||||
email_subject_template: `How was your experience with ${businessName}?`,
|
|
||||||
email_body_template: buildEmailBody('{customerName}', businessName, '{reviewLink}'),
|
|
||||||
is_active: true,
|
|
||||||
createdById: currentUser.id,
|
|
||||||
updatedById: currentUser.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (reviewLink && reviewLinkField) {
|
|
||||||
businessDefaults[reviewLinkField] = reviewLink;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [business] = await db.businesses.findOrCreate({
|
|
||||||
where: { name: businessName, createdById: currentUser.id },
|
|
||||||
defaults: businessDefaults,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
const businessUpdates = {
|
|
||||||
review_destination: reviewDestination,
|
|
||||||
shopify_hosted_reviews_enabled: business.shopify_hosted_reviews_enabled || isHostedReviewDestination,
|
|
||||||
delay_days: delayDays,
|
|
||||||
is_active: true,
|
|
||||||
updatedById: currentUser.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (reviewLink && reviewLinkField) {
|
|
||||||
businessUpdates[reviewLinkField] = reviewLink;
|
|
||||||
}
|
|
||||||
|
|
||||||
await business.update(businessUpdates, { transaction });
|
|
||||||
|
|
||||||
const [customer] = await db.customers.findOrCreate({
|
|
||||||
where: { email: customerEmail, createdById: currentUser.id },
|
|
||||||
defaults: {
|
|
||||||
email: customerEmail,
|
|
||||||
name: customerName || null,
|
|
||||||
phone: phone || null,
|
|
||||||
contact_status: 'active',
|
|
||||||
businessId: business.id,
|
|
||||||
createdById: currentUser.id,
|
|
||||||
updatedById: currentUser.id,
|
|
||||||
},
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
await customer.update({
|
|
||||||
name: customerName || customer.name,
|
|
||||||
phone: phone || customer.phone,
|
|
||||||
contact_status: customer.contact_status || 'active',
|
|
||||||
businessId: business.id,
|
|
||||||
updatedById: currentUser.id,
|
|
||||||
}, { transaction });
|
|
||||||
|
|
||||||
const emailSubject = `How was your experience with ${businessName}?`;
|
|
||||||
const reviewRequest = await db.review_requests.create({
|
|
||||||
status: 'pending',
|
|
||||||
scheduled_for: scheduledFor,
|
|
||||||
email_subject: emailSubject,
|
|
||||||
email_body: buildEmailBody(customerName, businessName, effectiveReviewLink),
|
|
||||||
review_link: effectiveReviewLink,
|
|
||||||
tracking_token: trackingToken,
|
|
||||||
review_platform: reviewDestination,
|
|
||||||
businessId: business.id,
|
|
||||||
customerId: customer.id,
|
|
||||||
createdById: currentUser.id,
|
|
||||||
updatedById: currentUser.id,
|
|
||||||
}, { transaction });
|
|
||||||
|
|
||||||
await transaction.commit();
|
|
||||||
|
|
||||||
const createdRequest = await db.review_requests.findByPk(reviewRequest.id, {
|
|
||||||
include: [
|
|
||||||
{ model: db.businesses, as: 'business' },
|
|
||||||
{ model: db.customers, as: 'customer' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(201).send({ request: createdRequest });
|
|
||||||
} catch (error) {
|
|
||||||
await transaction.rollback();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
router.use('/', require('../helpers').commonErrorHandler);
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const SubscriptionService = require('../services/subscription');
|
|
||||||
const wrapAsync = require('../helpers').wrapAsync;
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
router.post('/', wrapAsync(async (req, res) => {
|
|
||||||
const result = await SubscriptionService.handleStripeWebhook(
|
|
||||||
req.body,
|
|
||||||
req.headers['stripe-signature'],
|
|
||||||
);
|
|
||||||
|
|
||||||
res.status(200).send(result);
|
|
||||||
}));
|
|
||||||
|
|
||||||
router.use('/', require('../helpers').commonErrorHandler);
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const SubscriptionService = require('../services/subscription');
|
|
||||||
const wrapAsync = require('../helpers').wrapAsync;
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
function getRequestBaseUrl(req) {
|
|
||||||
const origin = req.get('origin');
|
|
||||||
|
|
||||||
if (origin) {
|
|
||||||
return origin;
|
|
||||||
}
|
|
||||||
|
|
||||||
const forwardedProto = req.get('x-forwarded-proto') || req.protocol;
|
|
||||||
const forwardedHost = req.get('x-forwarded-host') || req.get('host');
|
|
||||||
|
|
||||||
if (forwardedHost) {
|
|
||||||
return `${forwardedProto}://${forwardedHost}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get('/me', wrapAsync(async (req, res) => {
|
|
||||||
const status = await SubscriptionService.getStatus(req.currentUser);
|
|
||||||
|
|
||||||
res.status(200).send(status);
|
|
||||||
}));
|
|
||||||
|
|
||||||
router.post('/select-plan', wrapAsync(async (req, res) => {
|
|
||||||
const status = await SubscriptionService.selectPlan(req.currentUser, req.body?.planId || req.body?.plan);
|
|
||||||
|
|
||||||
res.status(200).send(status);
|
|
||||||
}));
|
|
||||||
|
|
||||||
|
|
||||||
router.post('/create-checkout-session', wrapAsync(async (req, res) => {
|
|
||||||
const session = await SubscriptionService.createCheckoutSession(
|
|
||||||
req.currentUser,
|
|
||||||
req.body?.planId || req.body?.plan,
|
|
||||||
getRequestBaseUrl(req),
|
|
||||||
);
|
|
||||||
|
|
||||||
res.status(200).send(session);
|
|
||||||
}));
|
|
||||||
|
|
||||||
router.post('/create-portal-session', wrapAsync(async (req, res) => {
|
|
||||||
const session = await SubscriptionService.createPortalSession(
|
|
||||||
req.currentUser,
|
|
||||||
getRequestBaseUrl(req),
|
|
||||||
);
|
|
||||||
|
|
||||||
res.status(200).send(session);
|
|
||||||
}));
|
|
||||||
|
|
||||||
router.use('/', require('../helpers').commonErrorHandler);
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
const UsersDBApi = require('../db/api/users');
|
const UsersDBApi = require('../db/api/users');
|
||||||
const db = require('../db/models');
|
|
||||||
const ValidationError = require('./notifications/errors/validation');
|
const ValidationError = require('./notifications/errors/validation');
|
||||||
const ForbiddenError = require('./notifications/errors/forbidden');
|
const ForbiddenError = require('./notifications/errors/forbidden');
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
@ -9,7 +8,6 @@ const PasswordResetEmail = require('./email/list/passwordReset');
|
|||||||
const EmailSender = require('./email');
|
const EmailSender = require('./email');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const helpers = require('../helpers');
|
const helpers = require('../helpers');
|
||||||
const SubscriptionService = require('./subscription');
|
|
||||||
|
|
||||||
class Auth {
|
class Auth {
|
||||||
static async signup(email, password, options = {}, host) {
|
static async signup(email, password, options = {}, host) {
|
||||||
@ -56,16 +54,11 @@ class Auth {
|
|||||||
return helpers.jwtSign(data);
|
return helpers.jwtSign(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscriptionPayload = SubscriptionService.getSignupSubscriptionPayload(
|
|
||||||
options?.body?.planId || options?.body?.plan,
|
|
||||||
);
|
|
||||||
|
|
||||||
const newUser = await UsersDBApi.createFromAuth(
|
const newUser = await UsersDBApi.createFromAuth(
|
||||||
{
|
{
|
||||||
firstName: email.split('@')[0],
|
firstName: email.split('@')[0],
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
email: email,
|
email: email,
|
||||||
...subscriptionPayload,
|
|
||||||
|
|
||||||
},
|
},
|
||||||
options,
|
options,
|
||||||
|
|||||||
@ -6,7 +6,6 @@ const csv = require('csv-parser');
|
|||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const stream = require('stream');
|
const stream = require('stream');
|
||||||
const SubscriptionService = require('./subscription');
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -16,8 +15,6 @@ module.exports = class BusinessesService {
|
|||||||
static async create(data, currentUser) {
|
static async create(data, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
await SubscriptionService.assertCanCreateBusinesses(currentUser, 1, { transaction });
|
|
||||||
|
|
||||||
await BusinessesDBApi.create(
|
await BusinessesDBApi.create(
|
||||||
data,
|
data,
|
||||||
{
|
{
|
||||||
@ -54,8 +51,6 @@ module.exports = class BusinessesService {
|
|||||||
.on('error', (error) => reject(error));
|
.on('error', (error) => reject(error));
|
||||||
})
|
})
|
||||||
|
|
||||||
await SubscriptionService.assertCanCreateBusinesses(req.currentUser, results.length, { transaction });
|
|
||||||
|
|
||||||
await BusinessesDBApi.bulkImport(results, {
|
await BusinessesDBApi.bulkImport(results, {
|
||||||
transaction,
|
transaction,
|
||||||
ignoreDuplicates: true,
|
ignoreDuplicates: true,
|
||||||
|
|||||||
@ -6,7 +6,6 @@ const csv = require('csv-parser');
|
|||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const stream = require('stream');
|
const stream = require('stream');
|
||||||
const SubscriptionService = require('./subscription');
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -16,8 +15,6 @@ module.exports = class Review_requestsService {
|
|||||||
static async create(data, currentUser) {
|
static async create(data, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
await SubscriptionService.assertCanCreateReviewRequests(currentUser, 1, { transaction });
|
|
||||||
|
|
||||||
await Review_requestsDBApi.create(
|
await Review_requestsDBApi.create(
|
||||||
data,
|
data,
|
||||||
{
|
{
|
||||||
@ -54,8 +51,6 @@ module.exports = class Review_requestsService {
|
|||||||
.on('error', (error) => reject(error));
|
.on('error', (error) => reject(error));
|
||||||
})
|
})
|
||||||
|
|
||||||
await SubscriptionService.assertCanCreateReviewRequests(req.currentUser, results.length, { transaction });
|
|
||||||
|
|
||||||
await Review_requestsDBApi.bulkImport(results, {
|
await Review_requestsDBApi.bulkImport(results, {
|
||||||
transaction,
|
transaction,
|
||||||
ignoreDuplicates: true,
|
ignoreDuplicates: true,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,222 +0,0 @@
|
|||||||
const Stripe = require('stripe');
|
|
||||||
const config = require('../config');
|
|
||||||
|
|
||||||
const PLAN_PRICE_ENV = {
|
|
||||||
starter: 'STRIPE_STARTER_PRICE_ID',
|
|
||||||
pro: 'STRIPE_PRO_PRICE_ID',
|
|
||||||
};
|
|
||||||
|
|
||||||
let cachedStripeClient = null;
|
|
||||||
let cachedSecretKey = null;
|
|
||||||
|
|
||||||
function httpError(message, code = 400) {
|
|
||||||
const error = new Error(message);
|
|
||||||
error.code = code;
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
|
|
||||||
function compactMissing(items) {
|
|
||||||
return items.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPriceIdForPlan(planId) {
|
|
||||||
if (planId === 'pro') {
|
|
||||||
return config.stripe.proPriceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (planId === 'starter') {
|
|
||||||
return config.stripe.starterPriceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPlanIdForPriceId(priceId) {
|
|
||||||
if (!priceId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (priceId === config.stripe.proPriceId) {
|
|
||||||
return 'pro';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (priceId === config.stripe.starterPriceId) {
|
|
||||||
return 'starter';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStripeClient() {
|
|
||||||
if (!config.stripe.secretKey) {
|
|
||||||
throw httpError('Stripe billing is not configured yet. Add STRIPE_SECRET_KEY in the backend environment.', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cachedStripeClient || cachedSecretKey !== config.stripe.secretKey) {
|
|
||||||
cachedStripeClient = Stripe(config.stripe.secretKey);
|
|
||||||
cachedSecretKey = config.stripe.secretKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
return cachedStripeClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMissingConfigurationMessage(missing) {
|
|
||||||
return `Stripe billing is not configured yet. Add ${missing.join(', ')} in the backend environment, then reload the backend service.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = class StripeBillingService {
|
|
||||||
static getPriceIdForPlan(planId) {
|
|
||||||
return getPriceIdForPlan(planId);
|
|
||||||
}
|
|
||||||
|
|
||||||
static getPlanIdForPriceId(priceId) {
|
|
||||||
return getPlanIdForPriceId(priceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
static getMissingCheckoutConfiguration(planId) {
|
|
||||||
const priceEnvName = PLAN_PRICE_ENV[planId] || PLAN_PRICE_ENV.starter;
|
|
||||||
|
|
||||||
return compactMissing([
|
|
||||||
config.stripe.secretKey ? null : 'STRIPE_SECRET_KEY',
|
|
||||||
getPriceIdForPlan(planId) ? null : priceEnvName,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
static getMissingPortalConfiguration() {
|
|
||||||
return compactMissing([
|
|
||||||
config.stripe.secretKey ? null : 'STRIPE_SECRET_KEY',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
static getMissingWebhookConfiguration() {
|
|
||||||
return compactMissing([
|
|
||||||
config.stripe.secretKey ? null : 'STRIPE_SECRET_KEY',
|
|
||||||
config.stripe.webhookSecret ? null : 'STRIPE_WEBHOOK_SECRET',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
static getSetupStatus(planId = 'starter') {
|
|
||||||
const allMissing = new Set([
|
|
||||||
...this.getMissingCheckoutConfiguration('starter'),
|
|
||||||
...this.getMissingCheckoutConfiguration('pro'),
|
|
||||||
...this.getMissingWebhookConfiguration(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
checkoutReady: this.getMissingCheckoutConfiguration(planId).length === 0,
|
|
||||||
portalReady: this.getMissingPortalConfiguration().length === 0,
|
|
||||||
webhookReady: this.getMissingWebhookConfiguration().length === 0,
|
|
||||||
missingConfiguration: Array.from(allMissing),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static assertCheckoutConfigured(planId) {
|
|
||||||
const missing = this.getMissingCheckoutConfiguration(planId);
|
|
||||||
|
|
||||||
if (missing.length) {
|
|
||||||
throw httpError(formatMissingConfigurationMessage(missing), 400);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static assertPortalConfigured() {
|
|
||||||
const missing = this.getMissingPortalConfiguration();
|
|
||||||
|
|
||||||
if (missing.length) {
|
|
||||||
throw httpError(formatMissingConfigurationMessage(missing), 400);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static assertWebhookConfigured() {
|
|
||||||
const missing = this.getMissingWebhookConfiguration();
|
|
||||||
|
|
||||||
if (missing.length) {
|
|
||||||
throw httpError(formatMissingConfigurationMessage(missing), 400);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async createCheckoutSession(params) {
|
|
||||||
const {
|
|
||||||
user,
|
|
||||||
plan,
|
|
||||||
baseUrl,
|
|
||||||
trialPeriodDays,
|
|
||||||
} = params;
|
|
||||||
const priceId = getPriceIdForPlan(plan.id);
|
|
||||||
const subscriptionData = {
|
|
||||||
metadata: {
|
|
||||||
userId: user.id,
|
|
||||||
planId: plan.id,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
this.assertCheckoutConfigured(plan.id);
|
|
||||||
|
|
||||||
const stripe = getStripeClient();
|
|
||||||
|
|
||||||
if (trialPeriodDays && trialPeriodDays > 0) {
|
|
||||||
subscriptionData.trial_period_days = trialPeriodDays;
|
|
||||||
}
|
|
||||||
|
|
||||||
return stripe.checkout.sessions.create({
|
|
||||||
mode: 'subscription',
|
|
||||||
customer: user.stripeCustomerId || undefined,
|
|
||||||
customer_email: user.stripeCustomerId ? undefined : user.email,
|
|
||||||
client_reference_id: user.id,
|
|
||||||
line_items: [
|
|
||||||
{
|
|
||||||
price: priceId,
|
|
||||||
quantity: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
allow_promotion_codes: true,
|
|
||||||
billing_address_collection: 'auto',
|
|
||||||
success_url: `${baseUrl}/subscription?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
|
|
||||||
cancel_url: `${baseUrl}/subscription?checkout=cancelled`,
|
|
||||||
metadata: {
|
|
||||||
userId: user.id,
|
|
||||||
planId: plan.id,
|
|
||||||
},
|
|
||||||
subscription_data: subscriptionData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
static async createPortalSession(params) {
|
|
||||||
const { customerId, baseUrl } = params;
|
|
||||||
this.assertPortalConfigured();
|
|
||||||
|
|
||||||
const stripe = getStripeClient();
|
|
||||||
|
|
||||||
return stripe.billingPortal.sessions.create({
|
|
||||||
customer: customerId,
|
|
||||||
return_url: `${baseUrl}/subscription`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
static constructWebhookEvent(rawBody, signature) {
|
|
||||||
this.assertWebhookConfigured();
|
|
||||||
|
|
||||||
if (!signature) {
|
|
||||||
throw httpError('Missing Stripe webhook signature.', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const stripe = getStripeClient();
|
|
||||||
|
|
||||||
return stripe.webhooks.constructEvent(
|
|
||||||
rawBody,
|
|
||||||
signature,
|
|
||||||
config.stripe.webhookSecret,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static async retrieveSubscription(subscriptionId) {
|
|
||||||
if (!subscriptionId || typeof subscriptionId !== 'string') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stripe = getStripeClient();
|
|
||||||
|
|
||||||
return stripe.subscriptions.retrieve(subscriptionId, {
|
|
||||||
expand: ['items.data.price'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,830 +0,0 @@
|
|||||||
const db = require('../db/models');
|
|
||||||
const StripeBillingService = require('./stripeBilling');
|
|
||||||
const {
|
|
||||||
TRIAL_DAYS,
|
|
||||||
getSubscriptionPlanById,
|
|
||||||
getSubscriptionPlans,
|
|
||||||
} = require('./subscriptionPlans');
|
|
||||||
|
|
||||||
const DEFAULT_PLAN_ID = 'starter';
|
|
||||||
const DEFAULT_STATUS = 'trialing';
|
|
||||||
const INTERNAL_ADMIN_ROLE_NAMES = ['Administrator'];
|
|
||||||
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
|
||||||
const PAYMENT_CONNECTOR_FIELDS = [
|
|
||||||
'stripe_connected',
|
|
||||||
'square_connected',
|
|
||||||
'paypal_connected',
|
|
||||||
'shopify_connected',
|
|
||||||
'woocommerce_connected',
|
|
||||||
];
|
|
||||||
|
|
||||||
function httpError(message, code = 403) {
|
|
||||||
const error = new Error(message);
|
|
||||||
error.code = code;
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePlanId(planId) {
|
|
||||||
const normalized = typeof planId === 'string' ? planId.trim().toLowerCase() : '';
|
|
||||||
|
|
||||||
return getSubscriptionPlanById(normalized) ? normalized : DEFAULT_PLAN_ID;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPlan(planId) {
|
|
||||||
return getSubscriptionPlanById(normalizePlanId(planId)) || getSubscriptionPlanById(DEFAULT_PLAN_ID);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addDays(date, days) {
|
|
||||||
return new Date(date.getTime() + days * DAY_IN_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTrialWindow(referenceDate = new Date()) {
|
|
||||||
const trialStartedAt = new Date(referenceDate);
|
|
||||||
|
|
||||||
return {
|
|
||||||
trialStartedAt,
|
|
||||||
trialEndsAt: addDays(trialStartedAt, TRIAL_DAYS),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCurrentMonthRange(referenceDate = new Date()) {
|
|
||||||
const periodStart = new Date(Date.UTC(
|
|
||||||
referenceDate.getUTCFullYear(),
|
|
||||||
referenceDate.getUTCMonth(),
|
|
||||||
1,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
));
|
|
||||||
const periodEnd = new Date(Date.UTC(
|
|
||||||
referenceDate.getUTCFullYear(),
|
|
||||||
referenceDate.getUTCMonth() + 1,
|
|
||||||
1,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
));
|
|
||||||
|
|
||||||
return { periodStart, periodEnd };
|
|
||||||
}
|
|
||||||
|
|
||||||
function toDateOrNull(value) {
|
|
||||||
if (!value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date(value);
|
|
||||||
|
|
||||||
return Number.isNaN(date.getTime()) ? null : date;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEffectiveSubscription(user, referenceDate = new Date()) {
|
|
||||||
const plan = getPlan(user?.subscriptionPlanId);
|
|
||||||
const status = user?.subscriptionStatus || DEFAULT_STATUS;
|
|
||||||
const trialStartedAt = toDateOrNull(user?.trialStartedAt);
|
|
||||||
const trialEndsAt = toDateOrNull(user?.trialEndsAt);
|
|
||||||
const isTrialActive = status === 'trialing' && (!trialEndsAt || trialEndsAt.getTime() >= referenceDate.getTime());
|
|
||||||
const isActive = status === 'active' || isTrialActive;
|
|
||||||
const effectiveStatus = status === 'trialing' && !isTrialActive ? 'expired' : status;
|
|
||||||
const trialDaysLeft = trialEndsAt
|
|
||||||
? Math.max(0, Math.ceil((trialEndsAt.getTime() - referenceDate.getTime()) / DAY_IN_MS))
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
plan,
|
|
||||||
planId: plan.id,
|
|
||||||
status,
|
|
||||||
effectiveStatus,
|
|
||||||
isActive,
|
|
||||||
trialStartedAt,
|
|
||||||
trialEndsAt,
|
|
||||||
trialDaysLeft,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUserRoleName(user) {
|
|
||||||
return user?.app_role?.name || user?.app_role?.dataValues?.name || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isSubscriptionLimitExemptUser(user, options = {}) {
|
|
||||||
if (!user) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.email === 'admin@flatlogic.com' || user.dataValues?.email === 'admin@flatlogic.com') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const roleName = getUserRoleName(user);
|
|
||||||
|
|
||||||
if (INTERNAL_ADMIN_ROLE_NAMES.includes(roleName)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const roleId = user.app_roleId || user.dataValues?.app_roleId;
|
|
||||||
|
|
||||||
if (!roleId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const role = await db.roles.findByPk(roleId, {
|
|
||||||
transaction: options.transaction || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
return INTERNAL_ADMIN_ROLE_NAMES.includes(role?.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLimitMessage(plan, usageCount, limit, unit, options = {}) {
|
|
||||||
const baseMessage = `${plan.name} includes ${limit.toLocaleString()} ${unit}. You have already used ${usageCount.toLocaleString()}.`;
|
|
||||||
const upgradePrefix = plan.id === 'starter' ? 'Upgrade to Pro or ' : '';
|
|
||||||
|
|
||||||
if (options.resetDate) {
|
|
||||||
return `${baseMessage} ${upgradePrefix}wait until ${options.resetDate.toISOString().slice(0, 10)} for the monthly limit to reset.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${baseMessage} ${upgradePrefix}${options.remediation || 'remove an existing item before adding another.'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function getUnixDate(value) {
|
|
||||||
if (!value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timestamp = Number(value);
|
|
||||||
|
|
||||||
if (!timestamp) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Date(timestamp * 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPrimarySubscriptionItem(subscription) {
|
|
||||||
return subscription?.items?.data?.[0] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSubscriptionPriceId(subscription) {
|
|
||||||
const item = getPrimarySubscriptionItem(subscription);
|
|
||||||
|
|
||||||
return item?.price?.id || subscription?.plan?.id || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCurrentPeriodEnd(subscription) {
|
|
||||||
const item = getPrimarySubscriptionItem(subscription);
|
|
||||||
|
|
||||||
return getUnixDate(subscription?.current_period_end || item?.current_period_end);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPlanIdFromStripeSubscription(subscription, fallbackPlanId) {
|
|
||||||
const pricePlanId = StripeBillingService.getPlanIdForPriceId(getSubscriptionPriceId(subscription));
|
|
||||||
|
|
||||||
if (pricePlanId) {
|
|
||||||
return pricePlanId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalizePlanId(subscription?.metadata?.planId || fallbackPlanId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStripeCustomerId(value) {
|
|
||||||
if (!value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value.id || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStripeSubscriptionId(value) {
|
|
||||||
if (!value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value.id || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTrialDaysLeftForCheckout(subscription) {
|
|
||||||
if (!subscription.isActive || subscription.status !== DEFAULT_STATUS || !subscription.trialEndsAt) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.max(0, Math.ceil((subscription.trialEndsAt.getTime() - Date.now()) / DAY_IN_MS));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getTeamUsageScope(userId, transaction) {
|
|
||||||
const user = await db.users.findByPk(userId, {
|
|
||||||
attributes: ['id', 'createdById'],
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
const teamOwnerId = user?.createdById || userId;
|
|
||||||
const teamMembers = await db.users.findAll({
|
|
||||||
attributes: ['id'],
|
|
||||||
where: {
|
|
||||||
[db.Sequelize.Op.or]: [
|
|
||||||
{ id: teamOwnerId },
|
|
||||||
{ createdById: teamOwnerId },
|
|
||||||
],
|
|
||||||
disabled: false,
|
|
||||||
},
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
const teamMemberIds = teamMembers.map((teamMember) => teamMember.id);
|
|
||||||
|
|
||||||
if (!teamMemberIds.includes(userId)) {
|
|
||||||
teamMemberIds.push(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
teamOwnerId,
|
|
||||||
teamMemberIds,
|
|
||||||
teamMembers: teamMemberIds.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getUserRecord(currentUserOrId, options = {}) {
|
|
||||||
const transaction = options.transaction || undefined;
|
|
||||||
const userId = typeof currentUserOrId === 'string' ? currentUserOrId : currentUserOrId?.id;
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
throw httpError('A signed-in user is required to check subscription limits.', 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldLoad = options.forceReload || typeof currentUserOrId === 'string' || currentUserOrId.subscriptionPlanId === undefined;
|
|
||||||
|
|
||||||
if (!shouldLoad) {
|
|
||||||
return currentUserOrId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await db.users.findByPk(userId, { transaction });
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw httpError('Subscription user was not found.', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = class SubscriptionService {
|
|
||||||
static normalizePlanId(planId) {
|
|
||||||
return normalizePlanId(planId);
|
|
||||||
}
|
|
||||||
|
|
||||||
static getSignupSubscriptionPayload(planId) {
|
|
||||||
return {
|
|
||||||
subscriptionPlanId: normalizePlanId(planId),
|
|
||||||
subscriptionStatus: DEFAULT_STATUS,
|
|
||||||
...buildTrialWindow(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static getEffectiveSubscription(user, referenceDate = new Date()) {
|
|
||||||
return getEffectiveSubscription(user, referenceDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
static async getUsageForUserId(userId, options = {}) {
|
|
||||||
const transaction = options.transaction || undefined;
|
|
||||||
const { periodStart, periodEnd } = getCurrentMonthRange();
|
|
||||||
const teamScope = await getTeamUsageScope(userId, transaction);
|
|
||||||
const teamMemberFilter = { [db.Sequelize.Op.in]: teamScope.teamMemberIds };
|
|
||||||
const businesses = await db.businesses.findAll({
|
|
||||||
where: { createdById: teamMemberFilter },
|
|
||||||
attributes: ['id', ...PAYMENT_CONNECTOR_FIELDS],
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
const monthlyReviewRequests = await db.review_requests.count({
|
|
||||||
where: {
|
|
||||||
createdById: teamMemberFilter,
|
|
||||||
createdAt: {
|
|
||||||
[db.Sequelize.Op.gte]: periodStart,
|
|
||||||
[db.Sequelize.Op.lt]: periodEnd,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
const paymentConnectors = businesses.reduce((total, business) => {
|
|
||||||
return total + PAYMENT_CONNECTOR_FIELDS.filter((field) => Boolean(business[field])).length;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
monthlyReviewRequests,
|
|
||||||
businesses: businesses.length,
|
|
||||||
teamMembers: teamScope.teamMembers,
|
|
||||||
paymentConnectors,
|
|
||||||
periodStart,
|
|
||||||
periodEnd,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static async getStatus(currentUserOrId, options = {}) {
|
|
||||||
const user = await getUserRecord(currentUserOrId, { ...options, forceReload: true });
|
|
||||||
const subscription = getEffectiveSubscription(user);
|
|
||||||
const usage = await this.getUsageForUserId(user.id, options);
|
|
||||||
const plans = getSubscriptionPlans();
|
|
||||||
|
|
||||||
return {
|
|
||||||
subscription: {
|
|
||||||
planId: subscription.planId,
|
|
||||||
planName: subscription.plan.name,
|
|
||||||
status: subscription.status,
|
|
||||||
effectiveStatus: subscription.effectiveStatus,
|
|
||||||
isActive: subscription.isActive,
|
|
||||||
trialStartedAt: subscription.trialStartedAt,
|
|
||||||
trialEndsAt: subscription.trialEndsAt,
|
|
||||||
trialDaysLeft: subscription.trialDaysLeft,
|
|
||||||
priceMonthly: subscription.plan.priceMonthly,
|
|
||||||
currency: subscription.plan.currency,
|
|
||||||
stripeCustomerLinked: Boolean(user.stripeCustomerId),
|
|
||||||
stripeSubscriptionLinked: Boolean(user.stripeSubscriptionId),
|
|
||||||
currentPeriodEndsAt: user.stripeCurrentPeriodEndAt || null,
|
|
||||||
},
|
|
||||||
billing: {
|
|
||||||
...StripeBillingService.getSetupStatus(subscription.planId),
|
|
||||||
hasStripeCustomer: Boolean(user.stripeCustomerId),
|
|
||||||
hasStripeSubscription: Boolean(user.stripeSubscriptionId),
|
|
||||||
},
|
|
||||||
plan: subscription.plan,
|
|
||||||
usage,
|
|
||||||
limits: subscription.plan.limits,
|
|
||||||
plans,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static async selectPlan(currentUser, planId) {
|
|
||||||
const user = await db.users.findByPk(currentUser?.id);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw httpError('Subscription user was not found.', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const targetPlanId = normalizePlanId(planId);
|
|
||||||
const existingSubscription = getEffectiveSubscription(user, now);
|
|
||||||
|
|
||||||
if (targetPlanId === existingSubscription.planId) {
|
|
||||||
return this.getStatus(user.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.stripeCustomerId || user.stripeSubscriptionId || user.subscriptionStatus === 'active') {
|
|
||||||
throw httpError('This account is managed by Stripe. Use Checkout or Manage billing to change plans.', 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingSubscription.effectiveStatus !== DEFAULT_STATUS) {
|
|
||||||
throw httpError('Your trial is not active. Start Stripe Checkout to choose a paid plan.', 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
const trialWindow = user.trialStartedAt && user.trialEndsAt ? {
|
|
||||||
trialStartedAt: user.trialStartedAt,
|
|
||||||
trialEndsAt: user.trialEndsAt,
|
|
||||||
} : buildTrialWindow(now);
|
|
||||||
|
|
||||||
await user.update({
|
|
||||||
subscriptionPlanId: targetPlanId,
|
|
||||||
subscriptionStatus: DEFAULT_STATUS,
|
|
||||||
trialStartedAt: trialWindow.trialStartedAt,
|
|
||||||
trialEndsAt: trialWindow.trialEndsAt,
|
|
||||||
updatedById: currentUser.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.getStatus(user.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static async createCheckoutSession(currentUser, planId, baseUrl) {
|
|
||||||
const user = await db.users.findByPk(currentUser?.id);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw httpError('Subscription user was not found.', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const plan = getPlan(planId);
|
|
||||||
const subscription = getEffectiveSubscription(user);
|
|
||||||
const trialPeriodDays = user.stripeSubscriptionId ? 0 : getTrialDaysLeftForCheckout(subscription);
|
|
||||||
const session = await StripeBillingService.createCheckoutSession({
|
|
||||||
user,
|
|
||||||
plan,
|
|
||||||
baseUrl,
|
|
||||||
trialPeriodDays,
|
|
||||||
});
|
|
||||||
|
|
||||||
await user.update({
|
|
||||||
subscriptionPlanId: plan.id,
|
|
||||||
stripeCheckoutSessionId: session.id,
|
|
||||||
updatedById: currentUser.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionId: session.id,
|
|
||||||
url: session.url,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static async createPortalSession(currentUser, baseUrl) {
|
|
||||||
const user = await db.users.findByPk(currentUser?.id);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw httpError('Subscription user was not found.', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.stripeCustomerId) {
|
|
||||||
throw httpError('No Stripe customer is linked to this account yet. Start Checkout first, then use Manage billing.', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const portalSession = await StripeBillingService.createPortalSession({
|
|
||||||
customerId: user.stripeCustomerId,
|
|
||||||
baseUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
url: portalSession.url,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static async syncStripeSubscription(subscription, options = {}) {
|
|
||||||
if (!subscription) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stripeSubscriptionId = getStripeSubscriptionId(subscription.id);
|
|
||||||
const stripeCustomerId = getStripeCustomerId(subscription.customer || options.customerId);
|
|
||||||
const whereClauses = [];
|
|
||||||
|
|
||||||
if (options.userId) {
|
|
||||||
whereClauses.push({ id: options.userId });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stripeSubscriptionId) {
|
|
||||||
whereClauses.push({ stripeSubscriptionId });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stripeCustomerId) {
|
|
||||||
whereClauses.push({ stripeCustomerId });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!whereClauses.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await db.users.findOne({
|
|
||||||
where: {
|
|
||||||
[db.Sequelize.Op.or]: whereClauses,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const planId = getPlanIdFromStripeSubscription(subscription, options.planId || user.subscriptionPlanId);
|
|
||||||
const status = subscription.status || user.subscriptionStatus || DEFAULT_STATUS;
|
|
||||||
const trialStartedAt = getUnixDate(subscription.trial_start) || user.trialStartedAt;
|
|
||||||
const trialEndsAt = getUnixDate(subscription.trial_end) || user.trialEndsAt;
|
|
||||||
const subscriptionStartedAt = getUnixDate(subscription.start_date) || user.subscriptionStartedAt || new Date();
|
|
||||||
const subscriptionEndsAt = getUnixDate(subscription.cancel_at) || getCurrentPeriodEnd(subscription) || user.subscriptionEndsAt;
|
|
||||||
const subscriptionCanceledAt = getUnixDate(subscription.canceled_at) || (status === 'canceled' ? new Date() : user.subscriptionCanceledAt);
|
|
||||||
|
|
||||||
await user.update({
|
|
||||||
subscriptionPlanId: planId,
|
|
||||||
subscriptionStatus: status,
|
|
||||||
trialStartedAt,
|
|
||||||
trialEndsAt,
|
|
||||||
subscriptionStartedAt,
|
|
||||||
subscriptionEndsAt,
|
|
||||||
subscriptionCanceledAt,
|
|
||||||
stripeCustomerId: stripeCustomerId || user.stripeCustomerId,
|
|
||||||
stripeSubscriptionId: stripeSubscriptionId || user.stripeSubscriptionId,
|
|
||||||
stripePriceId: getSubscriptionPriceId(subscription) || user.stripePriceId,
|
|
||||||
stripeCheckoutSessionId: options.checkoutSessionId || user.stripeCheckoutSessionId,
|
|
||||||
stripeCurrentPeriodEndAt: getCurrentPeriodEnd(subscription) || user.stripeCurrentPeriodEndAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async updateStripeSubscriptionStatusByReference(reference, status) {
|
|
||||||
const whereClauses = [];
|
|
||||||
|
|
||||||
if (reference.subscriptionId) {
|
|
||||||
whereClauses.push({ stripeSubscriptionId: reference.subscriptionId });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reference.customerId) {
|
|
||||||
whereClauses.push({ stripeCustomerId: reference.customerId });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!whereClauses.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await db.users.findOne({
|
|
||||||
where: {
|
|
||||||
[db.Sequelize.Op.or]: whereClauses,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
await user.update({ subscriptionStatus: status });
|
|
||||||
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async handleStripeWebhook(rawBody, signature) {
|
|
||||||
const event = StripeBillingService.constructWebhookEvent(rawBody, signature);
|
|
||||||
|
|
||||||
switch (event.type) {
|
|
||||||
case 'checkout.session.completed': {
|
|
||||||
const session = event.data.object;
|
|
||||||
const subscription = await StripeBillingService.retrieveSubscription(getStripeSubscriptionId(session.subscription));
|
|
||||||
|
|
||||||
if (subscription) {
|
|
||||||
await this.syncStripeSubscription(subscription, {
|
|
||||||
userId: session.client_reference_id || session.metadata?.userId,
|
|
||||||
planId: session.metadata?.planId,
|
|
||||||
customerId: getStripeCustomerId(session.customer),
|
|
||||||
checkoutSessionId: session.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'customer.subscription.created':
|
|
||||||
case 'customer.subscription.updated':
|
|
||||||
case 'customer.subscription.deleted':
|
|
||||||
await this.syncStripeSubscription(event.data.object);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'invoice.payment_succeeded':
|
|
||||||
case 'invoice.payment_failed': {
|
|
||||||
const invoice = event.data.object;
|
|
||||||
const subscriptionId = getStripeSubscriptionId(invoice.subscription);
|
|
||||||
const subscription = await StripeBillingService.retrieveSubscription(subscriptionId);
|
|
||||||
|
|
||||||
if (subscription) {
|
|
||||||
await this.syncStripeSubscription(subscription, {
|
|
||||||
customerId: getStripeCustomerId(invoice.customer),
|
|
||||||
});
|
|
||||||
} else if (event.type === 'invoice.payment_failed') {
|
|
||||||
await this.updateStripeSubscriptionStatusByReference({
|
|
||||||
subscriptionId,
|
|
||||||
customerId: getStripeCustomerId(invoice.customer),
|
|
||||||
}, 'past_due');
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
received: true,
|
|
||||||
type: event.type,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static async canCreateReviewRequests(currentUserOrId, quantity = 1, options = {}) {
|
|
||||||
const user = await getUserRecord(currentUserOrId, options);
|
|
||||||
const subscription = getEffectiveSubscription(user);
|
|
||||||
|
|
||||||
if (await isSubscriptionLimitExemptUser(user, options)) {
|
|
||||||
return {
|
|
||||||
allowed: true,
|
|
||||||
usage: null,
|
|
||||||
subscription,
|
|
||||||
subscriptionExempt: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!subscription.isActive) {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
code: 403,
|
|
||||||
message: 'Your Review Flow trial has ended. Choose a plan to keep creating review requests.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const usage = await this.getUsageForUserId(user.id, options);
|
|
||||||
const limit = subscription.plan.limits.monthlyReviewRequests;
|
|
||||||
|
|
||||||
if (usage.monthlyReviewRequests + quantity > limit) {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
code: 403,
|
|
||||||
message: getLimitMessage(
|
|
||||||
subscription.plan,
|
|
||||||
usage.monthlyReviewRequests,
|
|
||||||
limit,
|
|
||||||
'review requests per month',
|
|
||||||
{ resetDate: usage.periodEnd },
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { allowed: true, usage, subscription };
|
|
||||||
}
|
|
||||||
|
|
||||||
static async assertCanCreateReviewRequests(currentUserOrId, quantity = 1, options = {}) {
|
|
||||||
const result = await this.canCreateReviewRequests(currentUserOrId, quantity, options);
|
|
||||||
|
|
||||||
if (!result.allowed) {
|
|
||||||
throw httpError(result.message, result.code);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async canCreateBusinesses(currentUserOrId, quantity = 1, options = {}) {
|
|
||||||
const user = await getUserRecord(currentUserOrId, options);
|
|
||||||
const subscription = getEffectiveSubscription(user);
|
|
||||||
|
|
||||||
if (await isSubscriptionLimitExemptUser(user, options)) {
|
|
||||||
return {
|
|
||||||
allowed: true,
|
|
||||||
usage: null,
|
|
||||||
subscription,
|
|
||||||
subscriptionExempt: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!subscription.isActive) {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
code: 403,
|
|
||||||
message: 'Your Review Flow trial has ended. Choose a plan to keep adding business profiles.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const usage = await this.getUsageForUserId(user.id, options);
|
|
||||||
const limit = subscription.plan.limits.businesses;
|
|
||||||
|
|
||||||
if (usage.businesses + quantity > limit) {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
code: 403,
|
|
||||||
message: getLimitMessage(
|
|
||||||
subscription.plan,
|
|
||||||
usage.businesses,
|
|
||||||
limit,
|
|
||||||
limit === 1 ? 'business profile' : 'business profiles',
|
|
||||||
{
|
|
||||||
remediation: limit === 1
|
|
||||||
? 'remove your existing business profile before adding another.'
|
|
||||||
: 'remove an existing business profile before adding another.',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { allowed: true, usage, subscription };
|
|
||||||
}
|
|
||||||
|
|
||||||
static async assertCanCreateBusinesses(currentUserOrId, quantity = 1, options = {}) {
|
|
||||||
const result = await this.canCreateBusinesses(currentUserOrId, quantity, options);
|
|
||||||
|
|
||||||
if (!result.allowed) {
|
|
||||||
throw httpError(result.message, result.code);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async canCreateTeamMembers(currentUserOrId, quantity = 1, options = {}) {
|
|
||||||
const user = await getUserRecord(currentUserOrId, options);
|
|
||||||
const subscription = getEffectiveSubscription(user);
|
|
||||||
|
|
||||||
if (await isSubscriptionLimitExemptUser(user, options)) {
|
|
||||||
return {
|
|
||||||
allowed: true,
|
|
||||||
usage: null,
|
|
||||||
subscription,
|
|
||||||
subscriptionExempt: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!subscription.isActive) {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
code: 403,
|
|
||||||
message: 'Your Review Flow trial has ended. Choose a plan to keep inviting team members.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const usage = await this.getUsageForUserId(user.id, options);
|
|
||||||
const limit = subscription.plan.limits.teamMembers;
|
|
||||||
|
|
||||||
if (usage.teamMembers + quantity > limit) {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
code: 403,
|
|
||||||
message: getLimitMessage(
|
|
||||||
subscription.plan,
|
|
||||||
usage.teamMembers,
|
|
||||||
limit,
|
|
||||||
'team members',
|
|
||||||
{ remediation: 'remove or disable a team member before inviting another.' },
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { allowed: true, usage, subscription };
|
|
||||||
}
|
|
||||||
|
|
||||||
static async assertCanCreateTeamMembers(currentUserOrId, quantity = 1, options = {}) {
|
|
||||||
const result = await this.canCreateTeamMembers(currentUserOrId, quantity, options);
|
|
||||||
|
|
||||||
if (!result.allowed) {
|
|
||||||
throw httpError(result.message, result.code);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async canConnectPaymentProvider(currentUserOrId, connectedField, options = {}) {
|
|
||||||
const user = await getUserRecord(currentUserOrId, options);
|
|
||||||
const subscription = getEffectiveSubscription(user);
|
|
||||||
|
|
||||||
if (await isSubscriptionLimitExemptUser(user, options)) {
|
|
||||||
return {
|
|
||||||
allowed: true,
|
|
||||||
usage: null,
|
|
||||||
subscription,
|
|
||||||
subscriptionExempt: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!subscription.isActive) {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
code: 403,
|
|
||||||
message: 'Your Review Flow trial has ended. Choose a plan to keep connecting payment providers.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!PAYMENT_CONNECTOR_FIELDS.includes(connectedField)) {
|
|
||||||
throw httpError('Unknown payment provider connector.', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const usage = await this.getUsageForUserId(user.id, options);
|
|
||||||
const limit = subscription.plan.limits.paymentConnectors;
|
|
||||||
|
|
||||||
if (usage.paymentConnectors + 1 > limit) {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
code: 403,
|
|
||||||
message: getLimitMessage(
|
|
||||||
subscription.plan,
|
|
||||||
usage.paymentConnectors,
|
|
||||||
limit,
|
|
||||||
'connected payment providers',
|
|
||||||
{ remediation: 'disconnect a payment provider before connecting another.' },
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { allowed: true, usage, subscription };
|
|
||||||
}
|
|
||||||
|
|
||||||
static async assertCanConnectPaymentProvider(currentUserOrId, connectedField, options = {}) {
|
|
||||||
const result = await this.canConnectPaymentProvider(currentUserOrId, connectedField, options);
|
|
||||||
|
|
||||||
if (!result.allowed) {
|
|
||||||
throw httpError(result.message, result.code);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async assertFeatureAccess(currentUserOrId, featureKey, options = {}) {
|
|
||||||
const user = await getUserRecord(currentUserOrId, options);
|
|
||||||
const subscription = getEffectiveSubscription(user);
|
|
||||||
|
|
||||||
if (await isSubscriptionLimitExemptUser(user, options)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!subscription.isActive) {
|
|
||||||
throw httpError('Your Review Flow trial has ended. Choose a plan to keep using this feature.', 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!subscription.plan.includedFeatureKeys.includes(featureKey)) {
|
|
||||||
throw httpError(`${subscription.plan.name} does not include this feature. Upgrade to Pro to unlock it.`, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,113 +0,0 @@
|
|||||||
const TRIAL_DAYS = 14;
|
|
||||||
|
|
||||||
const subscriptionPlans = [
|
|
||||||
{
|
|
||||||
id: 'starter',
|
|
||||||
name: 'Starter',
|
|
||||||
priceMonthly: 49,
|
|
||||||
currency: 'USD',
|
|
||||||
trialDays: TRIAL_DAYS,
|
|
||||||
tagline: 'For small teams that want automated review collection without extra marketing automation.',
|
|
||||||
limits: {
|
|
||||||
monthlyReviewRequests: 250,
|
|
||||||
businesses: 1,
|
|
||||||
teamMembers: 2,
|
|
||||||
paymentConnectors: 5,
|
|
||||||
},
|
|
||||||
features: [
|
|
||||||
'Review Flow dashboard',
|
|
||||||
'Manual review request creation',
|
|
||||||
'Hosted public review form',
|
|
||||||
'Customer management',
|
|
||||||
'Business profile management',
|
|
||||||
'Transaction tracking',
|
|
||||||
'Stripe, Square, PayPal, Shopify, and WooCommerce webhook intake',
|
|
||||||
'Review request status tracking',
|
|
||||||
'Email delivery logs',
|
|
||||||
'Basic reporting',
|
|
||||||
'Standard support',
|
|
||||||
],
|
|
||||||
includedFeatureKeys: [
|
|
||||||
'reviewflow_dashboard',
|
|
||||||
'manual_review_requests',
|
|
||||||
'hosted_review_form',
|
|
||||||
'customer_management',
|
|
||||||
'business_management',
|
|
||||||
'transaction_tracking',
|
|
||||||
'payment_webhooks',
|
|
||||||
'review_status_tracking',
|
|
||||||
'email_delivery_logs',
|
|
||||||
'basic_reporting',
|
|
||||||
'standard_support',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'pro',
|
|
||||||
name: 'Pro',
|
|
||||||
priceMonthly: 99,
|
|
||||||
currency: 'USD',
|
|
||||||
trialDays: TRIAL_DAYS,
|
|
||||||
tagline: 'For growing businesses that want automation, AI assistance, and reputation marketing tools.',
|
|
||||||
limits: {
|
|
||||||
monthlyReviewRequests: 2500,
|
|
||||||
businesses: 10,
|
|
||||||
teamMembers: 10,
|
|
||||||
paymentConnectors: 5,
|
|
||||||
},
|
|
||||||
features: [
|
|
||||||
'Everything in Starter',
|
|
||||||
'Advanced automation rules',
|
|
||||||
'AI review reply assistant',
|
|
||||||
'Social proof widgets',
|
|
||||||
'Review monitoring workspace',
|
|
||||||
'Referral campaigns',
|
|
||||||
'Repeat booking reminders',
|
|
||||||
'NPS surveys',
|
|
||||||
'Competitor/reputation insights',
|
|
||||||
'Broadcast campaigns',
|
|
||||||
'Advanced reporting',
|
|
||||||
'Branding customization',
|
|
||||||
'Priority support',
|
|
||||||
],
|
|
||||||
includedFeatureKeys: [
|
|
||||||
'reviewflow_dashboard',
|
|
||||||
'manual_review_requests',
|
|
||||||
'hosted_review_form',
|
|
||||||
'customer_management',
|
|
||||||
'business_management',
|
|
||||||
'transaction_tracking',
|
|
||||||
'payment_webhooks',
|
|
||||||
'review_status_tracking',
|
|
||||||
'email_delivery_logs',
|
|
||||||
'basic_reporting',
|
|
||||||
'standard_support',
|
|
||||||
'advanced_automation',
|
|
||||||
'ai_review_replies',
|
|
||||||
'social_proof_widgets',
|
|
||||||
'review_monitoring',
|
|
||||||
'referral_campaigns',
|
|
||||||
'repeat_booking_reminders',
|
|
||||||
'nps_surveys',
|
|
||||||
'competitor_insights',
|
|
||||||
'broadcast_campaigns',
|
|
||||||
'advanced_reporting',
|
|
||||||
'branding_customization',
|
|
||||||
'priority_support',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const getSubscriptionPlans = () => subscriptionPlans.map((plan) => ({
|
|
||||||
...plan,
|
|
||||||
limits: { ...plan.limits },
|
|
||||||
features: [...plan.features],
|
|
||||||
includedFeatureKeys: [...plan.includedFeatureKeys],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const getSubscriptionPlanById = (planId) => getSubscriptionPlans().find((plan) => plan.id === planId);
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
TRIAL_DAYS,
|
|
||||||
getSubscriptionPlanById,
|
|
||||||
getSubscriptionPlans,
|
|
||||||
};
|
|
||||||
@ -3,12 +3,14 @@ const UsersDBApi = require('../db/api/users');
|
|||||||
const processFile = require("../middlewares/upload");
|
const processFile = require("../middlewares/upload");
|
||||||
const ValidationError = require('./notifications/errors/validation');
|
const ValidationError = require('./notifications/errors/validation');
|
||||||
const csv = require('csv-parser');
|
const csv = require('csv-parser');
|
||||||
|
const axios = require('axios');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const stream = require('stream');
|
const stream = require('stream');
|
||||||
|
|
||||||
|
|
||||||
|
const InvitationEmail = require('./email/list/invitation');
|
||||||
|
const EmailSender = require('./email');
|
||||||
const AuthService = require('./auth');
|
const AuthService = require('./auth');
|
||||||
const SubscriptionService = require('./subscription');
|
|
||||||
|
|
||||||
module.exports = class UsersService {
|
module.exports = class UsersService {
|
||||||
static async create(data, currentUser, sendInvitationEmails = true, host) {
|
static async create(data, currentUser, sendInvitationEmails = true, host) {
|
||||||
@ -24,7 +26,6 @@ module.exports = class UsersService {
|
|||||||
'iam.errors.userAlreadyExists',
|
'iam.errors.userAlreadyExists',
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await SubscriptionService.assertCanCreateTeamMembers(currentUser, 1, { transaction });
|
|
||||||
await UsersDBApi.create(
|
await UsersDBApi.create(
|
||||||
{data},
|
{data},
|
||||||
|
|
||||||
@ -78,8 +79,6 @@ module.exports = class UsersService {
|
|||||||
throw new ValidationError('importer.errors.userEmailMissing');
|
throw new ValidationError('importer.errors.userEmailMissing');
|
||||||
}
|
}
|
||||||
|
|
||||||
await SubscriptionService.assertCanCreateTeamMembers(req.currentUser, results.length, { transaction });
|
|
||||||
|
|
||||||
await UsersDBApi.bulkImport(results, {
|
await UsersDBApi.bulkImport(results, {
|
||||||
transaction,
|
transaction,
|
||||||
ignoreDuplicates: true,
|
ignoreDuplicates: true,
|
||||||
@ -135,7 +134,7 @@ module.exports = class UsersService {
|
|||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
static async remove(id, currentUser) {
|
static async remove(id, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|||||||
1370
backend/yarn.lock
1370
backend/yarn.lock
File diff suppressed because it is too large
Load Diff
@ -39,7 +39,7 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
|
|||||||
>
|
>
|
||||||
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0">
|
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0">
|
||||||
|
|
||||||
<b className="font-black">Review Flow</b>
|
<b className="font-black">ReviewFlow</b>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -90,7 +90,7 @@ const CardBusinesses = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Business name</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>BusinessName</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.name }
|
{ item.name }
|
||||||
@ -102,7 +102,7 @@ const CardBusinesses = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Google review link</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>GoogleReviewLink</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.google_review_link }
|
{ item.google_review_link }
|
||||||
@ -114,7 +114,7 @@ const CardBusinesses = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Yelp review link</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>YelpReviewLink</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.yelp_review_link }
|
{ item.yelp_review_link }
|
||||||
@ -126,7 +126,7 @@ const CardBusinesses = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Facebook review link</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>FacebookReviewLink</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.facebook_review_link }
|
{ item.facebook_review_link }
|
||||||
@ -138,7 +138,7 @@ const CardBusinesses = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Review delay days</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>DelayDays</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.delay_days }
|
{ item.delay_days }
|
||||||
@ -150,7 +150,7 @@ const CardBusinesses = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Email subject template</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>EmailSubjectTemplate</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.email_subject_template }
|
{ item.email_subject_template }
|
||||||
@ -162,7 +162,7 @@ const CardBusinesses = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Email body template</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>EmailBodyTemplate</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.email_body_template }
|
{ item.email_body_template }
|
||||||
@ -174,7 +174,7 @@ const CardBusinesses = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Active</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>IsActive</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ dataFormatter.booleanFormatter(item.is_active) }
|
{ dataFormatter.booleanFormatter(item.is_active) }
|
||||||
@ -186,7 +186,7 @@ const CardBusinesses = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Stripe account reference</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>StripeAccountReference</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.stripe_account_reference }
|
{ item.stripe_account_reference }
|
||||||
@ -198,7 +198,7 @@ const CardBusinesses = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Stripe connected</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>StripeConnected</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ dataFormatter.booleanFormatter(item.stripe_connected) }
|
{ dataFormatter.booleanFormatter(item.stripe_connected) }
|
||||||
@ -210,7 +210,7 @@ const CardBusinesses = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Stripe connected at</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>StripeConnectedAt</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ dataFormatter.dateTimeFormatter(item.stripe_connected_at) }
|
{ dataFormatter.dateTimeFormatter(item.stripe_connected_at) }
|
||||||
@ -222,7 +222,7 @@ const CardBusinesses = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Default review platform</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>DefaultReviewPlatform</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.default_review_platform }
|
{ item.default_review_platform }
|
||||||
@ -234,7 +234,7 @@ const CardBusinesses = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Custom review link</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>CustomReviewLink</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.custom_review_link }
|
{ item.custom_review_link }
|
||||||
|
|||||||
@ -56,7 +56,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
|||||||
|
|
||||||
|
|
||||||
<div className={'flex-1 px-3'}>
|
<div className={'flex-1 px-3'}>
|
||||||
<p className={'text-xs text-gray-500 '}>Business name</p>
|
<p className={'text-xs text-gray-500 '}>BusinessName</p>
|
||||||
<p className={'line-clamp-2'}>{ item.name }</p>
|
<p className={'line-clamp-2'}>{ item.name }</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -64,7 +64,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
|||||||
|
|
||||||
|
|
||||||
<div className={'flex-1 px-3'}>
|
<div className={'flex-1 px-3'}>
|
||||||
<p className={'text-xs text-gray-500 '}>Google review link</p>
|
<p className={'text-xs text-gray-500 '}>GoogleReviewLink</p>
|
||||||
<p className={'line-clamp-2'}>{ item.google_review_link }</p>
|
<p className={'line-clamp-2'}>{ item.google_review_link }</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -72,7 +72,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
|||||||
|
|
||||||
|
|
||||||
<div className={'flex-1 px-3'}>
|
<div className={'flex-1 px-3'}>
|
||||||
<p className={'text-xs text-gray-500 '}>Yelp review link</p>
|
<p className={'text-xs text-gray-500 '}>YelpReviewLink</p>
|
||||||
<p className={'line-clamp-2'}>{ item.yelp_review_link }</p>
|
<p className={'line-clamp-2'}>{ item.yelp_review_link }</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -80,7 +80,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
|||||||
|
|
||||||
|
|
||||||
<div className={'flex-1 px-3'}>
|
<div className={'flex-1 px-3'}>
|
||||||
<p className={'text-xs text-gray-500 '}>Facebook review link</p>
|
<p className={'text-xs text-gray-500 '}>FacebookReviewLink</p>
|
||||||
<p className={'line-clamp-2'}>{ item.facebook_review_link }</p>
|
<p className={'line-clamp-2'}>{ item.facebook_review_link }</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -88,7 +88,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
|||||||
|
|
||||||
|
|
||||||
<div className={'flex-1 px-3'}>
|
<div className={'flex-1 px-3'}>
|
||||||
<p className={'text-xs text-gray-500 '}>Review delay days</p>
|
<p className={'text-xs text-gray-500 '}>DelayDays</p>
|
||||||
<p className={'line-clamp-2'}>{ item.delay_days }</p>
|
<p className={'line-clamp-2'}>{ item.delay_days }</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -96,7 +96,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
|||||||
|
|
||||||
|
|
||||||
<div className={'flex-1 px-3'}>
|
<div className={'flex-1 px-3'}>
|
||||||
<p className={'text-xs text-gray-500 '}>Email subject template</p>
|
<p className={'text-xs text-gray-500 '}>EmailSubjectTemplate</p>
|
||||||
<p className={'line-clamp-2'}>{ item.email_subject_template }</p>
|
<p className={'line-clamp-2'}>{ item.email_subject_template }</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -104,7 +104,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
|||||||
|
|
||||||
|
|
||||||
<div className={'flex-1 px-3'}>
|
<div className={'flex-1 px-3'}>
|
||||||
<p className={'text-xs text-gray-500 '}>Email body template</p>
|
<p className={'text-xs text-gray-500 '}>EmailBodyTemplate</p>
|
||||||
<p className={'line-clamp-2'}>{ item.email_body_template }</p>
|
<p className={'line-clamp-2'}>{ item.email_body_template }</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -112,7 +112,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
|||||||
|
|
||||||
|
|
||||||
<div className={'flex-1 px-3'}>
|
<div className={'flex-1 px-3'}>
|
||||||
<p className={'text-xs text-gray-500 '}>Active</p>
|
<p className={'text-xs text-gray-500 '}>IsActive</p>
|
||||||
<p className={'line-clamp-2'}>{ dataFormatter.booleanFormatter(item.is_active) }</p>
|
<p className={'line-clamp-2'}>{ dataFormatter.booleanFormatter(item.is_active) }</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -120,7 +120,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
|||||||
|
|
||||||
|
|
||||||
<div className={'flex-1 px-3'}>
|
<div className={'flex-1 px-3'}>
|
||||||
<p className={'text-xs text-gray-500 '}>Stripe account reference</p>
|
<p className={'text-xs text-gray-500 '}>StripeAccountReference</p>
|
||||||
<p className={'line-clamp-2'}>{ item.stripe_account_reference }</p>
|
<p className={'line-clamp-2'}>{ item.stripe_account_reference }</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -128,7 +128,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
|||||||
|
|
||||||
|
|
||||||
<div className={'flex-1 px-3'}>
|
<div className={'flex-1 px-3'}>
|
||||||
<p className={'text-xs text-gray-500 '}>Stripe connected</p>
|
<p className={'text-xs text-gray-500 '}>StripeConnected</p>
|
||||||
<p className={'line-clamp-2'}>{ dataFormatter.booleanFormatter(item.stripe_connected) }</p>
|
<p className={'line-clamp-2'}>{ dataFormatter.booleanFormatter(item.stripe_connected) }</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -136,7 +136,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
|||||||
|
|
||||||
|
|
||||||
<div className={'flex-1 px-3'}>
|
<div className={'flex-1 px-3'}>
|
||||||
<p className={'text-xs text-gray-500 '}>Stripe connected at</p>
|
<p className={'text-xs text-gray-500 '}>StripeConnectedAt</p>
|
||||||
<p className={'line-clamp-2'}>{ dataFormatter.dateTimeFormatter(item.stripe_connected_at) }</p>
|
<p className={'line-clamp-2'}>{ dataFormatter.dateTimeFormatter(item.stripe_connected_at) }</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -144,7 +144,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
|||||||
|
|
||||||
|
|
||||||
<div className={'flex-1 px-3'}>
|
<div className={'flex-1 px-3'}>
|
||||||
<p className={'text-xs text-gray-500 '}>Default review platform</p>
|
<p className={'text-xs text-gray-500 '}>DefaultReviewPlatform</p>
|
||||||
<p className={'line-clamp-2'}>{ item.default_review_platform }</p>
|
<p className={'line-clamp-2'}>{ item.default_review_platform }</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -152,7 +152,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
|||||||
|
|
||||||
|
|
||||||
<div className={'flex-1 px-3'}>
|
<div className={'flex-1 px-3'}>
|
||||||
<p className={'text-xs text-gray-500 '}>Custom review link</p>
|
<p className={'text-xs text-gray-500 '}>CustomReviewLink</p>
|
||||||
<p className={'line-clamp-2'}>{ item.custom_review_link }</p>
|
<p className={'line-clamp-2'}>{ item.custom_review_link }</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -65,7 +65,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'name',
|
field: 'name',
|
||||||
headerName: 'Business name',
|
headerName: 'BusinessName',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -80,7 +80,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'google_review_link',
|
field: 'google_review_link',
|
||||||
headerName: 'Google review link',
|
headerName: 'GoogleReviewLink',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -95,7 +95,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'yelp_review_link',
|
field: 'yelp_review_link',
|
||||||
headerName: 'Yelp review link',
|
headerName: 'YelpReviewLink',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -110,7 +110,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'facebook_review_link',
|
field: 'facebook_review_link',
|
||||||
headerName: 'Facebook review link',
|
headerName: 'FacebookReviewLink',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -125,7 +125,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'delay_days',
|
field: 'delay_days',
|
||||||
headerName: 'Review delay days',
|
headerName: 'DelayDays',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -141,7 +141,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'email_subject_template',
|
field: 'email_subject_template',
|
||||||
headerName: 'Email subject template',
|
headerName: 'EmailSubjectTemplate',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -156,7 +156,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'email_body_template',
|
field: 'email_body_template',
|
||||||
headerName: 'Email body template',
|
headerName: 'EmailBodyTemplate',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -171,7 +171,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'is_active',
|
field: 'is_active',
|
||||||
headerName: 'Active',
|
headerName: 'IsActive',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -187,7 +187,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'stripe_account_reference',
|
field: 'stripe_account_reference',
|
||||||
headerName: 'Stripe account reference',
|
headerName: 'StripeAccountReference',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -202,7 +202,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'stripe_connected',
|
field: 'stripe_connected',
|
||||||
headerName: 'Stripe connected',
|
headerName: 'StripeConnected',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -218,7 +218,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'stripe_connected_at',
|
field: 'stripe_connected_at',
|
||||||
headerName: 'Stripe connected at',
|
headerName: 'StripeConnectedAt',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -236,7 +236,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'default_review_platform',
|
field: 'default_review_platform',
|
||||||
headerName: 'Default review platform',
|
headerName: 'DefaultReviewPlatform',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -251,7 +251,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'custom_review_link',
|
field: 'custom_review_link',
|
||||||
headerName: 'Custom review link',
|
headerName: 'CustomReviewLink',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
|
|||||||
@ -152,7 +152,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
type: 'dateTime',
|
type: 'dateTime',
|
||||||
valueGetter: (params: GridValueGetterParams) =>
|
valueGetter: (params: GridValueGetterParams) =>
|
||||||
params.row.last_transaction_at ? new Date(params.row.last_transaction_at) : null,
|
new Date(params.row.last_transaction_at),
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, {useEffect, useRef} from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { useState } from 'react'
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
@ -11,7 +12,6 @@ import { setDarkMode } from '../stores/styleSlice'
|
|||||||
import { logoutUser } from '../stores/authSlice'
|
import { logoutUser } from '../stores/authSlice'
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import ClickOutside from "./ClickOutside";
|
import ClickOutside from "./ClickOutside";
|
||||||
import { getPortalLabel } from '../helpers/portalRoles';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item: MenuNavBarItem
|
item: MenuNavBarItem
|
||||||
@ -30,9 +30,7 @@ export default function NavBarItem({ item }: Props) {
|
|||||||
|
|
||||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
||||||
|
|
||||||
const userName = `${currentUser?.firstName ? currentUser?.firstName : ""} ${currentUser?.lastName ? currentUser?.lastName : ""}`.trim();
|
const userName = `${currentUser?.firstName ? currentUser?.firstName : ""} ${currentUser?.lastName ? currentUser?.lastName : ""}`;
|
||||||
const userDisplayName = userName || currentUser?.email || '';
|
|
||||||
const portalLabel = currentUser ? getPortalLabel(currentUser) : '';
|
|
||||||
|
|
||||||
const [isDropdownActive, setIsDropdownActive] = useState(false)
|
const [isDropdownActive, setIsDropdownActive] = useState(false)
|
||||||
|
|
||||||
@ -49,7 +47,7 @@ export default function NavBarItem({ item }: Props) {
|
|||||||
item.isDesktopNoLabel ? 'lg:w-16 lg:justify-center' : '',
|
item.isDesktopNoLabel ? 'lg:w-16 lg:justify-center' : '',
|
||||||
].join(' ')
|
].join(' ')
|
||||||
|
|
||||||
const itemLabel = item.isCurrentUser ? userDisplayName : item.label
|
const itemLabel = item.isCurrentUser ? userName : item.label
|
||||||
|
|
||||||
const handleMenuClick = () => {
|
const handleMenuClick = () => {
|
||||||
if (item.menu) {
|
if (item.menu) {
|
||||||
@ -94,12 +92,7 @@ export default function NavBarItem({ item }: Props) {
|
|||||||
item.isDesktopNoLabel && item.icon ? 'lg:hidden' : ''
|
item.isDesktopNoLabel && item.icon ? 'lg:hidden' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{item.isCurrentUser ? (
|
{itemLabel}
|
||||||
<span className='flex flex-col leading-tight'>
|
|
||||||
<span>{itemLabel}</span>
|
|
||||||
<span className='text-[10px] uppercase tracking-wider opacity-70'>{portalLabel}</span>
|
|
||||||
</span>
|
|
||||||
) : itemLabel}
|
|
||||||
</span>
|
</span>
|
||||||
{item.isCurrentUser && <UserAvatarCurrentUser className="w-6 h-6 mr-3 inline-flex" />}
|
{item.isCurrentUser && <UserAvatarCurrentUser className="w-6 h-6 mr-3 inline-flex" />}
|
||||||
{item.menu && (
|
{item.menu && (
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -63,39 +63,9 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
|
||||||
field: 'provider',
|
|
||||||
headerName: 'Provider',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'provider_event_type',
|
|
||||||
headerName: 'ProviderEventType',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 160,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
{
|
||||||
field: 'stripe_event_reference',
|
field: 'stripe_event_reference',
|
||||||
headerName: 'EventReference',
|
headerName: 'StripeEventReference',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
|
|||||||
@ -1,146 +0,0 @@
|
|||||||
import { mdiCreditCardOutline } from '@mdi/js'
|
|
||||||
import axios from 'axios'
|
|
||||||
import React, { useEffect, useState } from 'react'
|
|
||||||
import BaseButton from './BaseButton'
|
|
||||||
import CardBox from './CardBox'
|
|
||||||
import { useAppSelector } from '../stores/hooks'
|
|
||||||
import { isInternalAdmin } from '../helpers/portalRoles'
|
|
||||||
|
|
||||||
type LimitKey = 'monthlyReviewRequests' | 'businesses' | 'teamMembers' | 'paymentConnectors'
|
|
||||||
|
|
||||||
type SubscriptionLimitStatus = {
|
|
||||||
subscription: {
|
|
||||||
planId: string
|
|
||||||
planName: string
|
|
||||||
effectiveStatus: string
|
|
||||||
isActive: boolean
|
|
||||||
}
|
|
||||||
usage: Record<LimitKey, number>
|
|
||||||
limits: Record<LimitKey, number>
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
limitKey: LimitKey
|
|
||||||
actionLabel: string
|
|
||||||
label?: string
|
|
||||||
className?: string
|
|
||||||
nearLimitPercent?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultLabels: Record<LimitKey, string> = {
|
|
||||||
monthlyReviewRequests: 'review requests this month',
|
|
||||||
businesses: 'business profiles',
|
|
||||||
teamMembers: 'team members',
|
|
||||||
paymentConnectors: 'connected payment providers',
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatNumber(value: number) {
|
|
||||||
return value.toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SubscriptionLimitGate({
|
|
||||||
limitKey,
|
|
||||||
actionLabel,
|
|
||||||
label,
|
|
||||||
className = 'mb-6',
|
|
||||||
nearLimitPercent = 80,
|
|
||||||
}: Props) {
|
|
||||||
const { currentUser } = useAppSelector((state) => state.auth)
|
|
||||||
const [status, setStatus] = useState<SubscriptionLimitStatus | null>(null)
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let isMounted = true
|
|
||||||
|
|
||||||
const loadStatus = async () => {
|
|
||||||
try {
|
|
||||||
const response = await axios.get('/subscription/me')
|
|
||||||
|
|
||||||
if (isMounted) {
|
|
||||||
setStatus(response.data)
|
|
||||||
setError('')
|
|
||||||
}
|
|
||||||
} catch (requestError) {
|
|
||||||
console.error('Failed to load subscription limit status:', requestError)
|
|
||||||
|
|
||||||
if (isMounted) {
|
|
||||||
setError('Could not check plan limits right now. The backend will still enforce them when you submit.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentUser || isInternalAdmin(currentUser)) {
|
|
||||||
setStatus(null)
|
|
||||||
setError('')
|
|
||||||
return () => {
|
|
||||||
isMounted = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadStatus()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false
|
|
||||||
}
|
|
||||||
}, [currentUser])
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<CardBox className={`${className} border-0 bg-amber-50 text-amber-950 ring-1 ring-amber-200 dark:bg-amber-950 dark:text-amber-50 dark:ring-amber-800`}>
|
|
||||||
<p className='text-sm font-black uppercase tracking-[0.25em]'>Plan check unavailable</p>
|
|
||||||
<p className='mt-2 text-sm leading-6'>{error}</p>
|
|
||||||
</CardBox>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!status) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const used = Number(status.usage[limitKey]) || 0
|
|
||||||
const limit = Number(status.limits[limitKey]) || 0
|
|
||||||
const limitLabel = label || (limit === 1 && limitKey === 'businesses'
|
|
||||||
? 'business profile'
|
|
||||||
: defaultLabels[limitKey])
|
|
||||||
const percent = limit > 0 ? Math.round((used / limit) * 100) : 0
|
|
||||||
const isInactive = !status.subscription.isActive
|
|
||||||
const isBlocked = isInactive || (limit > 0 && used >= limit)
|
|
||||||
const isNearLimit = !isBlocked && percent >= nearLimitPercent
|
|
||||||
|
|
||||||
if (!isBlocked && !isNearLimit) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const cardClass = isBlocked
|
|
||||||
? 'border-0 bg-rose-50 text-rose-950 ring-1 ring-rose-200 dark:bg-rose-950 dark:text-rose-50 dark:ring-rose-800'
|
|
||||||
: 'border-0 bg-amber-50 text-amber-950 ring-1 ring-amber-200 dark:bg-amber-950 dark:text-amber-50 dark:ring-amber-800'
|
|
||||||
const buttonLabel = status.subscription.planId === 'starter' ? 'Upgrade to Pro' : 'Manage plan'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CardBox className={`${className} ${cardClass}`}>
|
|
||||||
<div className='flex flex-col gap-4 md:flex-row md:items-center md:justify-between'>
|
|
||||||
<div>
|
|
||||||
<p className='text-sm font-black uppercase tracking-[0.25em]'>
|
|
||||||
{isBlocked ? 'Plan limit reached' : 'Plan limit almost reached'}
|
|
||||||
</p>
|
|
||||||
<h3 className='mt-2 text-xl font-black'>
|
|
||||||
{actionLabel} {isBlocked ? 'may be blocked' : 'is getting close to the limit'}
|
|
||||||
</h3>
|
|
||||||
<p className='mt-2 text-sm leading-6'>
|
|
||||||
{isInactive
|
|
||||||
? `Your ${status.subscription.planName} plan is ${status.subscription.effectiveStatus}. Reactivate or choose a plan before continuing.`
|
|
||||||
: `${status.subscription.planName} includes ${formatNumber(limit)} ${limitLabel}. This account is using ${formatNumber(used)}.`}
|
|
||||||
{' '}Existing data stays available.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<BaseButton
|
|
||||||
href='/subscription'
|
|
||||||
icon={mdiCreditCardOutline}
|
|
||||||
label={buttonLabel}
|
|
||||||
color={isBlocked ? 'danger' : 'warning'}
|
|
||||||
className='self-start md:self-center'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -63,24 +63,9 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
|
||||||
field: 'payment_provider',
|
|
||||||
headerName: 'Provider',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
{
|
||||||
field: 'stripe_payment_reference',
|
field: 'stripe_payment_reference',
|
||||||
headerName: 'PaymentReference',
|
headerName: 'StripePaymentReference',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
|
|||||||
@ -1,26 +0,0 @@
|
|||||||
const starterPlanId = 'starter'
|
|
||||||
|
|
||||||
export function isStarterPlan(planId?: string | null) {
|
|
||||||
return planId === starterPlanId
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBusinessMenuLabel(planId?: string | null, businessLimit?: number | null) {
|
|
||||||
return isStarterPlan(planId) || Number(businessLimit) === 1 ? 'Business' : 'Businesses'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBusinessProfileNoun(count?: number | null) {
|
|
||||||
return Number(count) === 1 ? 'business profile' : 'business profiles'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBusinessProfileLimitLabel(limit?: number | null) {
|
|
||||||
const numericLimit = Number(limit) || 0
|
|
||||||
|
|
||||||
return `${numericLimit.toLocaleString()} ${getBusinessProfileNoun(numericLimit)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBusinessProfileUsageLabel(used?: number | null, limit?: number | null) {
|
|
||||||
const numericUsed = Number(used) || 0
|
|
||||||
const numericLimit = Number(limit) || 0
|
|
||||||
|
|
||||||
return `${numericUsed.toLocaleString()} / ${getBusinessProfileLimitLabel(numericLimit)}`
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
const internalAdminRoleNames = ['Administrator']
|
|
||||||
|
|
||||||
export function getRoleName(user?: any) {
|
|
||||||
return user?.app_role?.name || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isInternalAdmin(user?: any) {
|
|
||||||
const roleName = getRoleName(user)
|
|
||||||
|
|
||||||
return internalAdminRoleNames.includes(roleName) || user?.email === 'admin@flatlogic.com'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPortalLabel(user?: any) {
|
|
||||||
return isInternalAdmin(user) ? 'Internal Admin Portal' : 'Customer Workspace'
|
|
||||||
}
|
|
||||||
@ -1,21 +1,18 @@
|
|||||||
|
|
||||||
export function hasPermission(user, permission_name?: string | string[]) {
|
export function hasPermission(user, permission_name: string | string[]) {
|
||||||
if (!user?.app_role?.name) return false
|
if (!user?.app_role?.name) return false;
|
||||||
if (!permission_name) {
|
if (!permission_name) {
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.app_role.name === 'Administrator') return true
|
|
||||||
|
|
||||||
const permissions = new Set<string>([
|
const permissions = new Set<string>([
|
||||||
...(user?.custom_permissions ?? []).map((p) => p.name),
|
...(user?.custom_permissions ?? []).map((p) => p.name),
|
||||||
...(user?.app_role_permissions ?? []).map((p) => p.name),
|
...(user?.app_role_permissions ?? []).map((p) => p.name),
|
||||||
])
|
]);
|
||||||
|
|
||||||
if (typeof permission_name === 'string') {
|
if (typeof permission_name === 'string') {
|
||||||
return permissions.has(permission_name)
|
return permissions.has(permission_name) || user.app_role.name === 'Administrator'
|
||||||
|
} else {
|
||||||
|
return permission_name.some((permission) => permissions.has(permission));
|
||||||
}
|
}
|
||||||
|
|
||||||
return permission_name.some((permission) => permissions.has(permission))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import React, { ReactNode, useEffect, useMemo, useState } from 'react'
|
import React, { ReactNode, useEffect } from 'react'
|
||||||
|
import { useState } from 'react'
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import { getMenuAsideForUser } from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
import menuNavBar from '../menuNavBar'
|
import menuNavBar from '../menuNavBar'
|
||||||
import BaseIcon from '../components/BaseIcon'
|
import BaseIcon from '../components/BaseIcon'
|
||||||
import NavBar from '../components/NavBar'
|
import NavBar from '../components/NavBar'
|
||||||
@ -14,23 +15,19 @@ import { useRouter } from 'next/router'
|
|||||||
import {findMe, logoutUser} from "../stores/authSlice";
|
import {findMe, logoutUser} from "../stores/authSlice";
|
||||||
|
|
||||||
import {hasPermission} from "../helpers/userPermissions";
|
import {hasPermission} from "../helpers/userPermissions";
|
||||||
import { getBusinessMenuLabel } from '../helpers/businessPlanLabels';
|
|
||||||
import { getPortalLabel, isInternalAdmin } from '../helpers/portalRoles';
|
|
||||||
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
|
||||||
permission?: string
|
permission?: string
|
||||||
portal?: 'admin' | 'customer'
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LayoutAuthenticated({
|
export default function LayoutAuthenticated({
|
||||||
children,
|
children,
|
||||||
|
|
||||||
permission,
|
permission
|
||||||
portal
|
|
||||||
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
@ -66,39 +63,12 @@ export default function LayoutAuthenticated({
|
|||||||
|
|
||||||
if (!hasPermission(currentUser, permission)) router.push('/error');
|
if (!hasPermission(currentUser, permission)) router.push('/error');
|
||||||
}, [currentUser, permission]);
|
}, [currentUser, permission]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!portal || !currentUser) return;
|
|
||||||
|
|
||||||
const isAdminPortal = isInternalAdmin(currentUser);
|
|
||||||
|
|
||||||
if ((portal === 'admin' && !isAdminPortal) || (portal === 'customer' && isAdminPortal)) {
|
|
||||||
router.push('/dashboard');
|
|
||||||
}
|
|
||||||
}, [currentUser, portal, router]);
|
|
||||||
|
|
||||||
|
|
||||||
const darkMode = useAppSelector((state) => state.style.darkMode)
|
const darkMode = useAppSelector((state) => state.style.darkMode)
|
||||||
|
|
||||||
const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false)
|
const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false)
|
||||||
const [isAsideLgActive, setIsAsideLgActive] = useState(false)
|
const [isAsideLgActive, setIsAsideLgActive] = useState(false)
|
||||||
const businessMenuLabel = isInternalAdmin(currentUser)
|
|
||||||
? 'Business profiles'
|
|
||||||
: getBusinessMenuLabel(currentUser?.subscriptionPlanId)
|
|
||||||
const portalLabel = getPortalLabel(currentUser)
|
|
||||||
const planAwareMenuAside = useMemo(() => getMenuAsideForUser(currentUser).map((item) => {
|
|
||||||
const children = item.menu?.map((child) => (
|
|
||||||
child.href === '/businesses/businesses-list'
|
|
||||||
? { ...child, label: businessMenuLabel }
|
|
||||||
: child
|
|
||||||
))
|
|
||||||
|
|
||||||
if (item.href === '/businesses/businesses-list') {
|
|
||||||
return { ...item, label: businessMenuLabel }
|
|
||||||
}
|
|
||||||
|
|
||||||
return children ? { ...item, menu: children } : item
|
|
||||||
}), [businessMenuLabel, currentUser])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleRouteChangeStart = () => {
|
const handleRouteChangeStart = () => {
|
||||||
@ -148,11 +118,11 @@ export default function LayoutAuthenticated({
|
|||||||
<AsideMenu
|
<AsideMenu
|
||||||
isAsideMobileExpanded={isAsideMobileExpanded}
|
isAsideMobileExpanded={isAsideMobileExpanded}
|
||||||
isAsideLgActive={isAsideLgActive}
|
isAsideLgActive={isAsideLgActive}
|
||||||
menu={planAwareMenuAside}
|
menu={menuAside}
|
||||||
onAsideLgClose={() => setIsAsideLgActive(false)}
|
onAsideLgClose={() => setIsAsideLgActive(false)}
|
||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
<FooterBar>{portalLabel} · ReviewFlow</FooterBar>
|
<FooterBar>Hand-crafted & Made with ❤️</FooterBar>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,193 +1,107 @@
|
|||||||
import * as icon from '@mdi/js'
|
import * as icon from '@mdi/js';
|
||||||
import { MenuAsideItem } from './interfaces'
|
import { MenuAsideItem } from './interfaces'
|
||||||
import { isInternalAdmin } from './helpers/portalRoles'
|
|
||||||
|
|
||||||
const storeIcon =
|
const menuAside: MenuAsideItem[] = [
|
||||||
'mdiStore' in icon
|
|
||||||
? icon['mdiStore' as keyof typeof icon]
|
|
||||||
: icon.mdiTable
|
|
||||||
|
|
||||||
const accountMultipleIcon =
|
|
||||||
'mdiAccountMultiple' in icon
|
|
||||||
? icon['mdiAccountMultiple' as keyof typeof icon]
|
|
||||||
: icon.mdiTable
|
|
||||||
|
|
||||||
const emailFastIcon =
|
|
||||||
'mdiEmailFastOutline' in icon
|
|
||||||
? icon['mdiEmailFastOutline' as keyof typeof icon]
|
|
||||||
: icon.mdiTable
|
|
||||||
|
|
||||||
const webhookIcon =
|
|
||||||
'mdiWebhook' in icon
|
|
||||||
? icon['mdiWebhook' as keyof typeof icon]
|
|
||||||
: icon.mdiTable
|
|
||||||
|
|
||||||
const emailCheckIcon =
|
|
||||||
'mdiEmailCheckOutline' in icon
|
|
||||||
? icon['mdiEmailCheckOutline' as keyof typeof icon]
|
|
||||||
: icon.mdiTable
|
|
||||||
|
|
||||||
const clockIcon =
|
|
||||||
'mdiClockOutline' in icon
|
|
||||||
? icon['mdiClockOutline' as keyof typeof icon]
|
|
||||||
: icon.mdiTable
|
|
||||||
|
|
||||||
export const customerMenuAside: MenuAsideItem[] = [
|
|
||||||
{
|
{
|
||||||
href: '/dashboard',
|
href: '/dashboard',
|
||||||
icon: icon.mdiViewDashboardOutline,
|
icon: icon.mdiViewDashboardOutline,
|
||||||
label: 'Workspace dashboard',
|
label: 'Dashboard',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
href: '/users/users-list',
|
||||||
|
label: 'Users',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
icon: icon.mdiAccountGroup ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_USERS'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/reviewflow',
|
href: '/roles/roles-list',
|
||||||
icon: icon.mdiStarOutline,
|
label: 'Roles',
|
||||||
label: 'Review Flow',
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_ROLES'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/permissions/permissions-list',
|
||||||
|
label: 'Permissions',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
icon: icon.mdiShieldAccountOutline ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_PERMISSIONS'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/businesses/businesses-list',
|
href: '/businesses/businesses-list',
|
||||||
label: 'Businesses',
|
label: 'Businesses',
|
||||||
icon: storeIcon,
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
permissions: 'READ_BUSINESSES',
|
// @ts-ignore
|
||||||
|
icon: 'mdiStore' in icon ? icon['mdiStore' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_BUSINESSES'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/customers/customers-list',
|
href: '/customers/customers-list',
|
||||||
label: 'Customers',
|
label: 'Customers',
|
||||||
icon: accountMultipleIcon,
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
permissions: 'READ_CUSTOMERS',
|
// @ts-ignore
|
||||||
|
icon: 'mdiAccountMultiple' in icon ? icon['mdiAccountMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_CUSTOMERS'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/transactions/transactions-list',
|
href: '/transactions/transactions-list',
|
||||||
label: 'Transactions',
|
label: 'Transactions',
|
||||||
icon: icon.mdiCreditCardOutline,
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
permissions: 'READ_TRANSACTIONS',
|
// @ts-ignore
|
||||||
|
icon: 'mdiCreditCardOutline' in icon ? icon['mdiCreditCardOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_TRANSACTIONS'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/review_requests/review_requests-list',
|
href: '/review_requests/review_requests-list',
|
||||||
label: 'Review requests',
|
label: 'Review requests',
|
||||||
icon: emailFastIcon,
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
permissions: 'READ_REVIEW_REQUESTS',
|
// @ts-ignore
|
||||||
|
icon: 'mdiEmailFastOutline' in icon ? icon['mdiEmailFastOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_REVIEW_REQUESTS'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/stripe_events/stripe_events-list',
|
||||||
|
label: 'Stripe events',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
icon: 'mdiWebhook' in icon ? icon['mdiWebhook' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_STRIPE_EVENTS'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/email_delivery_logs/email_delivery_logs-list',
|
href: '/email_delivery_logs/email_delivery_logs-list',
|
||||||
label: 'Email delivery',
|
label: 'Email delivery logs',
|
||||||
icon: emailCheckIcon,
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
permissions: 'READ_EMAIL_DELIVERY_LOGS',
|
// @ts-ignore
|
||||||
|
icon: 'mdiEmailCheckOutline' in icon ? icon['mdiEmailCheckOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_EMAIL_DELIVERY_LOGS'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/subscription',
|
href: '/cron_runs/cron_runs-list',
|
||||||
icon: icon.mdiCreditCardOutline,
|
label: 'Cron runs',
|
||||||
label: 'Subscription',
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
icon: 'mdiClockOutline' in icon ? icon['mdiClockOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_CRON_RUNS'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/profile',
|
href: '/profile',
|
||||||
label: 'Profile',
|
label: 'Profile',
|
||||||
icon: icon.mdiAccountCircle,
|
icon: icon.mdiAccountCircle,
|
||||||
},
|
},
|
||||||
]
|
|
||||||
|
|
||||||
export const internalAdminMenuAside: MenuAsideItem[] = [
|
|
||||||
{
|
{
|
||||||
href: '/dashboard',
|
href: '/api-docs',
|
||||||
icon: icon.mdiViewDashboardOutline,
|
target: '_blank',
|
||||||
label: 'Admin dashboard',
|
label: 'Swagger API',
|
||||||
},
|
icon: icon.mdiFileCode,
|
||||||
{
|
permissions: 'READ_API_DOCS'
|
||||||
label: 'Customer operations',
|
|
||||||
icon: icon.mdiAccountGroup,
|
|
||||||
permissions: ['READ_USERS', 'READ_BUSINESSES', 'READ_CUSTOMERS'],
|
|
||||||
menu: [
|
|
||||||
{
|
|
||||||
href: '/users/users-list',
|
|
||||||
label: 'Customer accounts',
|
|
||||||
icon: icon.mdiAccountGroup,
|
|
||||||
permissions: 'READ_USERS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/businesses/businesses-list',
|
|
||||||
label: 'Business profiles',
|
|
||||||
icon: storeIcon,
|
|
||||||
permissions: 'READ_BUSINESSES',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/customers/customers-list',
|
|
||||||
label: 'End customers',
|
|
||||||
icon: accountMultipleIcon,
|
|
||||||
permissions: 'READ_CUSTOMERS',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Review operations',
|
|
||||||
icon: icon.mdiStarOutline,
|
|
||||||
permissions: ['READ_REVIEW_REQUESTS', 'READ_EMAIL_DELIVERY_LOGS', 'READ_CRON_RUNS'],
|
|
||||||
menu: [
|
|
||||||
{
|
|
||||||
href: '/review_requests/review_requests-list',
|
|
||||||
label: 'Review requests',
|
|
||||||
icon: emailFastIcon,
|
|
||||||
permissions: 'READ_REVIEW_REQUESTS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/email_delivery_logs/email_delivery_logs-list',
|
|
||||||
label: 'Email delivery logs',
|
|
||||||
icon: emailCheckIcon,
|
|
||||||
permissions: 'READ_EMAIL_DELIVERY_LOGS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/cron_runs/cron_runs-list',
|
|
||||||
label: 'Automation runs',
|
|
||||||
icon: clockIcon,
|
|
||||||
permissions: 'READ_CRON_RUNS',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Billing & payments',
|
|
||||||
icon: icon.mdiCreditCardOutline,
|
|
||||||
permissions: ['READ_TRANSACTIONS', 'READ_STRIPE_EVENTS'],
|
|
||||||
menu: [
|
|
||||||
{
|
|
||||||
href: '/transactions/transactions-list',
|
|
||||||
label: 'Transactions',
|
|
||||||
icon: icon.mdiCreditCardOutline,
|
|
||||||
permissions: 'READ_TRANSACTIONS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/stripe_events/stripe_events-list',
|
|
||||||
label: 'Payment events',
|
|
||||||
icon: webhookIcon,
|
|
||||||
permissions: 'READ_STRIPE_EVENTS',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Access control',
|
|
||||||
icon: icon.mdiShieldAccountVariantOutline,
|
|
||||||
permissions: ['READ_ROLES', 'READ_PERMISSIONS'],
|
|
||||||
menu: [
|
|
||||||
{
|
|
||||||
href: '/roles/roles-list',
|
|
||||||
label: 'Roles',
|
|
||||||
icon: icon.mdiShieldAccountVariantOutline,
|
|
||||||
permissions: 'READ_ROLES',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/permissions/permissions-list',
|
|
||||||
label: 'Permissions',
|
|
||||||
icon: icon.mdiKeyVariant,
|
|
||||||
permissions: 'READ_PERMISSIONS',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/profile',
|
|
||||||
label: 'Profile',
|
|
||||||
icon: icon.mdiAccountCircle,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function getMenuAsideForUser(user?: any) {
|
export default menuAside
|
||||||
return isInternalAdmin(user) ? internalAdminMenuAside : customerMenuAside
|
|
||||||
}
|
|
||||||
|
|
||||||
export default customerMenuAside
|
|
||||||
|
|||||||
@ -149,7 +149,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
|
|||||||
setStepsEnabled(false);
|
setStepsEnabled(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const title = 'Review Flow'
|
const title = 'ReviewFlow'
|
||||||
const description = "Automate review-request emails after Stripe payments with templates, scheduling, and dashboard analytics."
|
const description = "Automate review-request emails after Stripe payments with templates, scheduling, and dashboard analytics."
|
||||||
const url = "https://flatlogic.com/"
|
const url = "https://flatlogic.com/"
|
||||||
const image = "https://project-screens.s3.amazonaws.com/screenshots/40346/app-hero-20260629-021915.png"
|
const image = "https://project-screens.s3.amazonaws.com/screenshots/40346/app-hero-20260629-021915.png"
|
||||||
|
|||||||
@ -469,10 +469,10 @@ const EditBusinesses = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Edit Business')}</title>
|
<title>{getPageTitle('Edit businesses')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='Edit Business' main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit businesses'} main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
<CardBox>
|
<CardBox>
|
||||||
@ -550,11 +550,11 @@ const EditBusinesses = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Business name"
|
label="BusinessName"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="name"
|
name="name"
|
||||||
placeholder="Business name"
|
placeholder="BusinessName"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -587,11 +587,11 @@ const EditBusinesses = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Google review link"
|
label="GoogleReviewLink"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="google_review_link"
|
name="google_review_link"
|
||||||
placeholder="Google review link"
|
placeholder="GoogleReviewLink"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -624,11 +624,11 @@ const EditBusinesses = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Yelp review link"
|
label="YelpReviewLink"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="yelp_review_link"
|
name="yelp_review_link"
|
||||||
placeholder="Yelp review link"
|
placeholder="YelpReviewLink"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -661,11 +661,11 @@ const EditBusinesses = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Facebook review link"
|
label="FacebookReviewLink"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="facebook_review_link"
|
name="facebook_review_link"
|
||||||
placeholder="Facebook review link"
|
placeholder="FacebookReviewLink"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -704,12 +704,12 @@ const EditBusinesses = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Review delay days"
|
label="DelayDays"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
type="number"
|
type="number"
|
||||||
name="delay_days"
|
name="delay_days"
|
||||||
placeholder="Review delay days"
|
placeholder="DelayDays"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -736,11 +736,11 @@ const EditBusinesses = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Email subject template"
|
label="EmailSubjectTemplate"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="email_subject_template"
|
name="email_subject_template"
|
||||||
placeholder="Email subject template"
|
placeholder="EmailSubjectTemplate"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -776,7 +776,7 @@ const EditBusinesses = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Email body template' hasTextareaHeight>
|
<FormField label='EmailBodyTemplate' hasTextareaHeight>
|
||||||
<Field
|
<Field
|
||||||
name='email_body_template'
|
name='email_body_template'
|
||||||
id='email_body_template'
|
id='email_body_template'
|
||||||
@ -824,7 +824,7 @@ const EditBusinesses = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Active' labelFor='is_active'>
|
<FormField label='IsActive' labelFor='is_active'>
|
||||||
<Field
|
<Field
|
||||||
name='is_active'
|
name='is_active'
|
||||||
id='is_active'
|
id='is_active'
|
||||||
@ -845,11 +845,11 @@ const EditBusinesses = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Stripe account reference"
|
label="StripeAccountReference"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="stripe_account_reference"
|
name="stripe_account_reference"
|
||||||
placeholder="Stripe account reference"
|
placeholder="StripeAccountReference"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -897,7 +897,7 @@ const EditBusinesses = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Stripe connected' labelFor='stripe_connected'>
|
<FormField label='StripeConnected' labelFor='stripe_connected'>
|
||||||
<Field
|
<Field
|
||||||
name='stripe_connected'
|
name='stripe_connected'
|
||||||
id='stripe_connected'
|
id='stripe_connected'
|
||||||
@ -928,7 +928,7 @@ const EditBusinesses = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Stripe connected at"
|
label="StripeConnectedAt"
|
||||||
>
|
>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
dateFormat="yyyy-MM-dd hh:mm"
|
dateFormat="yyyy-MM-dd hh:mm"
|
||||||
@ -974,7 +974,7 @@ const EditBusinesses = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label="Default review platform" labelFor="default_review_platform">
|
<FormField label="DefaultReviewPlatform" labelFor="default_review_platform">
|
||||||
<Field name="default_review_platform" id="default_review_platform" component="select">
|
<Field name="default_review_platform" id="default_review_platform" component="select">
|
||||||
|
|
||||||
<option value="google">google</option>
|
<option value="google">google</option>
|
||||||
@ -1003,11 +1003,11 @@ const EditBusinesses = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Custom review link"
|
label="CustomReviewLink"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="custom_review_link"
|
name="custom_review_link"
|
||||||
placeholder="Custom review link"
|
placeholder="CustomReviewLink"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
|||||||
@ -466,10 +466,10 @@ const EditBusinessesPage = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Edit Business')}</title>
|
<title>{getPageTitle('Edit businesses')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='Edit Business' main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit businesses'} main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
<CardBox>
|
<CardBox>
|
||||||
@ -547,11 +547,11 @@ const EditBusinessesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Business name"
|
label="BusinessName"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="name"
|
name="name"
|
||||||
placeholder="Business name"
|
placeholder="BusinessName"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -584,11 +584,11 @@ const EditBusinessesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Google review link"
|
label="GoogleReviewLink"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="google_review_link"
|
name="google_review_link"
|
||||||
placeholder="Google review link"
|
placeholder="GoogleReviewLink"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -621,11 +621,11 @@ const EditBusinessesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Yelp review link"
|
label="YelpReviewLink"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="yelp_review_link"
|
name="yelp_review_link"
|
||||||
placeholder="Yelp review link"
|
placeholder="YelpReviewLink"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -658,11 +658,11 @@ const EditBusinessesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Facebook review link"
|
label="FacebookReviewLink"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="facebook_review_link"
|
name="facebook_review_link"
|
||||||
placeholder="Facebook review link"
|
placeholder="FacebookReviewLink"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -701,12 +701,12 @@ const EditBusinessesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Review delay days"
|
label="DelayDays"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
type="number"
|
type="number"
|
||||||
name="delay_days"
|
name="delay_days"
|
||||||
placeholder="Review delay days"
|
placeholder="DelayDays"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -733,11 +733,11 @@ const EditBusinessesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Email subject template"
|
label="EmailSubjectTemplate"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="email_subject_template"
|
name="email_subject_template"
|
||||||
placeholder="Email subject template"
|
placeholder="EmailSubjectTemplate"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -773,7 +773,7 @@ const EditBusinessesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Email body template' hasTextareaHeight>
|
<FormField label='EmailBodyTemplate' hasTextareaHeight>
|
||||||
<Field
|
<Field
|
||||||
name='email_body_template'
|
name='email_body_template'
|
||||||
id='email_body_template'
|
id='email_body_template'
|
||||||
@ -821,7 +821,7 @@ const EditBusinessesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Active' labelFor='is_active'>
|
<FormField label='IsActive' labelFor='is_active'>
|
||||||
<Field
|
<Field
|
||||||
name='is_active'
|
name='is_active'
|
||||||
id='is_active'
|
id='is_active'
|
||||||
@ -842,11 +842,11 @@ const EditBusinessesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Stripe account reference"
|
label="StripeAccountReference"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="stripe_account_reference"
|
name="stripe_account_reference"
|
||||||
placeholder="Stripe account reference"
|
placeholder="StripeAccountReference"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -894,7 +894,7 @@ const EditBusinessesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Stripe connected' labelFor='stripe_connected'>
|
<FormField label='StripeConnected' labelFor='stripe_connected'>
|
||||||
<Field
|
<Field
|
||||||
name='stripe_connected'
|
name='stripe_connected'
|
||||||
id='stripe_connected'
|
id='stripe_connected'
|
||||||
@ -925,7 +925,7 @@ const EditBusinessesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Stripe connected at"
|
label="StripeConnectedAt"
|
||||||
>
|
>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
dateFormat="yyyy-MM-dd hh:mm"
|
dateFormat="yyyy-MM-dd hh:mm"
|
||||||
@ -971,7 +971,7 @@ const EditBusinessesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label="Default review platform" labelFor="default_review_platform">
|
<FormField label="DefaultReviewPlatform" labelFor="default_review_platform">
|
||||||
<Field name="default_review_platform" id="default_review_platform" component="select">
|
<Field name="default_review_platform" id="default_review_platform" component="select">
|
||||||
|
|
||||||
<option value="google">google</option>
|
<option value="google">google</option>
|
||||||
@ -1000,11 +1000,11 @@ const EditBusinessesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Custom review link"
|
label="CustomReviewLink"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="custom_review_link"
|
name="custom_review_link"
|
||||||
placeholder="Custom review link"
|
placeholder="CustomReviewLink"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
|||||||
@ -14,13 +14,10 @@ import Link from "next/link";
|
|||||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||||
import CardBoxModal from "../../components/CardBoxModal";
|
import CardBoxModal from "../../components/CardBoxModal";
|
||||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||||
import SubscriptionLimitGate from '../../components/SubscriptionLimitGate';
|
|
||||||
import {setRefetch, uploadCsv} from '../../stores/businesses/businessesSlice';
|
import {setRefetch, uploadCsv} from '../../stores/businesses/businessesSlice';
|
||||||
|
|
||||||
|
|
||||||
import {hasPermission} from "../../helpers/userPermissions";
|
import {hasPermission} from "../../helpers/userPermissions";
|
||||||
import { getBusinessMenuLabel } from '../../helpers/businessPlanLabels';
|
|
||||||
import { isInternalAdmin } from '../../helpers/portalRoles';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -37,22 +34,20 @@ const BusinessesTablesPage = () => {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
|
||||||
const [filters] = useState([{label: 'Business name', title: 'name'},{label: 'Google review link', title: 'google_review_link'},{label: 'Yelp review link', title: 'yelp_review_link'},{label: 'Facebook review link', title: 'facebook_review_link'},{label: 'Email subject template', title: 'email_subject_template'},{label: 'Email body template', title: 'email_body_template'},{label: 'Stripe account reference', title: 'stripe_account_reference'},{label: 'Custom review link', title: 'custom_review_link'},
|
const [filters] = useState([{label: 'BusinessName', title: 'name'},{label: 'GoogleReviewLink', title: 'google_review_link'},{label: 'YelpReviewLink', title: 'yelp_review_link'},{label: 'FacebookReviewLink', title: 'facebook_review_link'},{label: 'EmailSubjectTemplate', title: 'email_subject_template'},{label: 'EmailBodyTemplate', title: 'email_body_template'},{label: 'StripeAccountReference', title: 'stripe_account_reference'},{label: 'CustomReviewLink', title: 'custom_review_link'},
|
||||||
{label: 'Review delay days', title: 'delay_days', number: 'true'},
|
{label: 'DelayDays', title: 'delay_days', number: 'true'},
|
||||||
|
|
||||||
{label: 'Stripe connected at', title: 'stripe_connected_at', date: 'true'},
|
{label: 'StripeConnectedAt', title: 'stripe_connected_at', date: 'true'},
|
||||||
|
|
||||||
|
|
||||||
{label: 'Owner', title: 'owner'},
|
{label: 'Owner', title: 'owner'},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{label: 'Default review platform', title: 'default_review_platform', type: 'enum', options: ['google','yelp','facebook','custom']},
|
{label: 'DefaultReviewPlatform', title: 'default_review_platform', type: 'enum', options: ['google','yelp','facebook','custom']},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_BUSINESSES');
|
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_BUSINESSES');
|
||||||
const isAdminPortal = isInternalAdmin(currentUser);
|
|
||||||
const businessPageTitle = isAdminPortal ? 'Business profiles' : getBusinessMenuLabel(currentUser?.subscriptionPlanId);
|
|
||||||
|
|
||||||
|
|
||||||
const addFilter = () => {
|
const addFilter = () => {
|
||||||
@ -95,27 +90,15 @@ const BusinessesTablesPage = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle(businessPageTitle)}</title>
|
<title>{getPageTitle('Businesses')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={businessPageTitle} main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Businesses" main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
<CardBox className='mb-6 border-0 bg-indigo-50 text-indigo-950 ring-1 ring-indigo-100 dark:bg-indigo-950 dark:text-indigo-50 dark:ring-indigo-900'>
|
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||||
<p className='font-black'>{businessPageTitle} setup</p>
|
|
||||||
<p className='mt-2 text-sm leading-6'>
|
|
||||||
{isAdminPortal
|
|
||||||
? 'Manage customer business profiles used for review links, email templates, delay timing, and payment/webhook settings. Internal admin management is not limited by customer subscription plans.'
|
|
||||||
: 'Stores the business profile used for review links, email templates, delay timing, and payment/webhook settings. Starter accounts manage one business profile; Pro accounts can manage up to ten.'}
|
|
||||||
</p>
|
|
||||||
</CardBox>
|
|
||||||
<SubscriptionLimitGate
|
|
||||||
limitKey='businesses'
|
|
||||||
actionLabel='Adding another business profile'
|
|
||||||
/>
|
|
||||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
|
||||||
|
|
||||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/businesses/businesses-new'} color='info' label='Add Business'/>}
|
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/businesses/businesses-new'} color='info' label='New Item'/>}
|
||||||
|
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className={'mr-3'}
|
className={'mr-3'}
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js
|
|||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import React, { ReactElement } from 'react'
|
import React, { ReactElement } from 'react'
|
||||||
import CardBox from '../../components/CardBox'
|
import CardBox from '../../components/CardBox'
|
||||||
import SubscriptionLimitGate from '../../components/SubscriptionLimitGate'
|
|
||||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||||
import SectionMain from '../../components/SectionMain'
|
import SectionMain from '../../components/SectionMain'
|
||||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||||
@ -272,16 +271,12 @@ const BusinessesNew = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Add Business')}</title>
|
<title>{getPageTitle('New Item')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='Add Business' main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
<SubscriptionLimitGate
|
|
||||||
limitKey='businesses'
|
|
||||||
actionLabel='Adding another business profile'
|
|
||||||
/>
|
|
||||||
<CardBox>
|
<CardBox>
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={
|
initialValues={
|
||||||
@ -326,11 +321,11 @@ const BusinessesNew = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Business name"
|
label="BusinessName"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="name"
|
name="name"
|
||||||
placeholder="Business name"
|
placeholder="BusinessName"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -361,11 +356,11 @@ const BusinessesNew = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Google review link"
|
label="GoogleReviewLink"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="google_review_link"
|
name="google_review_link"
|
||||||
placeholder="Google review link"
|
placeholder="GoogleReviewLink"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -396,11 +391,11 @@ const BusinessesNew = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Yelp review link"
|
label="YelpReviewLink"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="yelp_review_link"
|
name="yelp_review_link"
|
||||||
placeholder="Yelp review link"
|
placeholder="YelpReviewLink"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -431,11 +426,11 @@ const BusinessesNew = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Facebook review link"
|
label="FacebookReviewLink"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="facebook_review_link"
|
name="facebook_review_link"
|
||||||
placeholder="Facebook review link"
|
placeholder="FacebookReviewLink"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -472,12 +467,12 @@ const BusinessesNew = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Review delay days"
|
label="DelayDays"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
type="number"
|
type="number"
|
||||||
name="delay_days"
|
name="delay_days"
|
||||||
placeholder="Review delay days"
|
placeholder="DelayDays"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -502,11 +497,11 @@ const BusinessesNew = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Email subject template"
|
label="EmailSubjectTemplate"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="email_subject_template"
|
name="email_subject_template"
|
||||||
placeholder="Email subject template"
|
placeholder="EmailSubjectTemplate"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -540,7 +535,7 @@ const BusinessesNew = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Email body template' hasTextareaHeight>
|
<FormField label='EmailBodyTemplate' hasTextareaHeight>
|
||||||
<Field
|
<Field
|
||||||
name='email_body_template'
|
name='email_body_template'
|
||||||
id='email_body_template'
|
id='email_body_template'
|
||||||
@ -586,7 +581,7 @@ const BusinessesNew = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Active' labelFor='is_active'>
|
<FormField label='IsActive' labelFor='is_active'>
|
||||||
<Field
|
<Field
|
||||||
name='is_active'
|
name='is_active'
|
||||||
id='is_active'
|
id='is_active'
|
||||||
@ -605,11 +600,11 @@ const BusinessesNew = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Stripe account reference"
|
label="StripeAccountReference"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="stripe_account_reference"
|
name="stripe_account_reference"
|
||||||
placeholder="Stripe account reference"
|
placeholder="StripeAccountReference"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -655,7 +650,7 @@ const BusinessesNew = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Stripe connected' labelFor='stripe_connected'>
|
<FormField label='StripeConnected' labelFor='stripe_connected'>
|
||||||
<Field
|
<Field
|
||||||
name='stripe_connected'
|
name='stripe_connected'
|
||||||
id='stripe_connected'
|
id='stripe_connected'
|
||||||
@ -684,12 +679,12 @@ const BusinessesNew = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Stripe connected at"
|
label="StripeConnectedAt"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
name="stripe_connected_at"
|
name="stripe_connected_at"
|
||||||
placeholder="Stripe connected at"
|
placeholder="StripeConnectedAt"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -723,7 +718,7 @@ const BusinessesNew = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label="Default review platform" labelFor="default_review_platform">
|
<FormField label="DefaultReviewPlatform" labelFor="default_review_platform">
|
||||||
<Field name="default_review_platform" id="default_review_platform" component="select">
|
<Field name="default_review_platform" id="default_review_platform" component="select">
|
||||||
|
|
||||||
<option value="google">google</option>
|
<option value="google">google</option>
|
||||||
@ -750,11 +745,11 @@ const BusinessesNew = () => {
|
|||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Custom review link"
|
label="CustomReviewLink"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
name="custom_review_link"
|
name="custom_review_link"
|
||||||
placeholder="Custom review link"
|
placeholder="CustomReviewLink"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
|||||||
@ -14,13 +14,10 @@ import Link from "next/link";
|
|||||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||||
import CardBoxModal from "../../components/CardBoxModal";
|
import CardBoxModal from "../../components/CardBoxModal";
|
||||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||||
import SubscriptionLimitGate from '../../components/SubscriptionLimitGate';
|
|
||||||
import {setRefetch, uploadCsv} from '../../stores/businesses/businessesSlice';
|
import {setRefetch, uploadCsv} from '../../stores/businesses/businessesSlice';
|
||||||
|
|
||||||
|
|
||||||
import {hasPermission} from "../../helpers/userPermissions";
|
import {hasPermission} from "../../helpers/userPermissions";
|
||||||
import { getBusinessMenuLabel } from '../../helpers/businessPlanLabels';
|
|
||||||
import { isInternalAdmin } from '../../helpers/portalRoles';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -37,22 +34,20 @@ const BusinessesTablesPage = () => {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
|
||||||
const [filters] = useState([{label: 'Business name', title: 'name'},{label: 'Google review link', title: 'google_review_link'},{label: 'Yelp review link', title: 'yelp_review_link'},{label: 'Facebook review link', title: 'facebook_review_link'},{label: 'Email subject template', title: 'email_subject_template'},{label: 'Email body template', title: 'email_body_template'},{label: 'Stripe account reference', title: 'stripe_account_reference'},{label: 'Custom review link', title: 'custom_review_link'},
|
const [filters] = useState([{label: 'BusinessName', title: 'name'},{label: 'GoogleReviewLink', title: 'google_review_link'},{label: 'YelpReviewLink', title: 'yelp_review_link'},{label: 'FacebookReviewLink', title: 'facebook_review_link'},{label: 'EmailSubjectTemplate', title: 'email_subject_template'},{label: 'EmailBodyTemplate', title: 'email_body_template'},{label: 'StripeAccountReference', title: 'stripe_account_reference'},{label: 'CustomReviewLink', title: 'custom_review_link'},
|
||||||
{label: 'Review delay days', title: 'delay_days', number: 'true'},
|
{label: 'DelayDays', title: 'delay_days', number: 'true'},
|
||||||
|
|
||||||
{label: 'Stripe connected at', title: 'stripe_connected_at', date: 'true'},
|
{label: 'StripeConnectedAt', title: 'stripe_connected_at', date: 'true'},
|
||||||
|
|
||||||
|
|
||||||
{label: 'Owner', title: 'owner'},
|
{label: 'Owner', title: 'owner'},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{label: 'Default review platform', title: 'default_review_platform', type: 'enum', options: ['google','yelp','facebook','custom']},
|
{label: 'DefaultReviewPlatform', title: 'default_review_platform', type: 'enum', options: ['google','yelp','facebook','custom']},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_BUSINESSES');
|
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_BUSINESSES');
|
||||||
const isAdminPortal = isInternalAdmin(currentUser);
|
|
||||||
const businessPageTitle = isAdminPortal ? 'Business profiles' : getBusinessMenuLabel(currentUser?.subscriptionPlanId);
|
|
||||||
|
|
||||||
|
|
||||||
const addFilter = () => {
|
const addFilter = () => {
|
||||||
@ -95,27 +90,15 @@ const BusinessesTablesPage = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle(businessPageTitle)}</title>
|
<title>{getPageTitle('Businesses')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={businessPageTitle} main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Businesses" main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
<CardBox className='mb-6 border-0 bg-indigo-50 text-indigo-950 ring-1 ring-indigo-100 dark:bg-indigo-950 dark:text-indigo-50 dark:ring-indigo-900'>
|
|
||||||
<p className='font-black'>{businessPageTitle} setup</p>
|
|
||||||
<p className='mt-2 text-sm leading-6'>
|
|
||||||
{isAdminPortal
|
|
||||||
? 'Manage customer business profiles used for review links, email templates, delay timing, and payment/webhook settings. Internal admin management is not limited by customer subscription plans.'
|
|
||||||
: 'Stores the business profile used for review links, email templates, delay timing, and payment/webhook settings. Starter accounts manage one business profile; Pro accounts can manage up to ten.'}
|
|
||||||
</p>
|
|
||||||
</CardBox>
|
|
||||||
<SubscriptionLimitGate
|
|
||||||
limitKey='businesses'
|
|
||||||
actionLabel='Adding another business profile'
|
|
||||||
/>
|
|
||||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||||
|
|
||||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/businesses/businesses-new'} color='info' label='Add Business'/>}
|
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/businesses/businesses-new'} color='info' label='New Item'/>}
|
||||||
|
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className={'mr-3'}
|
className={'mr-3'}
|
||||||
|
|||||||
@ -29,6 +29,11 @@ const BusinessesView = () => {
|
|||||||
|
|
||||||
const { id } = router.query;
|
const { id } = router.query;
|
||||||
|
|
||||||
|
function removeLastCharacter(str) {
|
||||||
|
console.log(str,`str`)
|
||||||
|
return str.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetch({ id }));
|
dispatch(fetch({ id }));
|
||||||
}, [dispatch, id]);
|
}, [dispatch, id]);
|
||||||
@ -37,10 +42,10 @@ const BusinessesView = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('View Business')}</title>
|
<title>{getPageTitle('View businesses')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='View Business' main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={removeLastCharacter('View businesses')} main>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
color='info'
|
color='info'
|
||||||
label='Edit'
|
label='Edit'
|
||||||
@ -108,7 +113,7 @@ const BusinessesView = () => {
|
|||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
<div className={'mb-4'}>
|
||||||
<p className={'block font-bold mb-2'}>Business name</p>
|
<p className={'block font-bold mb-2'}>BusinessName</p>
|
||||||
<p>{businesses?.name}</p>
|
<p>{businesses?.name}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -140,7 +145,7 @@ const BusinessesView = () => {
|
|||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
<div className={'mb-4'}>
|
||||||
<p className={'block font-bold mb-2'}>Google review link</p>
|
<p className={'block font-bold mb-2'}>GoogleReviewLink</p>
|
||||||
<p>{businesses?.google_review_link}</p>
|
<p>{businesses?.google_review_link}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -172,7 +177,7 @@ const BusinessesView = () => {
|
|||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
<div className={'mb-4'}>
|
||||||
<p className={'block font-bold mb-2'}>Yelp review link</p>
|
<p className={'block font-bold mb-2'}>YelpReviewLink</p>
|
||||||
<p>{businesses?.yelp_review_link}</p>
|
<p>{businesses?.yelp_review_link}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -204,7 +209,7 @@ const BusinessesView = () => {
|
|||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
<div className={'mb-4'}>
|
||||||
<p className={'block font-bold mb-2'}>Facebook review link</p>
|
<p className={'block font-bold mb-2'}>FacebookReviewLink</p>
|
||||||
<p>{businesses?.facebook_review_link}</p>
|
<p>{businesses?.facebook_review_link}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -242,7 +247,7 @@ const BusinessesView = () => {
|
|||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
<div className={'mb-4'}>
|
||||||
<p className={'block font-bold mb-2'}>Review delay days</p>
|
<p className={'block font-bold mb-2'}>DelayDays</p>
|
||||||
<p>{businesses?.delay_days || 'No data'}</p>
|
<p>{businesses?.delay_days || 'No data'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -268,7 +273,7 @@ const BusinessesView = () => {
|
|||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
<div className={'mb-4'}>
|
||||||
<p className={'block font-bold mb-2'}>Email subject template</p>
|
<p className={'block font-bold mb-2'}>EmailSubjectTemplate</p>
|
||||||
<p>{businesses?.email_subject_template}</p>
|
<p>{businesses?.email_subject_template}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -304,7 +309,7 @@ const BusinessesView = () => {
|
|||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
<div className={'mb-4'}>
|
||||||
<p className={'block font-bold mb-2'}>Email body template</p>
|
<p className={'block font-bold mb-2'}>EmailBodyTemplate</p>
|
||||||
{businesses.email_body_template
|
{businesses.email_body_template
|
||||||
? <p dangerouslySetInnerHTML={{__html: businesses.email_body_template}}/>
|
? <p dangerouslySetInnerHTML={{__html: businesses.email_body_template}}/>
|
||||||
: <p>No data</p>
|
: <p>No data</p>
|
||||||
@ -350,7 +355,7 @@ const BusinessesView = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Active'>
|
<FormField label='IsActive'>
|
||||||
<SwitchField
|
<SwitchField
|
||||||
field={{name: 'is_active', value: businesses?.is_active}}
|
field={{name: 'is_active', value: businesses?.is_active}}
|
||||||
form={{setFieldValue: () => null}}
|
form={{setFieldValue: () => null}}
|
||||||
@ -370,7 +375,7 @@ const BusinessesView = () => {
|
|||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
<div className={'mb-4'}>
|
||||||
<p className={'block font-bold mb-2'}>Stripe account reference</p>
|
<p className={'block font-bold mb-2'}>StripeAccountReference</p>
|
||||||
<p>{businesses?.stripe_account_reference}</p>
|
<p>{businesses?.stripe_account_reference}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -417,7 +422,7 @@ const BusinessesView = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Stripe connected'>
|
<FormField label='StripeConnected'>
|
||||||
<SwitchField
|
<SwitchField
|
||||||
field={{name: 'stripe_connected', value: businesses?.stripe_connected}}
|
field={{name: 'stripe_connected', value: businesses?.stripe_connected}}
|
||||||
form={{setFieldValue: () => null}}
|
form={{setFieldValue: () => null}}
|
||||||
@ -446,7 +451,7 @@ const BusinessesView = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Stripe connected at'>
|
<FormField label='StripeConnectedAt'>
|
||||||
{businesses.stripe_connected_at ? <DatePicker
|
{businesses.stripe_connected_at ? <DatePicker
|
||||||
dateFormat="yyyy-MM-dd hh:mm"
|
dateFormat="yyyy-MM-dd hh:mm"
|
||||||
showTimeSelect
|
showTimeSelect
|
||||||
@ -456,7 +461,7 @@ const BusinessesView = () => {
|
|||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
disabled
|
disabled
|
||||||
/> : <p>No Stripe connection date</p>}
|
/> : <p>No StripeConnectedAt</p>}
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
|
||||||
@ -491,7 +496,7 @@ const BusinessesView = () => {
|
|||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
<div className={'mb-4'}>
|
||||||
<p className={'block font-bold mb-2'}>Default review platform</p>
|
<p className={'block font-bold mb-2'}>DefaultReviewPlatform</p>
|
||||||
<p>{businesses?.default_review_platform ?? 'No data'}</p>
|
<p>{businesses?.default_review_platform ?? 'No data'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -509,7 +514,7 @@ const BusinessesView = () => {
|
|||||||
|
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
<div className={'mb-4'}>
|
||||||
<p className={'block font-bold mb-2'}>Custom review link</p>
|
<p className={'block font-bold mb-2'}>CustomReviewLink</p>
|
||||||
<p>{businesses?.custom_review_link}</p>
|
<p>{businesses?.custom_review_link}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -545,7 +550,7 @@ const BusinessesView = () => {
|
|||||||
|
|
||||||
|
|
||||||
<>
|
<>
|
||||||
<p className={'block font-bold mb-2'}>Customers for this business</p>
|
<p className={'block font-bold mb-2'}>Customers Business</p>
|
||||||
<CardBox
|
<CardBox
|
||||||
className='mb-6 border border-gray-300 rounded overflow-hidden'
|
className='mb-6 border border-gray-300 rounded overflow-hidden'
|
||||||
hasTable
|
hasTable
|
||||||
|
|||||||
@ -1,64 +0,0 @@
|
|||||||
import { mdiConnection, mdiOpenInNew, mdiWebhook } from '@mdi/js';
|
|
||||||
import Head from 'next/head';
|
|
||||||
import React, { ReactElement } from 'react';
|
|
||||||
import BaseButton from '../components/BaseButton';
|
|
||||||
import PaymentProviderConnectors from '../components/ReviewFlow/PaymentProviderConnectors';
|
|
||||||
import SectionMain from '../components/SectionMain';
|
|
||||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
|
||||||
import { getPageTitle } from '../config';
|
|
||||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
|
||||||
|
|
||||||
export default function ConnectPage() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head>
|
|
||||||
<title>{getPageTitle('Connect')}</title>
|
|
||||||
</Head>
|
|
||||||
<SectionMain>
|
|
||||||
<SectionTitleLineWithButton icon={mdiConnection} title='Connect' main>
|
|
||||||
<BaseButton
|
|
||||||
href='/reviewflow'
|
|
||||||
icon={mdiOpenInNew}
|
|
||||||
label='Review Flow'
|
|
||||||
color='whiteDark'
|
|
||||||
/>
|
|
||||||
</SectionTitleLineWithButton>
|
|
||||||
|
|
||||||
<div className='mb-6 overflow-hidden rounded-3xl bg-gradient-to-br from-slate-950 via-blue-950 to-indigo-950 p-6 text-white shadow-2xl'>
|
|
||||||
<div className='grid gap-6 lg:grid-cols-[1.15fr_0.85fr] lg:items-center'>
|
|
||||||
<div>
|
|
||||||
<p className='mb-3 inline-flex rounded-full bg-white/10 px-4 py-1 text-sm font-semibold text-sky-200 ring-1 ring-white/20'>
|
|
||||||
Trigger setup · destination clarity
|
|
||||||
</p>
|
|
||||||
<h2 className='max-w-3xl text-4xl font-black tracking-tight md:text-5xl'>
|
|
||||||
Connect order/payment triggers without confusing local review channels.
|
|
||||||
</h2>
|
|
||||||
<p className='mt-4 max-w-2xl text-base text-slate-200 md:text-lg'>
|
|
||||||
Use this page to generate secure webhook URLs for payment and ecommerce triggers. Review destinations stay separate: local businesses use Google, Facebook, Yelp, Angi, or OpenTable links, ecommerce brands can use Trustpilot, and Shopify can use a hosted product-review form.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className='rounded-3xl bg-white/10 p-5 ring-1 ring-white/15 backdrop-blur'>
|
|
||||||
<div className='mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-sky-400/20 text-sky-100'>
|
|
||||||
<BaseButton icon={mdiWebhook} color='info' roundedFull />
|
|
||||||
</div>
|
|
||||||
<h3 className='text-xl font-black'>How connection works</h3>
|
|
||||||
<p className='mt-2 text-sm leading-6 text-slate-200'>
|
|
||||||
Pick the order/payment trigger first, then choose where reviews should land. Shopify is both an ecommerce trigger and a hosted Review Flow product-review destination.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PaymentProviderConnectors
|
|
||||||
eyebrow='Provider connections'
|
|
||||||
title='Connect triggers and choose review destinations'
|
|
||||||
description='Choose Stripe, PayPal, Square, Shopify, or WooCommerce as the order/payment trigger. Then choose a separate review destination so local and ecommerce customers see the right experience.'
|
|
||||||
/>
|
|
||||||
</SectionMain>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ConnectPage.getLayout = function getLayout(page: ReactElement) {
|
|
||||||
return <LayoutAuthenticated portal='customer'>{page}</LayoutAuthenticated>;
|
|
||||||
};
|
|
||||||
@ -645,7 +645,6 @@ const EditCron_runs = () => {
|
|||||||
EditCron_runs.getLayout = function getLayout(page: ReactElement) {
|
EditCron_runs.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'UPDATE_CRON_RUNS'}
|
permission={'UPDATE_CRON_RUNS'}
|
||||||
|
|
||||||
|
|||||||
@ -642,7 +642,6 @@ const EditCron_runsPage = () => {
|
|||||||
EditCron_runsPage.getLayout = function getLayout(page: ReactElement) {
|
EditCron_runsPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'UPDATE_CRON_RUNS'}
|
permission={'UPDATE_CRON_RUNS'}
|
||||||
|
|
||||||
|
|||||||
@ -154,7 +154,6 @@ const Cron_runsTablesPage = () => {
|
|||||||
Cron_runsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
Cron_runsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'READ_CRON_RUNS'}
|
permission={'READ_CRON_RUNS'}
|
||||||
|
|
||||||
|
|||||||
@ -504,7 +504,6 @@ const Cron_runsNew = () => {
|
|||||||
Cron_runsNew.getLayout = function getLayout(page: ReactElement) {
|
Cron_runsNew.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'CREATE_CRON_RUNS'}
|
permission={'CREATE_CRON_RUNS'}
|
||||||
|
|
||||||
|
|||||||
@ -152,7 +152,6 @@ const Cron_runsTablesPage = () => {
|
|||||||
Cron_runsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
Cron_runsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'READ_CRON_RUNS'}
|
permission={'READ_CRON_RUNS'}
|
||||||
|
|
||||||
|
|||||||
@ -354,7 +354,6 @@ const Cron_runsView = () => {
|
|||||||
Cron_runsView.getLayout = function getLayout(page: ReactElement) {
|
Cron_runsView.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'READ_CRON_RUNS'}
|
permission={'READ_CRON_RUNS'}
|
||||||
|
|
||||||
|
|||||||
@ -1,546 +1,431 @@
|
|||||||
import * as icon from '@mdi/js'
|
import * as icon from '@mdi/js';
|
||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import axios from 'axios'
|
import axios from 'axios';
|
||||||
import type { ReactElement } from 'react'
|
import type { ReactElement } from 'react'
|
||||||
import Link from 'next/link'
|
|
||||||
import LayoutAuthenticated from '../layouts/Authenticated'
|
import LayoutAuthenticated from '../layouts/Authenticated'
|
||||||
import SectionMain from '../components/SectionMain'
|
import SectionMain from '../components/SectionMain'
|
||||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
|
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
|
||||||
import BaseIcon from '../components/BaseIcon'
|
import BaseIcon from "../components/BaseIcon";
|
||||||
import BaseButton from '../components/BaseButton'
|
|
||||||
import CardBox from '../components/CardBox'
|
|
||||||
import { getPageTitle } from '../config'
|
import { getPageTitle } from '../config'
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
import { hasPermission } from '../helpers/userPermissions'
|
import { hasPermission } from "../helpers/userPermissions";
|
||||||
import { getBusinessMenuLabel } from '../helpers/businessPlanLabels'
|
import { fetchWidgets } from '../stores/roles/rolesSlice';
|
||||||
import { getPortalLabel, isInternalAdmin } from '../helpers/portalRoles'
|
import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator';
|
||||||
import { fetchWidgets } from '../stores/roles/rolesSlice'
|
import { SmartWidget } from '../components/SmartWidget/SmartWidget';
|
||||||
import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator'
|
|
||||||
import { SmartWidget } from '../components/SmartWidget/SmartWidget'
|
|
||||||
|
|
||||||
import { useAppDispatch, useAppSelector } from '../stores/hooks'
|
|
||||||
|
|
||||||
type EntityKey =
|
|
||||||
| 'users'
|
|
||||||
| 'roles'
|
|
||||||
| 'permissions'
|
|
||||||
| 'businesses'
|
|
||||||
| 'customers'
|
|
||||||
| 'transactions'
|
|
||||||
| 'review_requests'
|
|
||||||
| 'stripe_events'
|
|
||||||
| 'email_delivery_logs'
|
|
||||||
| 'cron_runs'
|
|
||||||
|
|
||||||
type CountValue = string | number | null
|
|
||||||
|
|
||||||
type CountState = Record<EntityKey, CountValue>
|
|
||||||
|
|
||||||
type DashboardCard = {
|
|
||||||
key: EntityKey
|
|
||||||
label: string
|
|
||||||
description: string
|
|
||||||
href: string
|
|
||||||
iconPath: string
|
|
||||||
permission: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type DashboardAction = {
|
|
||||||
label: string
|
|
||||||
href: string
|
|
||||||
permission?: string | string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type DashboardActionGroup = {
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
actions: DashboardAction[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadingMessage = 'Loading...'
|
|
||||||
|
|
||||||
const entityKeys: EntityKey[] = [
|
|
||||||
'users',
|
|
||||||
'roles',
|
|
||||||
'permissions',
|
|
||||||
'businesses',
|
|
||||||
'customers',
|
|
||||||
'transactions',
|
|
||||||
'review_requests',
|
|
||||||
'stripe_events',
|
|
||||||
'email_delivery_logs',
|
|
||||||
'cron_runs',
|
|
||||||
]
|
|
||||||
|
|
||||||
const entityConfig: Record<EntityKey, { endpoint: string; permission: string }> = {
|
|
||||||
users: { endpoint: 'users', permission: 'READ_USERS' },
|
|
||||||
roles: { endpoint: 'roles', permission: 'READ_ROLES' },
|
|
||||||
permissions: { endpoint: 'permissions', permission: 'READ_PERMISSIONS' },
|
|
||||||
businesses: { endpoint: 'businesses', permission: 'READ_BUSINESSES' },
|
|
||||||
customers: { endpoint: 'customers', permission: 'READ_CUSTOMERS' },
|
|
||||||
transactions: { endpoint: 'transactions', permission: 'READ_TRANSACTIONS' },
|
|
||||||
review_requests: { endpoint: 'review_requests', permission: 'READ_REVIEW_REQUESTS' },
|
|
||||||
stripe_events: { endpoint: 'stripe_events', permission: 'READ_STRIPE_EVENTS' },
|
|
||||||
email_delivery_logs: { endpoint: 'email_delivery_logs', permission: 'READ_EMAIL_DELIVERY_LOGS' },
|
|
||||||
cron_runs: { endpoint: 'cron_runs', permission: 'READ_CRON_RUNS' },
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialCounts = entityKeys.reduce((counts, key) => {
|
|
||||||
counts[key] = loadingMessage
|
|
||||||
|
|
||||||
return counts
|
|
||||||
}, {} as CountState)
|
|
||||||
|
|
||||||
const storeIcon =
|
|
||||||
'mdiStore' in icon
|
|
||||||
? icon['mdiStore' as keyof typeof icon]
|
|
||||||
: icon.mdiTable
|
|
||||||
|
|
||||||
const accountMultipleIcon =
|
|
||||||
'mdiAccountMultiple' in icon
|
|
||||||
? icon['mdiAccountMultiple' as keyof typeof icon]
|
|
||||||
: icon.mdiTable
|
|
||||||
|
|
||||||
const emailFastIcon =
|
|
||||||
'mdiEmailFastOutline' in icon
|
|
||||||
? icon['mdiEmailFastOutline' as keyof typeof icon]
|
|
||||||
: icon.mdiTable
|
|
||||||
|
|
||||||
const webhookIcon =
|
|
||||||
'mdiWebhook' in icon
|
|
||||||
? icon['mdiWebhook' as keyof typeof icon]
|
|
||||||
: icon.mdiTable
|
|
||||||
|
|
||||||
const emailCheckIcon =
|
|
||||||
'mdiEmailCheckOutline' in icon
|
|
||||||
? icon['mdiEmailCheckOutline' as keyof typeof icon]
|
|
||||||
: icon.mdiTable
|
|
||||||
|
|
||||||
const clockIcon =
|
|
||||||
'mdiClockOutline' in icon
|
|
||||||
? icon['mdiClockOutline' as keyof typeof icon]
|
|
||||||
: icon.mdiTable
|
|
||||||
|
|
||||||
function formatCount(value: CountValue) {
|
|
||||||
if (value === null || value === undefined) return '—'
|
|
||||||
if (typeof value === 'number') return value.toLocaleString()
|
|
||||||
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatCard({
|
|
||||||
card,
|
|
||||||
value,
|
|
||||||
corners,
|
|
||||||
cardsStyle,
|
|
||||||
iconsColor,
|
|
||||||
}: {
|
|
||||||
card: DashboardCard
|
|
||||||
value: CountValue
|
|
||||||
corners: string
|
|
||||||
cardsStyle: string
|
|
||||||
iconsColor: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Link href={card.href}>
|
|
||||||
<div
|
|
||||||
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6 h-full hover:shadow-lg transition-shadow`}
|
|
||||||
>
|
|
||||||
<div className='flex justify-between gap-4'>
|
|
||||||
<div>
|
|
||||||
<div className='text-sm font-black uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400'>
|
|
||||||
{card.label}
|
|
||||||
</div>
|
|
||||||
<div className='mt-2 text-3xl leading-tight font-semibold'>
|
|
||||||
{formatCount(value)}
|
|
||||||
</div>
|
|
||||||
<p className='mt-3 text-sm text-gray-500 dark:text-gray-400'>
|
|
||||||
{card.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<BaseIcon
|
|
||||||
className={`${iconsColor} flex-none`}
|
|
||||||
w='w-16'
|
|
||||||
h='h-16'
|
|
||||||
size={48}
|
|
||||||
path={card.iconPath}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActionGroupCard({ group, currentUser }: { group: DashboardActionGroup; currentUser: any }) {
|
|
||||||
const visibleActions = group.actions.filter(
|
|
||||||
(action) => !action.permission || hasPermission(currentUser, action.permission),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!visibleActions.length) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CardBox className='h-full'>
|
|
||||||
<div className='flex h-full flex-col'>
|
|
||||||
<div>
|
|
||||||
<h2 className='text-xl font-semibold'>{group.title}</h2>
|
|
||||||
<p className='mt-2 text-sm text-gray-500 dark:text-gray-400'>{group.description}</p>
|
|
||||||
</div>
|
|
||||||
<div className='mt-6 grid gap-3'>
|
|
||||||
{visibleActions.map((action) => (
|
|
||||||
<BaseButton
|
|
||||||
key={`${group.title}-${action.href}`}
|
|
||||||
href={action.href}
|
|
||||||
label={action.label}
|
|
||||||
color='whiteDark'
|
|
||||||
className='w-full justify-start'
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PortalIntroCard({ currentUser, adminPortal }: { currentUser: any; adminPortal: boolean }) {
|
|
||||||
const portalLabel = getPortalLabel(currentUser)
|
|
||||||
const roleName = currentUser?.app_role?.name || 'User'
|
|
||||||
const name = currentUser?.firstName || currentUser?.email || 'there'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CardBox className='mb-6 overflow-hidden'>
|
|
||||||
<div className='grid gap-6 lg:grid-cols-[1.4fr_0.8fr] lg:items-center'>
|
|
||||||
<div>
|
|
||||||
<p className='text-xs font-black uppercase tracking-[0.25em] text-indigo-500 dark:text-indigo-300'>
|
|
||||||
{portalLabel}
|
|
||||||
</p>
|
|
||||||
<h2 className='mt-3 text-2xl font-bold'>Welcome, {name}</h2>
|
|
||||||
<p className='mt-3 text-gray-600 dark:text-gray-300'>
|
|
||||||
{adminPortal
|
|
||||||
? 'This internal area is for running the SaaS business: customer accounts, business profiles, billing events, review operations, and access control.'
|
|
||||||
: 'This customer workspace is for setting up your business profile, connecting review automation, managing customers, tracking transactions, and handling your subscription.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className='rounded-3xl bg-slate-950 p-6 text-white'>
|
|
||||||
<p className='text-sm text-slate-300'>Signed in as</p>
|
|
||||||
<p className='mt-2 text-2xl font-bold'>{roleName}</p>
|
|
||||||
<p className='mt-4 text-sm text-slate-300'>
|
|
||||||
{adminPortal
|
|
||||||
? 'Customer workspace setup links are intentionally hidden from this portal.'
|
|
||||||
: 'Internal platform administration links are intentionally hidden from this workspace.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch();
|
||||||
const iconsColor = useAppSelector((state) => state.style.iconsColor)
|
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
||||||
const corners = useAppSelector((state) => state.style.corners)
|
const corners = useAppSelector((state) => state.style.corners);
|
||||||
const cardsStyle = useAppSelector((state) => state.style.cardsStyle)
|
const cardsStyle = useAppSelector((state) => state.style.cardsStyle);
|
||||||
const { currentUser } = useAppSelector((state) => state.auth)
|
|
||||||
const { isFetchingQuery } = useAppSelector((state) => state.openAi)
|
|
||||||
const { rolesWidgets, loading } = useAppSelector((state) => state.roles)
|
|
||||||
const [counts, setCounts] = React.useState<CountState>(initialCounts)
|
|
||||||
const [widgetsRole, setWidgetsRole] = React.useState({
|
|
||||||
role: { value: '', label: '' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const adminPortal = isInternalAdmin(currentUser)
|
const loadingMessage = 'Loading...';
|
||||||
const businessLabel = getBusinessMenuLabel(currentUser?.subscriptionPlanId)
|
|
||||||
const businessProfilesLabel = adminPortal ? 'Business profiles' : businessLabel
|
|
||||||
|
|
||||||
const loadData = React.useCallback(async () => {
|
|
||||||
if (!currentUser) return
|
const [users, setUsers] = React.useState(loadingMessage);
|
||||||
|
const [roles, setRoles] = React.useState(loadingMessage);
|
||||||
|
const [permissions, setPermissions] = React.useState(loadingMessage);
|
||||||
|
const [businesses, setBusinesses] = React.useState(loadingMessage);
|
||||||
|
const [customers, setCustomers] = React.useState(loadingMessage);
|
||||||
|
const [transactions, setTransactions] = React.useState(loadingMessage);
|
||||||
|
const [review_requests, setReview_requests] = React.useState(loadingMessage);
|
||||||
|
const [stripe_events, setStripe_events] = React.useState(loadingMessage);
|
||||||
|
const [email_delivery_logs, setEmail_delivery_logs] = React.useState(loadingMessage);
|
||||||
|
const [cron_runs, setCron_runs] = React.useState(loadingMessage);
|
||||||
|
|
||||||
const requests = entityKeys.map(async (key) => {
|
|
||||||
const config = entityConfig[key]
|
const [widgetsRole, setWidgetsRole] = React.useState({
|
||||||
|
role: { value: '', label: '' },
|
||||||
|
});
|
||||||
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
|
const { isFetchingQuery } = useAppSelector((state) => state.openAi);
|
||||||
|
|
||||||
|
const { rolesWidgets, loading } = useAppSelector((state) => state.roles);
|
||||||
|
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
const entities = ['users','roles','permissions','businesses','customers','transactions','review_requests','stripe_events','email_delivery_logs','cron_runs',];
|
||||||
|
const fns = [setUsers,setRoles,setPermissions,setBusinesses,setCustomers,setTransactions,setReview_requests,setStripe_events,setEmail_delivery_logs,setCron_runs,];
|
||||||
|
|
||||||
if (!hasPermission(currentUser, config.permission)) {
|
const requests = entities.map((entity, index) => {
|
||||||
return { key, count: null }
|
|
||||||
}
|
if(hasPermission(currentUser, `READ_${entity.toUpperCase()}`)) {
|
||||||
|
return axios.get(`/${entity.toLowerCase()}/count`);
|
||||||
|
} else {
|
||||||
|
fns[index](null);
|
||||||
|
return Promise.resolve({data: {count: null}});
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
const response = await axios.get(`/${config.endpoint}/count`)
|
Promise.allSettled(requests).then((results) => {
|
||||||
|
results.forEach((result, i) => {
|
||||||
return { key, count: response.data.count as CountValue }
|
if (result.status === 'fulfilled') {
|
||||||
})
|
fns[i](result.value.data.count);
|
||||||
|
} else {
|
||||||
const results = await Promise.allSettled(requests)
|
fns[i](result.reason.message);
|
||||||
|
}
|
||||||
setCounts((previousCounts) => {
|
});
|
||||||
const nextCounts = { ...previousCounts }
|
});
|
||||||
|
}
|
||||||
results.forEach((result, index) => {
|
|
||||||
const key = entityKeys[index]
|
async function getWidgets(roleId) {
|
||||||
|
await dispatch(fetchWidgets(roleId));
|
||||||
if (result.status === 'fulfilled') {
|
}
|
||||||
nextCounts[result.value.key] = result.value.count
|
React.useEffect(() => {
|
||||||
} else {
|
if (!currentUser) return;
|
||||||
console.error(`Failed to load ${key} dashboard count:`, result.reason)
|
loadData().then();
|
||||||
nextCounts[key] = 'Error'
|
setWidgetsRole({ role: { value: currentUser?.app_role?.id, label: currentUser?.app_role?.name } });
|
||||||
}
|
}, [currentUser]);
|
||||||
})
|
|
||||||
|
|
||||||
return nextCounts
|
|
||||||
})
|
|
||||||
}, [currentUser])
|
|
||||||
|
|
||||||
const getWidgets = React.useCallback(async (roleId: string) => {
|
|
||||||
await dispatch(fetchWidgets(roleId))
|
|
||||||
}, [dispatch])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!currentUser) return
|
|
||||||
|
|
||||||
loadData().then()
|
|
||||||
setWidgetsRole({
|
|
||||||
role: {
|
|
||||||
value: currentUser?.app_role?.id,
|
|
||||||
label: currentUser?.app_role?.name,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}, [currentUser, loadData])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!currentUser || !widgetsRole?.role?.value || adminPortal) return
|
|
||||||
|
|
||||||
getWidgets(widgetsRole?.role?.value || '').then()
|
|
||||||
}, [adminPortal, currentUser, getWidgets, widgetsRole?.role?.value])
|
|
||||||
|
|
||||||
const adminCards: DashboardCard[] = [
|
|
||||||
{
|
|
||||||
key: 'users',
|
|
||||||
label: 'Customer accounts',
|
|
||||||
description: 'Owners and team users across the platform.',
|
|
||||||
href: '/users/users-list',
|
|
||||||
iconPath: icon.mdiAccountGroup,
|
|
||||||
permission: 'READ_USERS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'businesses',
|
|
||||||
label: 'Business profiles',
|
|
||||||
description: 'Business locations connected to review flows.',
|
|
||||||
href: '/businesses/businesses-list',
|
|
||||||
iconPath: storeIcon,
|
|
||||||
permission: 'READ_BUSINESSES',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'transactions',
|
|
||||||
label: 'Transactions',
|
|
||||||
description: 'Payment records feeding review requests.',
|
|
||||||
href: '/transactions/transactions-list',
|
|
||||||
iconPath: icon.mdiCreditCardOutline,
|
|
||||||
permission: 'READ_TRANSACTIONS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'review_requests',
|
|
||||||
label: 'Review requests',
|
|
||||||
description: 'Review invitations generated by the system.',
|
|
||||||
href: '/review_requests/review_requests-list',
|
|
||||||
iconPath: emailFastIcon,
|
|
||||||
permission: 'READ_REVIEW_REQUESTS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'stripe_events',
|
|
||||||
label: 'Payment events',
|
|
||||||
description: 'Stripe webhook events and processing status.',
|
|
||||||
href: '/stripe_events/stripe_events-list',
|
|
||||||
iconPath: webhookIcon,
|
|
||||||
permission: 'READ_STRIPE_EVENTS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'cron_runs',
|
|
||||||
label: 'Automation runs',
|
|
||||||
description: 'Scheduled background job execution history.',
|
|
||||||
href: '/cron_runs/cron_runs-list',
|
|
||||||
iconPath: clockIcon,
|
|
||||||
permission: 'READ_CRON_RUNS',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const customerCards: DashboardCard[] = [
|
|
||||||
{
|
|
||||||
key: 'businesses',
|
|
||||||
label: businessProfilesLabel,
|
|
||||||
description: 'Your business profile and Google review destination.',
|
|
||||||
href: '/businesses/businesses-list',
|
|
||||||
iconPath: storeIcon,
|
|
||||||
permission: 'READ_BUSINESSES',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'customers',
|
|
||||||
label: 'Customers',
|
|
||||||
description: 'Customer records created from payments or imports.',
|
|
||||||
href: '/customers/customers-list',
|
|
||||||
iconPath: accountMultipleIcon,
|
|
||||||
permission: 'READ_CUSTOMERS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'transactions',
|
|
||||||
label: 'Transactions',
|
|
||||||
description: 'Payments that can trigger review follow-up.',
|
|
||||||
href: '/transactions/transactions-list',
|
|
||||||
iconPath: icon.mdiCreditCardOutline,
|
|
||||||
permission: 'READ_TRANSACTIONS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'review_requests',
|
|
||||||
label: 'Review requests',
|
|
||||||
description: 'Messages scheduled or sent to customers.',
|
|
||||||
href: '/review_requests/review_requests-list',
|
|
||||||
iconPath: emailFastIcon,
|
|
||||||
permission: 'READ_REVIEW_REQUESTS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'email_delivery_logs',
|
|
||||||
label: 'Email delivery',
|
|
||||||
description: 'Delivery activity for review request emails.',
|
|
||||||
href: '/email_delivery_logs/email_delivery_logs-list',
|
|
||||||
iconPath: emailCheckIcon,
|
|
||||||
permission: 'READ_EMAIL_DELIVERY_LOGS',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const adminActionGroups: DashboardActionGroup[] = [
|
|
||||||
{
|
|
||||||
title: 'Customer operations',
|
|
||||||
description: 'Support customer accounts and the business profiles they manage.',
|
|
||||||
actions: [
|
|
||||||
{ label: 'Review customer accounts', href: '/users/users-list', permission: 'READ_USERS' },
|
|
||||||
{ label: 'Review business profiles', href: '/businesses/businesses-list', permission: 'READ_BUSINESSES' },
|
|
||||||
{ label: 'Review end customers', href: '/customers/customers-list', permission: 'READ_CUSTOMERS' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Billing & review operations',
|
|
||||||
description: 'Monitor payment data, webhook events, review requests, and delivery health.',
|
|
||||||
actions: [
|
|
||||||
{ label: 'View transactions', href: '/transactions/transactions-list', permission: 'READ_TRANSACTIONS' },
|
|
||||||
{ label: 'View payment events', href: '/stripe_events/stripe_events-list', permission: 'READ_STRIPE_EVENTS' },
|
|
||||||
{ label: 'View review requests', href: '/review_requests/review_requests-list', permission: 'READ_REVIEW_REQUESTS' },
|
|
||||||
{ label: 'View email delivery logs', href: '/email_delivery_logs/email_delivery_logs-list', permission: 'READ_EMAIL_DELIVERY_LOGS' },
|
|
||||||
{ label: 'View automation runs', href: '/cron_runs/cron_runs-list', permission: 'READ_CRON_RUNS' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Platform access control',
|
|
||||||
description: 'Manage internal roles and permissions for the platform.',
|
|
||||||
actions: [
|
|
||||||
{ label: 'Manage roles', href: '/roles/roles-list', permission: 'READ_ROLES' },
|
|
||||||
{ label: 'Manage permissions', href: '/permissions/permissions-list', permission: 'READ_PERMISSIONS' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const customerActionGroups: DashboardActionGroup[] = [
|
|
||||||
{
|
|
||||||
title: 'Review automation setup',
|
|
||||||
description: 'Configure the business profile, review request templates, and payment triggers.',
|
|
||||||
actions: [
|
|
||||||
{ label: 'Open Review Flow', href: '/reviewflow' },
|
|
||||||
{ label: `Manage ${businessLabel}`, href: '/businesses/businesses-list', permission: 'READ_BUSINESSES' },
|
|
||||||
{ label: 'Manage review requests', href: '/review_requests/review_requests-list', permission: 'READ_REVIEW_REQUESTS' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Customer records',
|
|
||||||
description: 'Track customers and transactions that power follow-up messages.',
|
|
||||||
actions: [
|
|
||||||
{ label: 'Manage customers', href: '/customers/customers-list', permission: 'READ_CUSTOMERS' },
|
|
||||||
{ label: 'View transactions', href: '/transactions/transactions-list', permission: 'READ_TRANSACTIONS' },
|
|
||||||
{ label: 'View email delivery', href: '/email_delivery_logs/email_delivery_logs-list', permission: 'READ_EMAIL_DELIVERY_LOGS' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Plan & billing',
|
|
||||||
description: 'Review plan limits, usage, and billing status for this workspace.',
|
|
||||||
actions: [
|
|
||||||
{ label: 'Open subscription', href: '/subscription' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const cards = adminPortal ? adminCards : customerCards
|
|
||||||
const actionGroups = adminPortal ? adminActionGroups : customerActionGroups
|
|
||||||
const visibleCards = cards.filter((card) => hasPermission(currentUser, card.permission))
|
|
||||||
const title = adminPortal ? 'Internal admin portal' : 'Customer workspace'
|
|
||||||
const sectionIcon = adminPortal ? icon.mdiShieldAccountVariantOutline : icon.mdiChartTimelineVariant
|
|
||||||
const showCustomerWidgets = !adminPortal && hasPermission(currentUser, 'CREATE_ROLES')
|
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!currentUser || !widgetsRole?.role?.value) return;
|
||||||
|
getWidgets(widgetsRole?.role?.value || '').then();
|
||||||
|
}, [widgetsRole?.role?.value]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle(title)}</title>
|
<title>
|
||||||
|
{getPageTitle('Overview')}
|
||||||
|
</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={sectionIcon} title={title} main>
|
<SectionTitleLineWithButton
|
||||||
|
icon={icon.mdiChartTimelineVariant}
|
||||||
|
title='Overview'
|
||||||
|
main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
<PortalIntroCard currentUser={currentUser} adminPortal={adminPortal} />
|
{hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator
|
||||||
|
|
||||||
{showCustomerWidgets && (
|
|
||||||
<WidgetCreator
|
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
isFetchingQuery={isFetchingQuery}
|
isFetchingQuery={isFetchingQuery}
|
||||||
setWidgetsRole={setWidgetsRole}
|
setWidgetsRole={setWidgetsRole}
|
||||||
widgetsRole={widgetsRole}
|
widgetsRole={widgetsRole}
|
||||||
/>
|
/>}
|
||||||
)}
|
{!!rolesWidgets.length &&
|
||||||
{!!rolesWidgets.length && showCustomerWidgets && (
|
hasPermission(currentUser, 'CREATE_ROLES') && (
|
||||||
<p className='mb-4 text-gray-500 dark:text-gray-400'>
|
<p className=' text-gray-500 dark:text-gray-400 mb-4'>
|
||||||
{`${widgetsRole?.role?.label || 'Users'}'s widgets`}
|
{`${widgetsRole?.role?.label || 'Users'}'s widgets`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
|
||||||
|
|
||||||
{!adminPortal && (
|
|
||||||
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-6 grid-flow-dense'>
|
|
||||||
{(isFetchingQuery || loading) && (
|
|
||||||
<div className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 text-lg leading-tight text-gray-500 flex items-center ${cardsStyle} dark:border-dark-700 p-6`}>
|
|
||||||
<BaseIcon
|
|
||||||
className={`${iconsColor} animate-spin mr-5`}
|
|
||||||
w='w-16'
|
|
||||||
h='h-16'
|
|
||||||
size={48}
|
|
||||||
path={icon.mdiLoading}
|
|
||||||
/>{' '}
|
|
||||||
Loading widgets...
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{rolesWidgets.map((widget) => (
|
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-6 grid-flow-dense'>
|
||||||
<SmartWidget
|
{(isFetchingQuery || loading) && (
|
||||||
key={widget.id}
|
<div className={` ${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 text-lg leading-tight text-gray-500 flex items-center ${cardsStyle} dark:border-dark-700 p-6`}>
|
||||||
userId={currentUser?.id}
|
<BaseIcon
|
||||||
widget={widget}
|
className={`${iconsColor} animate-spin mr-5`}
|
||||||
roleId={widgetsRole?.role?.value || ''}
|
w='w-16'
|
||||||
admin={false}
|
h='h-16'
|
||||||
/>
|
size={48}
|
||||||
|
path={icon.mdiLoading}
|
||||||
|
/>{' '}
|
||||||
|
Loading widgets...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ rolesWidgets &&
|
||||||
|
rolesWidgets.map((widget) => (
|
||||||
|
<SmartWidget
|
||||||
|
key={widget.id}
|
||||||
|
userId={currentUser?.id}
|
||||||
|
widget={widget}
|
||||||
|
roleId={widgetsRole?.role?.value || ''}
|
||||||
|
admin={hasPermission(currentUser, 'CREATE_ROLES')}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!adminPortal && !!rolesWidgets.length && <hr className='my-6' />}
|
|
||||||
|
|
||||||
<div id='dashboard' className='grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6'>
|
|
||||||
{visibleCards.map((card) => (
|
|
||||||
<StatCard
|
|
||||||
key={`${card.key}-${card.label}`}
|
|
||||||
card={card}
|
|
||||||
value={counts[card.key]}
|
|
||||||
corners={corners}
|
|
||||||
cardsStyle={cardsStyle}
|
|
||||||
iconsColor={iconsColor}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='grid grid-cols-1 gap-6 lg:grid-cols-3'>
|
{!!rolesWidgets.length && <hr className='my-6 ' />}
|
||||||
{actionGroups.map((group) => (
|
|
||||||
<ActionGroupCard key={group.title} group={group} currentUser={currentUser} />
|
<div id="dashboard" className='grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6'>
|
||||||
))}
|
|
||||||
|
|
||||||
|
{hasPermission(currentUser, 'READ_USERS') && <Link href={'/users/users-list'}>
|
||||||
|
<div
|
||||||
|
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between align-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
|
||||||
|
Users
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl leading-tight font-semibold">
|
||||||
|
{users}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<BaseIcon
|
||||||
|
className={`${iconsColor}`}
|
||||||
|
w="w-16"
|
||||||
|
h="h-16"
|
||||||
|
size={48}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
path={icon.mdiAccountGroup || icon.mdiTable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>}
|
||||||
|
|
||||||
|
{hasPermission(currentUser, 'READ_ROLES') && <Link href={'/roles/roles-list'}>
|
||||||
|
<div
|
||||||
|
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between align-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
|
||||||
|
Roles
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl leading-tight font-semibold">
|
||||||
|
{roles}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<BaseIcon
|
||||||
|
className={`${iconsColor}`}
|
||||||
|
w="w-16"
|
||||||
|
h="h-16"
|
||||||
|
size={48}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
path={icon.mdiShieldAccountVariantOutline || icon.mdiTable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>}
|
||||||
|
|
||||||
|
{hasPermission(currentUser, 'READ_PERMISSIONS') && <Link href={'/permissions/permissions-list'}>
|
||||||
|
<div
|
||||||
|
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between align-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
|
||||||
|
Permissions
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl leading-tight font-semibold">
|
||||||
|
{permissions}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<BaseIcon
|
||||||
|
className={`${iconsColor}`}
|
||||||
|
w="w-16"
|
||||||
|
h="h-16"
|
||||||
|
size={48}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
path={icon.mdiShieldAccountOutline || icon.mdiTable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>}
|
||||||
|
|
||||||
|
{hasPermission(currentUser, 'READ_BUSINESSES') && <Link href={'/businesses/businesses-list'}>
|
||||||
|
<div
|
||||||
|
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between align-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
|
||||||
|
Businesses
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl leading-tight font-semibold">
|
||||||
|
{businesses}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<BaseIcon
|
||||||
|
className={`${iconsColor}`}
|
||||||
|
w="w-16"
|
||||||
|
h="h-16"
|
||||||
|
size={48}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
path={'mdiStore' in icon ? icon['mdiStore' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>}
|
||||||
|
|
||||||
|
{hasPermission(currentUser, 'READ_CUSTOMERS') && <Link href={'/customers/customers-list'}>
|
||||||
|
<div
|
||||||
|
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between align-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
|
||||||
|
Customers
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl leading-tight font-semibold">
|
||||||
|
{customers}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<BaseIcon
|
||||||
|
className={`${iconsColor}`}
|
||||||
|
w="w-16"
|
||||||
|
h="h-16"
|
||||||
|
size={48}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
path={'mdiAccountMultiple' in icon ? icon['mdiAccountMultiple' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>}
|
||||||
|
|
||||||
|
{hasPermission(currentUser, 'READ_TRANSACTIONS') && <Link href={'/transactions/transactions-list'}>
|
||||||
|
<div
|
||||||
|
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between align-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
|
||||||
|
Transactions
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl leading-tight font-semibold">
|
||||||
|
{transactions}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<BaseIcon
|
||||||
|
className={`${iconsColor}`}
|
||||||
|
w="w-16"
|
||||||
|
h="h-16"
|
||||||
|
size={48}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
path={'mdiCreditCardOutline' in icon ? icon['mdiCreditCardOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>}
|
||||||
|
|
||||||
|
{hasPermission(currentUser, 'READ_REVIEW_REQUESTS') && <Link href={'/review_requests/review_requests-list'}>
|
||||||
|
<div
|
||||||
|
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between align-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
|
||||||
|
Review requests
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl leading-tight font-semibold">
|
||||||
|
{review_requests}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<BaseIcon
|
||||||
|
className={`${iconsColor}`}
|
||||||
|
w="w-16"
|
||||||
|
h="h-16"
|
||||||
|
size={48}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
path={'mdiEmailFastOutline' in icon ? icon['mdiEmailFastOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>}
|
||||||
|
|
||||||
|
{hasPermission(currentUser, 'READ_STRIPE_EVENTS') && <Link href={'/stripe_events/stripe_events-list'}>
|
||||||
|
<div
|
||||||
|
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between align-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
|
||||||
|
Stripe events
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl leading-tight font-semibold">
|
||||||
|
{stripe_events}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<BaseIcon
|
||||||
|
className={`${iconsColor}`}
|
||||||
|
w="w-16"
|
||||||
|
h="h-16"
|
||||||
|
size={48}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
path={'mdiWebhook' in icon ? icon['mdiWebhook' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>}
|
||||||
|
|
||||||
|
{hasPermission(currentUser, 'READ_EMAIL_DELIVERY_LOGS') && <Link href={'/email_delivery_logs/email_delivery_logs-list'}>
|
||||||
|
<div
|
||||||
|
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between align-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
|
||||||
|
Email delivery logs
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl leading-tight font-semibold">
|
||||||
|
{email_delivery_logs}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<BaseIcon
|
||||||
|
className={`${iconsColor}`}
|
||||||
|
w="w-16"
|
||||||
|
h="h-16"
|
||||||
|
size={48}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
path={'mdiEmailCheckOutline' in icon ? icon['mdiEmailCheckOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>}
|
||||||
|
|
||||||
|
{hasPermission(currentUser, 'READ_CRON_RUNS') && <Link href={'/cron_runs/cron_runs-list'}>
|
||||||
|
<div
|
||||||
|
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between align-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
|
||||||
|
Cron runs
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl leading-tight font-semibold">
|
||||||
|
{cron_runs}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<BaseIcon
|
||||||
|
className={`${iconsColor}`}
|
||||||
|
w="w-16"
|
||||||
|
h="h-16"
|
||||||
|
size={48}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
path={'mdiClockOutline' in icon ? icon['mdiClockOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>}
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</SectionMain>
|
</SectionMain>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1,261 +1,161 @@
|
|||||||
import { mdiArrowRight, mdiCheckCircleOutline, mdiLogin, mdiShieldCheckOutline, mdiStarCircleOutline } from '@mdi/js';
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import React, { ReactElement } from 'react';
|
|
||||||
import BaseButton from '../components/BaseButton';
|
import BaseButton from '../components/BaseButton';
|
||||||
import CardBox from '../components/CardBox';
|
import CardBox from '../components/CardBox';
|
||||||
|
import SectionFullScreen from '../components/SectionFullScreen';
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
|
import BaseDivider from '../components/BaseDivider';
|
||||||
|
import BaseButtons from '../components/BaseButtons';
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import { subscriptionPlans, trialDays } from '../subscriptionPlans';
|
import { useAppSelector } from '../stores/hooks';
|
||||||
import { getBusinessProfileNoun } from '../helpers/businessPlanLabels';
|
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
||||||
|
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
||||||
|
|
||||||
const metrics = [
|
|
||||||
['7 days', 'default review delay'],
|
|
||||||
['5 sources', 'Stripe, Square, PayPal, Shopify, WooCommerce'],
|
|
||||||
['4 states', 'pending, sent, clicked, reviewed'],
|
|
||||||
];
|
|
||||||
|
|
||||||
const steps = [
|
|
||||||
['Capture', 'Receive Stripe, Square, PayPal, Shopify, or WooCommerce webhooks as soon as checkout happens.'],
|
|
||||||
['Schedule', 'Create the customer, transaction, and review request automatically with your preferred delay.'],
|
|
||||||
['Track', 'Follow pending, sent, clicked, and reviewed requests from one workspace.'],
|
|
||||||
];
|
|
||||||
|
|
||||||
const features = [
|
|
||||||
'Business review links and templates',
|
|
||||||
'Webhook-created customers and transactions',
|
|
||||||
'Readable queue with message preview',
|
|
||||||
'Internal admin controls stay separate from customer workspaces',
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Starter() {
|
export default function Starter() {
|
||||||
|
const [illustrationImage, setIllustrationImage] = useState({
|
||||||
|
src: undefined,
|
||||||
|
photographer: undefined,
|
||||||
|
photographer_url: undefined,
|
||||||
|
})
|
||||||
|
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
||||||
|
const [contentType, setContentType] = useState('video');
|
||||||
|
const [contentPosition, setContentPosition] = useState('left');
|
||||||
|
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||||
|
|
||||||
|
const title = 'ReviewFlow'
|
||||||
|
|
||||||
|
// Fetch Pexels image/video
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchData() {
|
||||||
|
const image = await getPexelsImage();
|
||||||
|
const video = await getPexelsVideo();
|
||||||
|
setIllustrationImage(image);
|
||||||
|
setIllustrationVideo(video);
|
||||||
|
}
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const imageBlock = (image) => (
|
||||||
|
<div
|
||||||
|
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
||||||
|
style={{
|
||||||
|
backgroundImage: `${
|
||||||
|
image
|
||||||
|
? `url(${image?.src?.original})`
|
||||||
|
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||||
|
}`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'left center',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className='flex justify-center w-full bg-blue-300/20'>
|
||||||
|
<a
|
||||||
|
className='text-[8px]'
|
||||||
|
href={image?.photographer_url}
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer'
|
||||||
|
>
|
||||||
|
Photo by {image?.photographer} on Pexels
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const videoBlock = (video) => {
|
||||||
|
if (video?.video_files?.length > 0) {
|
||||||
|
return (
|
||||||
|
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
||||||
|
<video
|
||||||
|
className='absolute top-0 left-0 w-full h-full object-cover'
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
>
|
||||||
|
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
||||||
|
<a
|
||||||
|
className='text-[8px]'
|
||||||
|
href={video?.user?.url}
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer'
|
||||||
|
>
|
||||||
|
Video by {video.user.name} on Pexels
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F7F8FC] text-slate-950">
|
<div
|
||||||
|
style={
|
||||||
|
contentPosition === 'background'
|
||||||
|
? {
|
||||||
|
backgroundImage: `${
|
||||||
|
illustrationImage
|
||||||
|
? `url(${illustrationImage.src?.original})`
|
||||||
|
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||||
|
}`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'left center',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Review Flow')}</title>
|
<title>{getPageTitle('Starter Page')}</title>
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
content="Review Flow helps businesses queue and track review requests after customer purchases or visits."
|
|
||||||
/>
|
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<header className="sticky top-0 z-20 border-b border-white/70 bg-white/80 backdrop-blur-xl">
|
<SectionFullScreen bg='violet'>
|
||||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
|
<div
|
||||||
<Link href="/" className="flex items-center gap-3 font-black tracking-tight">
|
className={`flex ${
|
||||||
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#101828] text-white shadow-lg shadow-indigo-950/20">
|
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||||
★
|
} min-h-screen w-full`}
|
||||||
</span>
|
>
|
||||||
<span className="text-xl">Review Flow</span>
|
{contentType === 'image' && contentPosition !== 'background'
|
||||||
</Link>
|
? imageBlock(illustrationImage)
|
||||||
<nav className="flex items-center gap-3">
|
: null}
|
||||||
<BaseButton href="/#pricing" label="Pricing" color="whiteDark" />
|
{contentType === 'video' && contentPosition !== 'background'
|
||||||
<BaseButton href="/login" icon={mdiLogin} label="Login" color="whiteDark" />
|
? videoBlock(illustrationVideo)
|
||||||
<BaseButton href="/reviewflow" icon={mdiArrowRight} label="Review Flow workspace" color="info" />
|
: null}
|
||||||
</nav>
|
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||||
|
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||||
|
<CardBoxComponentTitle title="Welcome to your ReviewFlow app!"/>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
||||||
|
<p className='text-center text-gray-500'>For guides and documentation please check
|
||||||
|
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BaseButtons>
|
||||||
|
<BaseButton
|
||||||
|
href='/login'
|
||||||
|
label='Login'
|
||||||
|
color='info'
|
||||||
|
className='w-full'
|
||||||
|
/>
|
||||||
|
|
||||||
|
</BaseButtons>
|
||||||
|
</CardBox>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</div>
|
||||||
|
</SectionFullScreen>
|
||||||
|
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
||||||
|
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
||||||
|
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<main>
|
|
||||||
<section className="relative isolate overflow-hidden px-6 py-20 md:py-28">
|
|
||||||
<div className="absolute left-1/2 top-0 -z-10 h-[640px] w-[920px] -translate-x-1/2 rounded-full bg-[radial-gradient(circle_at_center,#7C3AED_0%,#10B981_38%,transparent_68%)] opacity-20 blur-3xl" />
|
|
||||||
<div className="mx-auto grid max-w-7xl gap-12 lg:grid-cols-[1.05fr_0.95fr] lg:items-center">
|
|
||||||
<div>
|
|
||||||
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm font-bold text-emerald-700">
|
|
||||||
<span className="h-2 w-2 rounded-full bg-emerald-500" />
|
|
||||||
Review automation for modern local businesses
|
|
||||||
</div>
|
|
||||||
<h1 className="max-w-4xl text-5xl font-black leading-[0.95] tracking-tight text-slate-950 md:text-7xl">
|
|
||||||
Ask at the perfect moment. Earn more five-star reviews.
|
|
||||||
</h1>
|
|
||||||
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600">
|
|
||||||
Review Flow turns Stripe, Square, PayPal, Shopify, and WooCommerce webhooks into scheduled review requests with a clean queue, message preview, and admin controls already wired into your app.
|
|
||||||
</p>
|
|
||||||
<div className="mt-8 flex flex-wrap gap-3">
|
|
||||||
<BaseButton href="/reviewflow" icon={mdiStarCircleOutline} label="Open Review Flow" color="info" className="shadow-xl shadow-indigo-600/20" />
|
|
||||||
<BaseButton href="/login" icon={mdiShieldCheckOutline} label="Log in to workspace" color="whiteDark" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-10 grid max-w-2xl gap-3 sm:grid-cols-3">
|
|
||||||
{metrics.map(([value, label]) => (
|
|
||||||
<div key={label} className="rounded-3xl border border-white bg-white/80 p-5 shadow-xl shadow-slate-200/60">
|
|
||||||
<p className="text-3xl font-black text-slate-950">{value}</p>
|
|
||||||
<p className="mt-1 text-sm text-slate-500">{label}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CardBox className="border-0 bg-white/90 shadow-2xl shadow-indigo-950/10 ring-1 ring-slate-200/70" cardBoxClassName="p-0">
|
|
||||||
<div className="rounded-t-3xl bg-[#101828] p-5 text-white">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-bold uppercase tracking-[0.25em] text-emerald-300">Live workflow</p>
|
|
||||||
<h2 className="mt-2 text-2xl font-black">Review request queued</h2>
|
|
||||||
</div>
|
|
||||||
<span className="rounded-full bg-emerald-400/20 px-3 py-1 text-sm font-bold text-emerald-200 ring-1 ring-emerald-300/30">
|
|
||||||
pending
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-5 p-6">
|
|
||||||
<div className="rounded-3xl bg-slate-50 p-5">
|
|
||||||
<p className="text-xs font-black uppercase tracking-[0.25em] text-slate-400">Customer</p>
|
|
||||||
<p className="mt-2 text-lg font-black">Maya Chen</p>
|
|
||||||
<p className="text-slate-500">maya@example.com</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<div className="rounded-3xl bg-indigo-50 p-5 text-indigo-950">
|
|
||||||
<p className="text-sm font-bold text-indigo-500">Scheduled</p>
|
|
||||||
<p className="text-2xl font-black">+7 days</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-3xl bg-emerald-50 p-5 text-emerald-950">
|
|
||||||
<p className="text-sm font-bold text-emerald-600">Destination</p>
|
|
||||||
<p className="text-2xl font-black">Google</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-3xl border border-dashed border-slate-200 p-5">
|
|
||||||
<p className="font-black">How was your experience with Review Flow Studio?</p>
|
|
||||||
<p className="mt-2 text-sm leading-6 text-slate-500">
|
|
||||||
Hi Maya, thank you for choosing us. We would love to hear about your experience.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="px-6 pb-20">
|
|
||||||
<div className="mx-auto grid max-w-7xl gap-6 lg:grid-cols-3">
|
|
||||||
{steps.map(([title, copy], index) => (
|
|
||||||
<div key={title} className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-xl shadow-slate-200/50">
|
|
||||||
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-950 text-lg font-black text-white">
|
|
||||||
{index + 1}
|
|
||||||
</div>
|
|
||||||
<h3 className="text-2xl font-black">{title}</h3>
|
|
||||||
<p className="mt-3 leading-7 text-slate-600">{copy}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="pricing" className="px-6 pb-20">
|
|
||||||
<div className="mx-auto max-w-7xl">
|
|
||||||
<div className="mx-auto max-w-3xl text-center">
|
|
||||||
<p className="text-sm font-black uppercase tracking-[0.3em] text-emerald-600">Simple pricing</p>
|
|
||||||
<h2 className="mt-4 text-4xl font-black tracking-tight text-slate-950 md:text-5xl">Choose Starter or Pro.</h2>
|
|
||||||
<p className="mt-5 text-lg leading-8 text-slate-600">
|
|
||||||
Every plan starts with a {trialDays}-day free trial. Starter covers the core review workflow. Pro adds the advanced automation and reputation marketing tools growing teams need.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-12 grid gap-6 lg:grid-cols-2">
|
|
||||||
{subscriptionPlans.map((plan) => {
|
|
||||||
const isPro = plan.id === 'pro';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CardBox
|
|
||||||
key={plan.id}
|
|
||||||
className={`relative overflow-hidden border-0 bg-white shadow-2xl ${
|
|
||||||
isPro ? 'shadow-indigo-950/20 ring-2 ring-indigo-600' : 'shadow-slate-200/70 ring-1 ring-slate-200'
|
|
||||||
}`}
|
|
||||||
cardBoxClassName="p-0"
|
|
||||||
>
|
|
||||||
{plan.highlight && (
|
|
||||||
<div className="absolute right-6 top-6 rounded-full bg-indigo-600 px-4 py-1 text-sm font-black text-white shadow-lg shadow-indigo-600/30">
|
|
||||||
{plan.highlight}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="p-8">
|
|
||||||
<p className="text-sm font-black uppercase tracking-[0.3em] text-slate-400">Review Flow</p>
|
|
||||||
<h3 className="mt-3 text-3xl font-black text-slate-950">{plan.name}</h3>
|
|
||||||
<p className="mt-3 min-h-[56px] leading-7 text-slate-600">{plan.tagline}</p>
|
|
||||||
|
|
||||||
<div className="mt-8 flex items-end gap-2">
|
|
||||||
<span className="text-5xl font-black tracking-tight text-slate-950">${plan.priceMonthly}</span>
|
|
||||||
<span className="pb-2 font-bold text-slate-500">/month</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-sm font-bold text-emerald-600">{plan.trialDays}-day free trial included</p>
|
|
||||||
|
|
||||||
<div className="mt-8 grid gap-3 rounded-3xl bg-slate-50 p-5 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<p className="text-2xl font-black text-slate-950">{plan.limits.monthlyReviewRequests.toLocaleString()}</p>
|
|
||||||
<p className="text-sm text-slate-500">review requests/month</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-2xl font-black text-slate-950">{plan.limits.businesses}</p>
|
|
||||||
<p className="text-sm text-slate-500">{getBusinessProfileNoun(plan.limits.businesses)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-2xl font-black text-slate-950">{plan.limits.teamMembers}</p>
|
|
||||||
<p className="text-sm text-slate-500">team members</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-2xl font-black text-slate-950">{plan.limits.paymentConnectors}</p>
|
|
||||||
<p className="text-sm text-slate-500">payment connectors</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<BaseButton
|
|
||||||
href={`/register?plan=${plan.id}`}
|
|
||||||
icon={mdiArrowRight}
|
|
||||||
label={plan.ctaLabel}
|
|
||||||
color={isPro ? 'info' : 'whiteDark'}
|
|
||||||
className="mt-8 w-full shadow-xl shadow-indigo-600/10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={isPro ? 'bg-indigo-950 p-8 text-white' : 'bg-slate-950 p-8 text-white'}>
|
|
||||||
<p className="mb-5 text-sm font-black uppercase tracking-[0.25em] text-emerald-300">Included features</p>
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{plan.features.map((feature) => (
|
|
||||||
<div key={feature} className="flex items-start gap-3">
|
|
||||||
<span className="mt-1 text-emerald-300">
|
|
||||||
<svg className="h-5 w-5" viewBox="0 0 24 24">
|
|
||||||
<path fill="currentColor" d={mdiCheckCircleOutline} />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<span className="font-semibold text-slate-100">{feature}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="bg-[#101828] px-6 py-20 text-white">
|
|
||||||
<div className="mx-auto grid max-w-7xl gap-10 lg:grid-cols-[0.8fr_1.2fr] lg:items-center">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-black uppercase tracking-[0.3em] text-emerald-300">First MVP slice</p>
|
|
||||||
<h2 className="mt-4 text-4xl font-black tracking-tight md:text-5xl">A complete thin workflow, not just a screen.</h2>
|
|
||||||
<p className="mt-5 leading-8 text-slate-300">
|
|
||||||
The customer workspace lets an account owner connect payment webhooks, receive events, create transactions and customers, queue review requests, browse recent activity, and inspect the generated message. Internal admin users stay separate for support and operations.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
{features.map((feature) => (
|
|
||||||
<div key={feature} className="flex items-start gap-3 rounded-3xl bg-white/10 p-5 ring-1 ring-white/10">
|
|
||||||
<span className="mt-1 text-emerald-300"><svg className="h-5 w-5" viewBox="0 0 24 24"><path fill="currentColor" d={mdiCheckCircleOutline} /></svg></span>
|
|
||||||
<span className="font-semibold text-slate-100">{feature}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer className="bg-white px-6 py-8">
|
|
||||||
<div className="mx-auto flex max-w-7xl flex-col gap-4 text-sm text-slate-500 md:flex-row md:items-center md:justify-between">
|
|
||||||
<p>© 2026 Review Flow. All rights reserved.</p>
|
|
||||||
<div className="flex gap-5">
|
|
||||||
<Link href="/privacy-policy/" className="hover:text-slate-950">Privacy Policy</Link>
|
|
||||||
<Link href="/terms-of-use/" className="hover:text-slate-950">Terms of Use</Link>
|
|
||||||
<Link href="/login" className="font-bold text-slate-950">Login</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -263,3 +163,4 @@ export default function Starter() {
|
|||||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,7 @@ import { findMe, loginUser, resetAction } from '../stores/authSlice';
|
|||||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {toast, ToastContainer} from "react-toastify";
|
import {toast, ToastContainer} from "react-toastify";
|
||||||
import { getPexelsImage } from '../helpers/pexels'
|
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -33,7 +33,9 @@ export default function Login() {
|
|||||||
photographer: undefined,
|
photographer: undefined,
|
||||||
photographer_url: undefined,
|
photographer_url: undefined,
|
||||||
})
|
})
|
||||||
const [contentPosition] = useState<'left' | 'right' | 'background'>('left');
|
const [ illustrationVideo, setIllustrationVideo ] = useState({video_files: []})
|
||||||
|
const [contentType, setContentType] = useState('video');
|
||||||
|
const [contentPosition, setContentPosition] = useState('left');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
|
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
|
||||||
(state) => state.auth,
|
(state) => state.auth,
|
||||||
@ -42,90 +44,15 @@ export default function Login() {
|
|||||||
password: 'fc6e39e3',
|
password: 'fc6e39e3',
|
||||||
remember: true })
|
remember: true })
|
||||||
|
|
||||||
const title = 'Review Flow'
|
const title = 'ReviewFlow'
|
||||||
|
|
||||||
const appHighlights = [
|
// Fetch Pexels image/video
|
||||||
'Automated review requests after payments, jobs, or service milestones.',
|
|
||||||
'Customer, business, transaction, and delivery follow-up data in one customer workspace.',
|
|
||||||
'Dashboards, CRM records, payment events, email logs, and separate internal admin controls already built in.',
|
|
||||||
];
|
|
||||||
|
|
||||||
const competitorAdvantages = [
|
|
||||||
{
|
|
||||||
title: 'Built around review operations',
|
|
||||||
description:
|
|
||||||
'Review Flow combines CRM records, payments, follow-up, review requests, and reputation workflows in one focused system.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Designed for logistics teams',
|
|
||||||
description:
|
|
||||||
'Transportation teams can manage businesses, customers, transactions, payment events, and review requests without jumping tools.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Clear Starter and Pro tiers',
|
|
||||||
description:
|
|
||||||
'Starter is $49/month for the core review workflow. Pro is $99/month for higher limits, automation, AI, and reputation marketing tools.',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const pricingPlans = [
|
|
||||||
{
|
|
||||||
name: 'Starter',
|
|
||||||
price: '$49',
|
|
||||||
description:
|
|
||||||
'Best for small teams that need the core Review Flow workflow and simple monthly limits.',
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
title: 'Core review workflow',
|
|
||||||
features: [
|
|
||||||
'Review Flow workspace for creating, scheduling, and tracking review requests.',
|
|
||||||
'Manual review request creation and hosted public review forms.',
|
|
||||||
'Customer, business, transaction, and delivery follow-up records.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Starter limits',
|
|
||||||
features: [
|
|
||||||
'250 review requests per month.',
|
|
||||||
'1 business profile.',
|
|
||||||
'2 team members.',
|
|
||||||
'Stripe, Square, PayPal, Shopify, and WooCommerce webhook intake.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Pro',
|
|
||||||
price: '$99',
|
|
||||||
description:
|
|
||||||
'Best for growing teams that want higher limits, automation, AI assistance, and reputation marketing tools.',
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
title: 'Everything in Starter',
|
|
||||||
features: [
|
|
||||||
'2,500 review requests per month.',
|
|
||||||
'10 business profiles.',
|
|
||||||
'10 team members.',
|
|
||||||
'Priority support and advanced reporting.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Growth tools',
|
|
||||||
features: [
|
|
||||||
'Advanced automation rules.',
|
|
||||||
'AI review reply assistant.',
|
|
||||||
'Social proof widgets, referral campaigns, repeat booking reminders, NPS surveys, and broadcasts.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Fetch Pexels image
|
|
||||||
useEffect( () => {
|
useEffect( () => {
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
const image = await getPexelsImage()
|
const image = await getPexelsImage()
|
||||||
|
const video = await getPexelsVideo()
|
||||||
setIllustrationImage(image);
|
setIllustrationImage(image);
|
||||||
|
setIllustrationVideo(video);
|
||||||
}
|
}
|
||||||
fetchData();
|
fetchData();
|
||||||
}, []);
|
}, []);
|
||||||
@ -188,7 +115,32 @@ export default function Login() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const videoBlock = (video) => {
|
||||||
|
if (video?.video_files?.length > 0) {
|
||||||
|
return (
|
||||||
|
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
||||||
|
<video
|
||||||
|
className='absolute top-0 left-0 w-full h-full object-cover'
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
>
|
||||||
|
<source src={video.video_files[0]?.link} type='video/mp4'/>
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
||||||
|
<a
|
||||||
|
className='text-[8px]'
|
||||||
|
href={video.user.url}
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer'
|
||||||
|
>
|
||||||
|
Video by {video.user.name} on Pexels
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={contentPosition === 'background' ? {
|
<div style={contentPosition === 'background' ? {
|
||||||
@ -207,7 +159,8 @@ export default function Login() {
|
|||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<SectionFullScreen bg='violet'>
|
||||||
<div className={`flex ${contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'} min-h-screen w-full`}>
|
<div className={`flex ${contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'} min-h-screen w-full`}>
|
||||||
{contentPosition !== 'background' ? imageBlock(illustrationImage) : null}
|
{contentType === 'image' && contentPosition !== 'background' ? imageBlock(illustrationImage) : null}
|
||||||
|
{contentType === 'video' && contentPosition !== 'background' ? videoBlock(illustrationVideo) : null}
|
||||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||||
|
|
||||||
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'>
|
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'>
|
||||||
@ -222,13 +175,13 @@ export default function Login() {
|
|||||||
data-password="fc6e39e3"
|
data-password="fc6e39e3"
|
||||||
onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '}
|
onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '}
|
||||||
<code className={`${textColor}`}>fc6e39e3</code>{' / '}
|
<code className={`${textColor}`}>fc6e39e3</code>{' / '}
|
||||||
to login as Internal Admin</p>
|
to login as Admin</p>
|
||||||
<p>Use <code
|
<p>Use <code
|
||||||
className={`cursor-pointer ${textColor} `}
|
className={`cursor-pointer ${textColor} `}
|
||||||
data-password="874c3b951385"
|
data-password="874c3b951385"
|
||||||
onClick={(e) => setLogin(e.target)}>john@doe.com</code>{' / '}
|
onClick={(e) => setLogin(e.target)}>client@hello.com</code>{' / '}
|
||||||
<code className={`${textColor}`}>874c3b951385</code>{' / '}
|
<code className={`${textColor}`}>874c3b951385</code>{' / '}
|
||||||
to login as Customer Owner</p>
|
to login as User</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<BaseIcon
|
<BaseIcon
|
||||||
@ -304,95 +257,6 @@ export default function Login() {
|
|||||||
</Form>
|
</Form>
|
||||||
</Formik>
|
</Formik>
|
||||||
</CardBox>
|
</CardBox>
|
||||||
|
|
||||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
|
||||||
<div className='space-y-8'>
|
|
||||||
<div>
|
|
||||||
<p className='text-sm font-semibold uppercase tracking-[0.2em] text-blue-600'>About Us</p>
|
|
||||||
<h3 className='mt-2 text-3xl font-semibold text-gray-900 dark:text-white'>
|
|
||||||
Review management built for transportation teams.
|
|
||||||
</h3>
|
|
||||||
<p className='mt-4 text-base leading-7 text-gray-600 dark:text-slate-300'>
|
|
||||||
Review Flow helps logistics and transportation businesses turn completed jobs, payments,
|
|
||||||
and customer interactions into organized review requests. Your team can manage customer
|
|
||||||
records, monitor follow-up, and keep reputation-building work moving from one secure
|
|
||||||
workspace.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='grid gap-3 md:grid-cols-3'>
|
|
||||||
{appHighlights.map((highlight) => (
|
|
||||||
<div
|
|
||||||
key={highlight}
|
|
||||||
className='rounded-2xl border border-blue-100 bg-blue-50/70 p-4 text-sm leading-6 text-blue-900 dark:border-dark-700 dark:bg-dark-800 dark:text-slate-200'
|
|
||||||
>
|
|
||||||
{highlight}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 className='text-xl font-semibold text-gray-900 dark:text-white'>Why we're better</h4>
|
|
||||||
<div className='mt-4 grid gap-4 md:grid-cols-3'>
|
|
||||||
{competitorAdvantages.map((item) => (
|
|
||||||
<div key={item.title} className='rounded-2xl border border-gray-200 p-4 dark:border-dark-700'>
|
|
||||||
<h5 className='font-semibold text-gray-900 dark:text-white'>{item.title}</h5>
|
|
||||||
<p className='mt-2 text-sm leading-6 text-gray-600 dark:text-slate-300'>
|
|
||||||
{item.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className='flex flex-col justify-between gap-2 md:flex-row md:items-end'>
|
|
||||||
<div>
|
|
||||||
<p className='text-sm font-semibold uppercase tracking-[0.2em] text-blue-600'>Pricing</p>
|
|
||||||
<h4 className='mt-2 text-2xl font-semibold text-gray-900 dark:text-white'>Simple monthly plans</h4>
|
|
||||||
</div>
|
|
||||||
<p className='text-sm text-gray-500 dark:text-slate-400'>Upgrade when your review workflow grows.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='mt-4 grid gap-4 md:grid-cols-2'>
|
|
||||||
{pricingPlans.map((plan) => (
|
|
||||||
<div
|
|
||||||
key={plan.name}
|
|
||||||
className='rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-dark-700 dark:bg-dark-900'
|
|
||||||
>
|
|
||||||
<div className='flex items-start justify-between gap-3'>
|
|
||||||
<div>
|
|
||||||
<h5 className='text-lg font-semibold text-gray-900 dark:text-white'>{plan.name}</h5>
|
|
||||||
<p className='mt-1 text-sm leading-6 text-gray-600 dark:text-slate-300'>{plan.description}</p>
|
|
||||||
</div>
|
|
||||||
<div className='text-right'>
|
|
||||||
<span className='text-3xl font-bold text-blue-600'>{plan.price}</span>
|
|
||||||
<span className='block text-xs text-gray-500 dark:text-slate-400'>/month</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='mt-5 space-y-5'>
|
|
||||||
{plan.sections.map((section) => (
|
|
||||||
<div key={section.title}>
|
|
||||||
<h6 className='text-sm font-semibold uppercase tracking-wide text-gray-900 dark:text-white'>
|
|
||||||
{section.title}
|
|
||||||
</h6>
|
|
||||||
<ul className='mt-2 space-y-2 text-sm text-gray-700 dark:text-slate-300'>
|
|
||||||
{section.features.map((feature) => (
|
|
||||||
<li key={feature} className='flex gap-2'>
|
|
||||||
<span className='font-semibold text-blue-600'>✓</span>
|
|
||||||
<span>{feature}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionFullScreen>
|
</SectionFullScreen>
|
||||||
|
|||||||
@ -175,7 +175,6 @@ const EditPermissions = () => {
|
|||||||
EditPermissions.getLayout = function getLayout(page: ReactElement) {
|
EditPermissions.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'UPDATE_PERMISSIONS'}
|
permission={'UPDATE_PERMISSIONS'}
|
||||||
|
|
||||||
|
|||||||
@ -172,7 +172,6 @@ const EditPermissionsPage = () => {
|
|||||||
EditPermissionsPage.getLayout = function getLayout(page: ReactElement) {
|
EditPermissionsPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'UPDATE_PERMISSIONS'}
|
permission={'UPDATE_PERMISSIONS'}
|
||||||
|
|
||||||
|
|||||||
@ -150,7 +150,6 @@ const PermissionsTablesPage = () => {
|
|||||||
PermissionsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
PermissionsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'READ_PERMISSIONS'}
|
permission={'READ_PERMISSIONS'}
|
||||||
|
|
||||||
|
|||||||
@ -131,7 +131,6 @@ const PermissionsNew = () => {
|
|||||||
PermissionsNew.getLayout = function getLayout(page: ReactElement) {
|
PermissionsNew.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'CREATE_PERMISSIONS'}
|
permission={'CREATE_PERMISSIONS'}
|
||||||
|
|
||||||
|
|||||||
@ -148,7 +148,6 @@ const PermissionsTablesPage = () => {
|
|||||||
PermissionsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
PermissionsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'READ_PERMISSIONS'}
|
permission={'READ_PERMISSIONS'}
|
||||||
|
|
||||||
|
|||||||
@ -115,7 +115,6 @@ const PermissionsView = () => {
|
|||||||
PermissionsView.getLayout = function getLayout(page: ReactElement) {
|
PermissionsView.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'READ_PERMISSIONS'}
|
permission={'READ_PERMISSIONS'}
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import LayoutGuest from '../layouts/Guest';
|
|||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
|
|
||||||
export default function PrivacyPolicy() {
|
export default function PrivacyPolicy() {
|
||||||
const title = 'Review Flow'
|
const title = 'ReviewFlow'
|
||||||
const [projectUrl, setProjectUrl] = useState('');
|
const [projectUrl, setProjectUrl] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -12,15 +12,12 @@ import BaseDivider from '../components/BaseDivider';
|
|||||||
import BaseButtons from '../components/BaseButtons';
|
import BaseButtons from '../components/BaseButtons';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import { subscriptionPlans } from '../subscriptionPlans';
|
|
||||||
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
export default function Register() {
|
export default function Register() {
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const selectedPlanId = typeof router.query.plan === 'string' ? router.query.plan : 'starter';
|
|
||||||
const selectedPlan = subscriptionPlans.find((plan) => plan.id === selectedPlanId) || subscriptionPlans[0];
|
|
||||||
const notify = (type, msg) => toast( msg, {type, position: "bottom-center"});
|
const notify = (type, msg) => toast( msg, {type, position: "bottom-center"});
|
||||||
|
|
||||||
|
|
||||||
@ -28,7 +25,7 @@ export default function Register() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const { data: response } = await axios.post('/auth/signup',{ ...value, planId: selectedPlan.id });
|
const { data: response } = await axios.post('/auth/signup',value);
|
||||||
await router.push('/login')
|
await router.push('/login')
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
notify('success', 'Please check your email for verification link')
|
notify('success', 'Please check your email for verification link')
|
||||||
@ -47,10 +44,6 @@ export default function Register() {
|
|||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<SectionFullScreen bg='violet'>
|
||||||
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
|
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
|
||||||
<div className='mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-emerald-900'>
|
|
||||||
<p className='font-black'>{selectedPlan.name} trial</p>
|
|
||||||
<p className='text-sm'>${selectedPlan.priceMonthly}/month after the {selectedPlan.trialDays}-day free trial. You can manage billing from Subscription after signup.</p>
|
|
||||||
</div>
|
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
email: '',
|
email: '',
|
||||||
|
|||||||
@ -1,344 +0,0 @@
|
|||||||
import {
|
|
||||||
mdiArrowLeft,
|
|
||||||
mdiCheckCircleOutline,
|
|
||||||
mdiStar,
|
|
||||||
} from '@mdi/js';
|
|
||||||
import axios from 'axios';
|
|
||||||
import Head from 'next/head';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import React, { FormEvent, ReactElement, useEffect, useState } from 'react';
|
|
||||||
import BaseButton from '../../components/BaseButton';
|
|
||||||
import CardBox from '../../components/CardBox';
|
|
||||||
import LayoutGuest from '../../layouts/Guest';
|
|
||||||
import { getPageTitle } from '../../config';
|
|
||||||
|
|
||||||
interface HostedReviewProduct {
|
|
||||||
name?: string;
|
|
||||||
sku?: string;
|
|
||||||
quantity?: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HostedReviewPayload {
|
|
||||||
provider?: string;
|
|
||||||
order?: {
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
orderNumber?: string;
|
|
||||||
};
|
|
||||||
products?: HostedReviewProduct[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HostedReviewRequest {
|
|
||||||
id: string;
|
|
||||||
status?: string;
|
|
||||||
review_platform?: string;
|
|
||||||
review_rating?: number | null;
|
|
||||||
review_title?: string | null;
|
|
||||||
review_content?: string | null;
|
|
||||||
reviewer_display_name?: string | null;
|
|
||||||
review_payload?: HostedReviewPayload | null;
|
|
||||||
business?: {
|
|
||||||
name?: string;
|
|
||||||
} | null;
|
|
||||||
customer?: {
|
|
||||||
name?: string;
|
|
||||||
email?: string;
|
|
||||||
} | null;
|
|
||||||
transaction?: {
|
|
||||||
payment_provider?: string;
|
|
||||||
description?: string;
|
|
||||||
amount?: string | number;
|
|
||||||
currency?: string;
|
|
||||||
} | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ratingOptions = [1, 2, 3, 4, 5];
|
|
||||||
|
|
||||||
function getErrorMessage(error: unknown) {
|
|
||||||
if (axios.isAxiosError(error) && error.response?.data) {
|
|
||||||
const responseData = error.response.data;
|
|
||||||
|
|
||||||
if (typeof responseData === 'string') {
|
|
||||||
return responseData;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof responseData === 'object' && 'message' in responseData) {
|
|
||||||
return String(responseData.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Something went wrong. Please try again.';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAmount(amount?: string | number, currency?: string) {
|
|
||||||
const numericAmount = Number(amount);
|
|
||||||
|
|
||||||
if (!Number.isFinite(numericAmount)) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Intl.NumberFormat('en', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: currency || 'USD',
|
|
||||||
}).format(numericAmount);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HostedReviewPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const trackingToken = Array.isArray(router.query.trackingToken)
|
|
||||||
? router.query.trackingToken[0]
|
|
||||||
: router.query.trackingToken;
|
|
||||||
const [review, setReview] = useState<HostedReviewRequest | null>(null);
|
|
||||||
const [rating, setRating] = useState(5);
|
|
||||||
const [title, setTitle] = useState('');
|
|
||||||
const [content, setContent] = useState('');
|
|
||||||
const [reviewerName, setReviewerName] = useState('');
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!trackingToken) return;
|
|
||||||
|
|
||||||
const loadReview = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios.get(
|
|
||||||
`/reviewflow-public/reviews/${trackingToken}`,
|
|
||||||
);
|
|
||||||
const loadedReview = response.data.review as HostedReviewRequest;
|
|
||||||
setReview(loadedReview);
|
|
||||||
setRating(loadedReview.review_rating || 5);
|
|
||||||
setTitle(loadedReview.review_title || '');
|
|
||||||
setContent(loadedReview.review_content || '');
|
|
||||||
setReviewerName(
|
|
||||||
loadedReview.reviewer_display_name || loadedReview.customer?.name || '',
|
|
||||||
);
|
|
||||||
setIsSubmitted(loadedReview.status === 'reviewed');
|
|
||||||
} catch (requestError) {
|
|
||||||
console.error('Failed to load hosted review request:', requestError);
|
|
||||||
setError(getErrorMessage(requestError));
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadReview();
|
|
||||||
}, [trackingToken]);
|
|
||||||
|
|
||||||
const submitReview = async (event: FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
if (!trackingToken) return;
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
setError('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios.post(
|
|
||||||
`/reviewflow-public/reviews/${trackingToken}`,
|
|
||||||
{
|
|
||||||
rating,
|
|
||||||
title,
|
|
||||||
content,
|
|
||||||
reviewerName,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
setReview(response.data.review);
|
|
||||||
setIsSubmitted(true);
|
|
||||||
} catch (requestError) {
|
|
||||||
console.error('Failed to submit hosted review:', requestError);
|
|
||||||
setError(getErrorMessage(requestError));
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const businessName = review?.business?.name || 'this business';
|
|
||||||
const products = review?.review_payload?.products || [];
|
|
||||||
const orderName =
|
|
||||||
review?.review_payload?.order?.name || review?.transaction?.description || '';
|
|
||||||
const amount = formatAmount(
|
|
||||||
review?.transaction?.amount,
|
|
||||||
review?.transaction?.currency,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head>
|
|
||||||
<title>{getPageTitle(`Review ${businessName}`)}</title>
|
|
||||||
</Head>
|
|
||||||
<main className='min-h-screen bg-gradient-to-br from-slate-950 via-indigo-950 to-emerald-950 px-4 py-10 text-slate-100'>
|
|
||||||
<div className='mx-auto max-w-3xl'>
|
|
||||||
<div className='mb-6 text-center'>
|
|
||||||
<p className='text-sm font-bold uppercase tracking-[0.3em] text-emerald-300'>
|
|
||||||
Review Flow
|
|
||||||
</p>
|
|
||||||
<h1 className='mt-3 text-4xl font-black tracking-tight md:text-5xl'>
|
|
||||||
Share your experience with {businessName}
|
|
||||||
</h1>
|
|
||||||
<p className='mt-3 text-base text-slate-300'>
|
|
||||||
Your feedback helps the team improve and helps future customers know what to expect.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CardBox className='border-0 bg-white text-slate-800 shadow-2xl dark:bg-slate-900 dark:text-slate-100'>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className='rounded-2xl border border-dashed border-slate-200 p-8 text-center text-slate-500 dark:border-slate-700'>
|
|
||||||
Loading your review form...
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className='space-y-4 rounded-2xl border border-rose-200 bg-rose-50 p-6 text-rose-900'>
|
|
||||||
<p className='font-black'>We could not load this review request.</p>
|
|
||||||
<p>{error}</p>
|
|
||||||
<BaseButton
|
|
||||||
href='/'
|
|
||||||
icon={mdiArrowLeft}
|
|
||||||
label='Back to website'
|
|
||||||
color='whiteDark'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : isSubmitted ? (
|
|
||||||
<div className='rounded-3xl bg-emerald-50 p-8 text-center text-emerald-950'>
|
|
||||||
<BaseButton icon={mdiCheckCircleOutline} color='success' roundedFull />
|
|
||||||
<h2 className='mt-4 text-3xl font-black'>Thank you for your review!</h2>
|
|
||||||
<p className='mt-3 text-emerald-800'>
|
|
||||||
Your feedback was submitted successfully.
|
|
||||||
</p>
|
|
||||||
{review?.review_rating && (
|
|
||||||
<div className='mt-5 flex justify-center gap-1 text-amber-500'>
|
|
||||||
{ratingOptions.map((option) => (
|
|
||||||
<span key={option}>{option <= Number(review.review_rating || 0) ? '★' : '☆'}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={submitReview} className='space-y-6'>
|
|
||||||
<div className='rounded-2xl bg-slate-50 p-4 dark:bg-slate-800'>
|
|
||||||
<p className='text-xs font-bold uppercase tracking-widest text-slate-400'>
|
|
||||||
Review context
|
|
||||||
</p>
|
|
||||||
<h2 className='mt-1 text-2xl font-black text-slate-900 dark:text-white'>
|
|
||||||
{businessName}
|
|
||||||
</h2>
|
|
||||||
{(orderName || amount || review?.transaction?.payment_provider) && (
|
|
||||||
<p className='mt-1 text-sm text-slate-500 dark:text-slate-300'>
|
|
||||||
{review?.transaction?.payment_provider || 'Order'}
|
|
||||||
{orderName ? ` · ${orderName}` : ''}
|
|
||||||
{amount ? ` · ${amount}` : ''}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{products.length > 0 && (
|
|
||||||
<div className='mt-4 grid gap-2'>
|
|
||||||
{products.map((product, index) => (
|
|
||||||
<div
|
|
||||||
key={`${product.name || 'product'}-${index}`}
|
|
||||||
className='rounded-xl bg-white p-3 text-sm ring-1 ring-slate-200 dark:bg-slate-900 dark:ring-slate-700'
|
|
||||||
>
|
|
||||||
<p className='font-bold text-slate-900 dark:text-white'>
|
|
||||||
{product.name || 'Purchased item'}
|
|
||||||
</p>
|
|
||||||
<p className='text-slate-500'>
|
|
||||||
{product.sku ? `SKU ${product.sku}` : 'Shopify product'}
|
|
||||||
{product.quantity ? ` · Qty ${product.quantity}` : ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className='mb-2 block text-sm font-black text-slate-900 dark:text-white'>
|
|
||||||
Your rating
|
|
||||||
</label>
|
|
||||||
<div className='flex flex-wrap gap-2'>
|
|
||||||
{ratingOptions.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option}
|
|
||||||
type='button'
|
|
||||||
onClick={() => setRating(option)}
|
|
||||||
className={`inline-flex h-12 w-12 items-center justify-center rounded-2xl border text-lg font-black transition ${
|
|
||||||
option <= rating
|
|
||||||
? 'border-amber-300 bg-amber-100 text-amber-600'
|
|
||||||
: 'border-slate-200 bg-white text-slate-400 dark:border-slate-700 dark:bg-slate-900'
|
|
||||||
}`}
|
|
||||||
aria-label={`${option} star rating`}
|
|
||||||
>
|
|
||||||
<svg viewBox='0 0 24 24' className='h-5 w-5' aria-hidden='true'>
|
|
||||||
<path fill='currentColor' d={mdiStar} />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className='mb-2 block text-sm font-black text-slate-900 dark:text-white'>
|
|
||||||
Review title <span className='font-normal text-slate-400'>(optional)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
value={title}
|
|
||||||
onChange={(event) => setTitle(event.target.value)}
|
|
||||||
className='h-11 w-full rounded-xl border border-slate-300 px-3 py-2 text-slate-900 outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 dark:border-slate-700 dark:bg-slate-900 dark:text-white'
|
|
||||||
placeholder='What stood out?'
|
|
||||||
maxLength={200}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className='mb-2 block text-sm font-black text-slate-900 dark:text-white'>
|
|
||||||
Your review
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
required
|
|
||||||
value={content}
|
|
||||||
onChange={(event) => setContent(event.target.value)}
|
|
||||||
className='min-h-36 w-full rounded-xl border border-slate-300 px-3 py-2 text-slate-900 outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 dark:border-slate-700 dark:bg-slate-900 dark:text-white'
|
|
||||||
placeholder='Tell us about your experience...'
|
|
||||||
maxLength={5000}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className='mb-2 block text-sm font-black text-slate-900 dark:text-white'>
|
|
||||||
Display name <span className='font-normal text-slate-400'>(optional)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
value={reviewerName}
|
|
||||||
onChange={(event) => setReviewerName(event.target.value)}
|
|
||||||
className='h-11 w-full rounded-xl border border-slate-300 px-3 py-2 text-slate-900 outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 dark:border-slate-700 dark:bg-slate-900 dark:text-white'
|
|
||||||
placeholder='Your name'
|
|
||||||
maxLength={120}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className='rounded-2xl border border-rose-200 bg-rose-50 p-4 text-rose-900'>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<BaseButton
|
|
||||||
type='submit'
|
|
||||||
icon={mdiCheckCircleOutline}
|
|
||||||
label={isSubmitting ? 'Submitting...' : 'Submit review'}
|
|
||||||
color='success'
|
|
||||||
disabled={isSubmitting}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</CardBox>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
HostedReviewPage.getLayout = function getLayout(page: ReactElement) {
|
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
|
||||||
};
|
|
||||||
@ -1,901 +0,0 @@
|
|||||||
import {
|
|
||||||
mdiAccountPlusOutline,
|
|
||||||
mdiCreditCardOutline,
|
|
||||||
mdiEmailOutline,
|
|
||||||
mdiOpenInNew,
|
|
||||||
mdiRefresh,
|
|
||||||
mdiSend,
|
|
||||||
mdiStarCircleOutline,
|
|
||||||
} from '@mdi/js';
|
|
||||||
import axios from 'axios';
|
|
||||||
import Head from 'next/head';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import React, {
|
|
||||||
FormEvent,
|
|
||||||
ReactElement,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import PaymentProviderConnectors, {
|
|
||||||
ConnectorFormValues,
|
|
||||||
} from '../components/ReviewFlow/PaymentProviderConnectors';
|
|
||||||
import BaseButton from '../components/BaseButton';
|
|
||||||
import CardBox from '../components/CardBox';
|
|
||||||
import FormField from '../components/FormField';
|
|
||||||
import SectionMain from '../components/SectionMain';
|
|
||||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
|
||||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
|
||||||
import { getPageTitle } from '../config';
|
|
||||||
import { getBusinessProfileLimitLabel } from '../helpers/businessPlanLabels';
|
|
||||||
|
|
||||||
interface ReviewBusiness {
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
google_review_link?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReviewCustomer {
|
|
||||||
name?: string;
|
|
||||||
email?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReviewTransaction {
|
|
||||||
id: string;
|
|
||||||
payment_provider?: string;
|
|
||||||
amount?: string | number;
|
|
||||||
currency?: string;
|
|
||||||
paid_at?: string;
|
|
||||||
receipt_email?: string;
|
|
||||||
description?: string;
|
|
||||||
business?: ReviewBusiness;
|
|
||||||
customer?: ReviewCustomer;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReviewEvent {
|
|
||||||
id: string;
|
|
||||||
provider?: string;
|
|
||||||
provider_event_type?: string;
|
|
||||||
event_type?: string;
|
|
||||||
processed?: boolean;
|
|
||||||
processing_error?: string;
|
|
||||||
createdAt?: string;
|
|
||||||
business?: ReviewBusiness;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReviewRequest {
|
|
||||||
id: string;
|
|
||||||
status?: string;
|
|
||||||
scheduled_for?: string;
|
|
||||||
email_subject?: string;
|
|
||||||
email_body?: string;
|
|
||||||
review_link?: string;
|
|
||||||
review_platform?: string;
|
|
||||||
review_rating?: number;
|
|
||||||
createdAt?: string;
|
|
||||||
business?: ReviewBusiness;
|
|
||||||
customer?: ReviewCustomer;
|
|
||||||
transaction?: ReviewTransaction;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SummaryResponse {
|
|
||||||
stats: {
|
|
||||||
pending: number;
|
|
||||||
sent: number;
|
|
||||||
clicked: number;
|
|
||||||
reviewed: number;
|
|
||||||
customers: number;
|
|
||||||
transactions: number;
|
|
||||||
paymentEvents: number;
|
|
||||||
};
|
|
||||||
requests: ReviewRequest[];
|
|
||||||
recentTransactions?: ReviewTransaction[];
|
|
||||||
recentEvents?: ReviewEvent[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SubscriptionStatusResponse {
|
|
||||||
subscription: {
|
|
||||||
planId: string;
|
|
||||||
planName: string;
|
|
||||||
effectiveStatus: string;
|
|
||||||
isActive: boolean;
|
|
||||||
trialEndsAt?: string | null;
|
|
||||||
trialDaysLeft?: number | null;
|
|
||||||
};
|
|
||||||
usage: {
|
|
||||||
monthlyReviewRequests: number;
|
|
||||||
businesses: number;
|
|
||||||
teamMembers: number;
|
|
||||||
paymentConnectors: number;
|
|
||||||
};
|
|
||||||
limits: {
|
|
||||||
monthlyReviewRequests: number;
|
|
||||||
businesses: number;
|
|
||||||
teamMembers: number;
|
|
||||||
paymentConnectors: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultForm = {
|
|
||||||
businessName: 'Review Flow Studio',
|
|
||||||
reviewDestination: 'google',
|
|
||||||
reviewLink: 'https://g.page/r/example/review',
|
|
||||||
delayDays: '7',
|
|
||||||
customerName: '',
|
|
||||||
customerEmail: '',
|
|
||||||
phone: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const reviewDestinationOptions = [
|
|
||||||
{ key: 'google', label: 'Google', requiresLink: true },
|
|
||||||
{ key: 'facebook', label: 'Facebook', requiresLink: true },
|
|
||||||
{ key: 'yelp', label: 'Yelp', requiresLink: true },
|
|
||||||
{ key: 'angi', label: 'Angi', requiresLink: true },
|
|
||||||
{ key: 'opentable', label: 'OpenTable', requiresLink: true },
|
|
||||||
{ key: 'trustpilot', label: 'Trustpilot', requiresLink: true },
|
|
||||||
{ key: 'shopify_hosted', label: 'Shopify hosted product review', requiresLink: false },
|
|
||||||
{ key: 'custom', label: 'Custom review page', requiresLink: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
const statusStyles: Record<string, string> = {
|
|
||||||
pending: 'bg-amber-100 text-amber-800 ring-amber-200',
|
|
||||||
sent: 'bg-sky-100 text-sky-800 ring-sky-200',
|
|
||||||
clicked: 'bg-violet-100 text-violet-800 ring-violet-200',
|
|
||||||
reviewed: 'bg-emerald-100 text-emerald-800 ring-emerald-200',
|
|
||||||
failed: 'bg-rose-100 text-rose-800 ring-rose-200',
|
|
||||||
};
|
|
||||||
|
|
||||||
const proFeaturePrompts = [
|
|
||||||
['Advanced automation', 'Create rules for timing, destinations, and follow-up behavior.'],
|
|
||||||
['AI reply assistant', 'Draft thoughtful review replies faster from one workspace.'],
|
|
||||||
['Reputation marketing', 'Unlock widgets, referral campaigns, NPS surveys, and broadcasts.'],
|
|
||||||
];
|
|
||||||
|
|
||||||
function formatDate(value?: string | null) {
|
|
||||||
if (!value) return 'Not scheduled';
|
|
||||||
|
|
||||||
return new Intl.DateTimeFormat('en', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
}).format(new Date(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasAuthToken() {
|
|
||||||
return (
|
|
||||||
typeof window !== 'undefined' && Boolean(localStorage.getItem('token'))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isUnauthorizedError(error: unknown) {
|
|
||||||
return axios.isAxiosError(error) && error.response?.status === 401;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAmount(amount?: string | number, currency?: string) {
|
|
||||||
const numericAmount = Number(amount);
|
|
||||||
|
|
||||||
if (!Number.isFinite(numericAmount)) {
|
|
||||||
return 'Amount pending';
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Intl.NumberFormat('en', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: currency || 'USD',
|
|
||||||
}).format(numericAmount);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ReviewFlowWorkspace() {
|
|
||||||
const [form, setForm] = useState(defaultForm);
|
|
||||||
const [summary, setSummary] = useState<SummaryResponse | null>(null);
|
|
||||||
const [selected, setSelected] = useState<ReviewRequest | null>(null);
|
|
||||||
const [created, setCreated] = useState<ReviewRequest | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [subscriptionStatus, setSubscriptionStatus] =
|
|
||||||
useState<SubscriptionStatusResponse | null>(null);
|
|
||||||
const [isClientReady, setIsClientReady] = useState(false);
|
|
||||||
|
|
||||||
const requests = summary?.requests ?? [];
|
|
||||||
const recentTransactions = summary?.recentTransactions ?? [];
|
|
||||||
const recentEvents = summary?.recentEvents ?? [];
|
|
||||||
const stats = summary?.stats ?? {
|
|
||||||
pending: 0,
|
|
||||||
sent: 0,
|
|
||||||
clicked: 0,
|
|
||||||
reviewed: 0,
|
|
||||||
customers: 0,
|
|
||||||
transactions: 0,
|
|
||||||
paymentEvents: 0,
|
|
||||||
};
|
|
||||||
const selectedReviewDestination =
|
|
||||||
reviewDestinationOptions.find(
|
|
||||||
(destination) => destination.key === form.reviewDestination,
|
|
||||||
) || reviewDestinationOptions[0];
|
|
||||||
const isHostedReviewDestination = !selectedReviewDestination.requiresLink;
|
|
||||||
|
|
||||||
const previewDate = useMemo(() => {
|
|
||||||
if (!isClientReady) return 'after the selected delay';
|
|
||||||
|
|
||||||
const days = Math.max(0, Number(form.delayDays) || 0);
|
|
||||||
return formatDate(
|
|
||||||
new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString(),
|
|
||||||
);
|
|
||||||
}, [form.delayDays, isClientReady]);
|
|
||||||
|
|
||||||
const loadSummary = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await axios.get('/reviewflow/summary');
|
|
||||||
setSummary(response.data);
|
|
||||||
if (!selected && response.data.requests?.length) {
|
|
||||||
setSelected(response.data.requests[0]);
|
|
||||||
}
|
|
||||||
setError('');
|
|
||||||
} catch (requestError) {
|
|
||||||
if (!isUnauthorizedError(requestError)) {
|
|
||||||
console.error('Failed to load Review Flow summary:', requestError);
|
|
||||||
setError(
|
|
||||||
'Could not load your review queue. Please refresh or try again.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadSubscriptionStatus = async () => {
|
|
||||||
try {
|
|
||||||
const response = await axios.get('/subscription/me');
|
|
||||||
setSubscriptionStatus(response.data);
|
|
||||||
} catch (requestError) {
|
|
||||||
if (!isUnauthorizedError(requestError)) {
|
|
||||||
console.error('Failed to load subscription status:', requestError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsClientReady(true);
|
|
||||||
|
|
||||||
if (!hasAuthToken()) {
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadSummary();
|
|
||||||
loadSubscriptionStatus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateForm = (key: keyof typeof defaultForm, value: string) => {
|
|
||||||
setForm((current) => ({ ...current, [key]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setIsSubmitting(true);
|
|
||||||
setError('');
|
|
||||||
setCreated(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios.post('/reviewflow/request', {
|
|
||||||
...form,
|
|
||||||
reviewLink: isHostedReviewDestination ? '' : form.reviewLink,
|
|
||||||
delayDays: Number(form.delayDays),
|
|
||||||
});
|
|
||||||
const newRequest = response.data.request;
|
|
||||||
setCreated(newRequest);
|
|
||||||
setSelected(newRequest);
|
|
||||||
setForm((current) => ({
|
|
||||||
...current,
|
|
||||||
customerName: '',
|
|
||||||
customerEmail: '',
|
|
||||||
phone: '',
|
|
||||||
}));
|
|
||||||
await Promise.all([loadSummary(), loadSubscriptionStatus()]);
|
|
||||||
} catch (requestError) {
|
|
||||||
console.error('Failed to create review request:', requestError);
|
|
||||||
if (axios.isAxiosError(requestError) && requestError.response?.data) {
|
|
||||||
setError(String(requestError.response.data));
|
|
||||||
} else {
|
|
||||||
setError(
|
|
||||||
'Could not create the review request. Please check the fields and try again.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleProviderConnected = async (
|
|
||||||
_business: unknown,
|
|
||||||
connectorForm: ConnectorFormValues,
|
|
||||||
) => {
|
|
||||||
setForm((current) => ({
|
|
||||||
...current,
|
|
||||||
businessName: connectorForm.businessName,
|
|
||||||
reviewDestination: connectorForm.reviewDestination,
|
|
||||||
reviewLink: connectorForm.reviewLink,
|
|
||||||
delayDays: connectorForm.delayDays,
|
|
||||||
}));
|
|
||||||
await Promise.all([loadSummary(), loadSubscriptionStatus()]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentSubscription = subscriptionStatus?.subscription;
|
|
||||||
const currentUsage = subscriptionStatus?.usage;
|
|
||||||
const currentLimits = subscriptionStatus?.limits;
|
|
||||||
const reviewRequestsUsed = currentUsage?.monthlyReviewRequests ?? 0;
|
|
||||||
const reviewRequestsLimit = currentLimits?.monthlyReviewRequests ?? 0;
|
|
||||||
const reviewRequestsRemaining = Math.max(
|
|
||||||
0,
|
|
||||||
reviewRequestsLimit - reviewRequestsUsed,
|
|
||||||
);
|
|
||||||
const reviewRequestsPercent = reviewRequestsLimit
|
|
||||||
? Math.min(100, Math.round((reviewRequestsUsed / reviewRequestsLimit) * 100))
|
|
||||||
: 0;
|
|
||||||
const businessesUsed = currentUsage?.businesses ?? 0;
|
|
||||||
const businessesLimit = currentLimits?.businesses ?? 0;
|
|
||||||
const businessesRemaining = Math.max(0, businessesLimit - businessesUsed);
|
|
||||||
const isStarterPlan = currentSubscription?.planId === 'starter';
|
|
||||||
const isSubscriptionInactive =
|
|
||||||
currentSubscription && !currentSubscription.isActive;
|
|
||||||
const isReviewRequestLimitReached = Boolean(
|
|
||||||
currentSubscription &&
|
|
||||||
reviewRequestsLimit > 0 &&
|
|
||||||
reviewRequestsUsed >= reviewRequestsLimit,
|
|
||||||
);
|
|
||||||
const isReviewRequestBlocked = Boolean(
|
|
||||||
isSubscriptionInactive || isReviewRequestLimitReached,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head>
|
|
||||||
<title>{getPageTitle('Review Flow')}</title>
|
|
||||||
</Head>
|
|
||||||
<SectionMain>
|
|
||||||
<SectionTitleLineWithButton
|
|
||||||
icon={mdiStarCircleOutline}
|
|
||||||
title='Review Flow command center'
|
|
||||||
main
|
|
||||||
>
|
|
||||||
{''}
|
|
||||||
</SectionTitleLineWithButton>
|
|
||||||
|
|
||||||
<div className='mb-6 overflow-hidden rounded-3xl bg-gradient-to-br from-slate-950 via-indigo-950 to-emerald-950 p-6 text-white shadow-2xl'>
|
|
||||||
<div className='grid gap-6 lg:grid-cols-[1.2fr_0.8fr] lg:items-center'>
|
|
||||||
<div>
|
|
||||||
<p className='mb-3 inline-flex rounded-full bg-white/10 px-4 py-1 text-sm font-semibold text-emerald-200 ring-1 ring-white/20'>
|
|
||||||
Clean workflow · trigger → customer → right review destination
|
|
||||||
</p>
|
|
||||||
<h2 className='max-w-3xl text-4xl font-black tracking-tight md:text-5xl'>
|
|
||||||
Keep ecommerce triggers and local review destinations cleanly separated.
|
|
||||||
</h2>
|
|
||||||
<p className='mt-4 max-w-2xl text-base text-slate-200 md:text-lg'>
|
|
||||||
Stripe, Square, PayPal, Shopify, and WooCommerce create customers and transactions from webhooks. Google, Facebook, Yelp, Angi, OpenTable, Trustpilot, and Shopify hosted reviews are treated as review destinations.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className='grid grid-cols-2 gap-3'>
|
|
||||||
{[
|
|
||||||
['Events', stats.paymentEvents],
|
|
||||||
['Payments', stats.transactions],
|
|
||||||
['Pending', stats.pending],
|
|
||||||
['Customers', stats.customers],
|
|
||||||
['Clicked', stats.clicked],
|
|
||||||
['Reviewed', stats.reviewed],
|
|
||||||
].map(([label, value]) => (
|
|
||||||
<div
|
|
||||||
key={label}
|
|
||||||
className='rounded-2xl bg-white/10 p-4 ring-1 ring-white/15 backdrop-blur'
|
|
||||||
>
|
|
||||||
<div className='text-3xl font-black'>{value}</div>
|
|
||||||
<div className='text-sm text-slate-300'>{label}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{currentSubscription && currentUsage && currentLimits && (
|
|
||||||
<div className={`mb-6 rounded-3xl border p-5 shadow-xl ${isSubscriptionInactive ? 'border-rose-200 bg-rose-50 text-rose-950' : 'border-slate-200 bg-white text-slate-900 dark:border-dark-700 dark:bg-dark-900 dark:text-white'}`}>
|
|
||||||
<div className='grid gap-5 lg:grid-cols-[0.9fr_1.1fr] lg:items-center'>
|
|
||||||
<div>
|
|
||||||
<p className='text-sm font-black uppercase tracking-[0.25em] text-emerald-500'>
|
|
||||||
Plan and usage
|
|
||||||
</p>
|
|
||||||
<h3 className='mt-2 text-2xl font-black'>
|
|
||||||
{currentSubscription.planName} · {currentSubscription.effectiveStatus}
|
|
||||||
</h3>
|
|
||||||
<p className='mt-2 text-sm text-slate-500 dark:text-slate-400'>
|
|
||||||
{currentSubscription.trialDaysLeft !== null &&
|
|
||||||
currentSubscription.trialDaysLeft !== undefined
|
|
||||||
? `${currentSubscription.trialDaysLeft} trial days left. `
|
|
||||||
: ''}
|
|
||||||
{reviewRequestsRemaining.toLocaleString()} review requests and {getBusinessProfileLimitLabel(businessesRemaining)} remaining on this plan.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className='grid gap-3 md:grid-cols-[1fr_auto] md:items-center'>
|
|
||||||
<div>
|
|
||||||
<div className='mb-2 flex items-center justify-between text-sm font-bold'>
|
|
||||||
<span>Monthly review requests</span>
|
|
||||||
<span>{reviewRequestsUsed.toLocaleString()} / {reviewRequestsLimit.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
<div className='h-3 overflow-hidden rounded-full bg-slate-100 dark:bg-dark-800'>
|
|
||||||
<div
|
|
||||||
className={reviewRequestsPercent >= 80 ? 'h-full rounded-full bg-amber-500' : 'h-full rounded-full bg-emerald-500'}
|
|
||||||
style={{ width: `${reviewRequestsPercent}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<BaseButton
|
|
||||||
href='/subscription'
|
|
||||||
icon={mdiCreditCardOutline}
|
|
||||||
label={isStarterPlan ? 'Upgrade / manage' : 'Manage plan'}
|
|
||||||
color={isStarterPlan ? 'info' : 'whiteDark'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{created && (
|
|
||||||
<div className='mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-emerald-900'>
|
|
||||||
<strong>Review request queued.</strong> {created.customer?.email} is
|
|
||||||
scheduled for {formatDate(created.scheduled_for)}.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<div className='mb-6 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-rose-900'>
|
|
||||||
<p>{error}</p>
|
|
||||||
{error.includes('Upgrade to Pro') && (
|
|
||||||
<BaseButton
|
|
||||||
href='/subscription'
|
|
||||||
icon={mdiCreditCardOutline}
|
|
||||||
label='Manage subscription'
|
|
||||||
color='danger'
|
|
||||||
className='mt-3'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PaymentProviderConnectors
|
|
||||||
className='mb-6'
|
|
||||||
onConnected={handleProviderConnected}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isStarterPlan && (
|
|
||||||
<CardBox className='mb-6 border-0 bg-gradient-to-br from-indigo-950 to-slate-950 text-white shadow-2xl'>
|
|
||||||
<div className='grid gap-6 lg:grid-cols-[0.8fr_1.2fr] lg:items-center'>
|
|
||||||
<div>
|
|
||||||
<p className='text-sm font-black uppercase tracking-[0.25em] text-emerald-300'>
|
|
||||||
Pro upgrade prompts
|
|
||||||
</p>
|
|
||||||
<h3 className='mt-2 text-3xl font-black'>
|
|
||||||
Unlock advanced reputation growth tools.
|
|
||||||
</h3>
|
|
||||||
<p className='mt-3 text-slate-300'>
|
|
||||||
Starter keeps the core review workflow running. Pro raises limits to 10 business profiles and unlocks the next automation, AI, and marketing modules as they are enabled.
|
|
||||||
</p>
|
|
||||||
<BaseButton
|
|
||||||
href='/subscription'
|
|
||||||
icon={mdiOpenInNew}
|
|
||||||
label='Upgrade to Pro'
|
|
||||||
color='info'
|
|
||||||
className='mt-5'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className='grid gap-3 md:grid-cols-3'>
|
|
||||||
{proFeaturePrompts.map(([title, copy]) => (
|
|
||||||
<div key={title} className='rounded-2xl bg-white/10 p-4 ring-1 ring-white/15'>
|
|
||||||
<p className='font-black'>{title}</p>
|
|
||||||
<p className='mt-2 text-sm leading-6 text-slate-300'>{copy}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className='grid gap-6 xl:grid-cols-[0.95fr_1.05fr]'>
|
|
||||||
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
|
|
||||||
<div className='mb-6 flex items-start justify-between gap-4'>
|
|
||||||
<div>
|
|
||||||
<p className='text-sm font-bold uppercase tracking-[0.25em] text-emerald-500'>
|
|
||||||
Manual fallback
|
|
||||||
</p>
|
|
||||||
<h3 className='text-2xl font-black text-slate-900 dark:text-white'>
|
|
||||||
Queue a review request
|
|
||||||
</h3>
|
|
||||||
<p className='mt-2 text-sm text-slate-500 dark:text-slate-400'>
|
|
||||||
Use this when a payment did not come through a webhook, or
|
|
||||||
when you want to test the review queue manually.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className='rounded-2xl bg-emerald-100 p-3 text-emerald-700'>
|
|
||||||
<BaseButton
|
|
||||||
icon={mdiAccountPlusOutline}
|
|
||||||
color='success'
|
|
||||||
roundedFull
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isReviewRequestBlocked && (
|
|
||||||
<div className='mb-5 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-rose-900 dark:border-rose-900 dark:bg-rose-950 dark:text-rose-50'>
|
|
||||||
<p className='text-sm font-black uppercase tracking-[0.25em]'>
|
|
||||||
{isSubscriptionInactive ? 'Subscription inactive' : 'Monthly request limit reached'}
|
|
||||||
</p>
|
|
||||||
<p className='mt-2 text-sm leading-6'>
|
|
||||||
{isSubscriptionInactive
|
|
||||||
? 'Review requests are paused until this account has an active plan.'
|
|
||||||
: `${currentSubscription?.planName} includes ${reviewRequestsLimit.toLocaleString()} review requests per month, and this account has already used ${reviewRequestsUsed.toLocaleString()}.`}
|
|
||||||
{' '}Existing queued requests stay available.
|
|
||||||
</p>
|
|
||||||
<BaseButton
|
|
||||||
href='/subscription'
|
|
||||||
icon={mdiCreditCardOutline}
|
|
||||||
label={isStarterPlan ? 'Upgrade to Pro' : 'Manage plan'}
|
|
||||||
color='danger'
|
|
||||||
className='mt-3'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<FormField
|
|
||||||
label='Business and review destination'
|
|
||||||
help='Choose the destination first. Shopify hosted reviews generate a Review Flow form automatically.'
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
required
|
|
||||||
value={form.businessName}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateForm('businessName', event.target.value)
|
|
||||||
}
|
|
||||||
placeholder='Business name'
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
value={form.reviewDestination}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateForm('reviewDestination', event.target.value)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{reviewDestinationOptions.map((destination) => (
|
|
||||||
<option key={destination.key} value={destination.key}>
|
|
||||||
{destination.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
|
||||||
<FormField
|
|
||||||
label={isHostedReviewDestination ? 'Hosted review form' : 'External review link'}
|
|
||||||
help={
|
|
||||||
isHostedReviewDestination
|
|
||||||
? 'No external URL needed; the outgoing email points to a hosted /review page.'
|
|
||||||
: 'Use the exact review page where this customer should land.'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isHostedReviewDestination ? (
|
|
||||||
<div className='rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm font-semibold text-emerald-900'>
|
|
||||||
Review Flow will create a secure hosted product-review link for this request.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
required
|
|
||||||
type='url'
|
|
||||||
value={form.reviewLink}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateForm('reviewLink', event.target.value)
|
|
||||||
}
|
|
||||||
placeholder='https://your-review-destination.example/review'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</FormField>
|
|
||||||
<FormField
|
|
||||||
label='Customer'
|
|
||||||
help='Webhook payments fill this automatically when the provider sends a customer email.'
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
value={form.customerName}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateForm('customerName', event.target.value)
|
|
||||||
}
|
|
||||||
placeholder='Customer name'
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
required
|
|
||||||
type='email'
|
|
||||||
value={form.customerEmail}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateForm('customerEmail', event.target.value)
|
|
||||||
}
|
|
||||||
placeholder='customer@example.com'
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField
|
|
||||||
label='Delay and phone'
|
|
||||||
help={`Preview: scheduled for ${previewDate}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
min='0'
|
|
||||||
max='30'
|
|
||||||
type='number'
|
|
||||||
value={form.delayDays}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateForm('delayDays', event.target.value)
|
|
||||||
}
|
|
||||||
placeholder='Delay days'
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={form.phone}
|
|
||||||
onChange={(event) => updateForm('phone', event.target.value)}
|
|
||||||
placeholder='Optional phone'
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<div className='flex flex-wrap gap-3'>
|
|
||||||
<BaseButton
|
|
||||||
type='submit'
|
|
||||||
icon={mdiSend}
|
|
||||||
label={isSubmitting ? 'Queueing...' : 'Queue review request'}
|
|
||||||
color='info'
|
|
||||||
disabled={isSubmitting || isReviewRequestBlocked}
|
|
||||||
/>
|
|
||||||
<BaseButton
|
|
||||||
type='button'
|
|
||||||
icon={mdiRefresh}
|
|
||||||
label='Refresh queue'
|
|
||||||
color='whiteDark'
|
|
||||||
onClick={loadSummary}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</CardBox>
|
|
||||||
|
|
||||||
<div className='grid gap-6 lg:grid-cols-[0.95fr_1.05fr] xl:grid-cols-1 2xl:grid-cols-[0.95fr_1.05fr]'>
|
|
||||||
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
|
|
||||||
<div className='mb-5 flex items-center justify-between'>
|
|
||||||
<div>
|
|
||||||
<p className='text-sm font-bold uppercase tracking-[0.25em] text-indigo-500'>
|
|
||||||
Queue
|
|
||||||
</p>
|
|
||||||
<h3 className='text-2xl font-black text-slate-900 dark:text-white'>
|
|
||||||
Recent requests
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div className='rounded-2xl border border-dashed border-slate-200 p-8 text-center text-slate-500'>
|
|
||||||
Loading review queue...
|
|
||||||
</div>
|
|
||||||
) : requests.length === 0 ? (
|
|
||||||
<div className='rounded-2xl border border-dashed border-slate-200 bg-slate-50 p-8 text-center dark:bg-dark-800'>
|
|
||||||
<div className='mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-indigo-100 text-indigo-700'>
|
|
||||||
<BaseButton
|
|
||||||
icon={mdiEmailOutline}
|
|
||||||
color='info'
|
|
||||||
roundedFull
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className='font-bold text-slate-900 dark:text-white'>
|
|
||||||
No requests yet
|
|
||||||
</p>
|
|
||||||
<p className='mt-1 text-sm text-slate-500'>
|
|
||||||
Create one manually or send a successful provider payment
|
|
||||||
webhook.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className='space-y-3'>
|
|
||||||
{requests.map((request) => {
|
|
||||||
const status = request.status || 'pending';
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={request.id}
|
|
||||||
type='button'
|
|
||||||
onClick={() => setSelected(request)}
|
|
||||||
className={`w-full rounded-2xl border p-4 text-left transition hover:-translate-y-0.5 hover:shadow-lg ${selected?.id === request.id ? 'border-indigo-300 bg-indigo-50 dark:bg-indigo-950/30' : 'border-slate-200 bg-white dark:border-dark-700 dark:bg-dark-900'}`}
|
|
||||||
>
|
|
||||||
<div className='flex items-start justify-between gap-3'>
|
|
||||||
<div>
|
|
||||||
<p className='font-black text-slate-900 dark:text-white'>
|
|
||||||
{request.customer?.name ||
|
|
||||||
request.customer?.email ||
|
|
||||||
'Customer'}
|
|
||||||
</p>
|
|
||||||
<p className='text-sm text-slate-500'>
|
|
||||||
{request.business?.name || 'Business'} ·{' '}
|
|
||||||
{formatDate(request.scheduled_for)}
|
|
||||||
</p>
|
|
||||||
{request.transaction?.payment_provider && (
|
|
||||||
<p className='mt-1 text-xs font-bold uppercase tracking-widest text-emerald-600'>
|
|
||||||
From {request.transaction.payment_provider}{' '}
|
|
||||||
payment
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`rounded-full px-3 py-1 text-xs font-bold ring-1 ${statusStyles[status] || statusStyles.pending}`}
|
|
||||||
>
|
|
||||||
{status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardBox>
|
|
||||||
|
|
||||||
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
|
|
||||||
<p className='text-sm font-bold uppercase tracking-[0.25em] text-fuchsia-500'>
|
|
||||||
Detail
|
|
||||||
</p>
|
|
||||||
<h3 className='mb-5 text-2xl font-black text-slate-900 dark:text-white'>
|
|
||||||
Message preview
|
|
||||||
</h3>
|
|
||||||
{selected ? (
|
|
||||||
<div className='space-y-4'>
|
|
||||||
<div className='rounded-2xl bg-slate-50 p-4 dark:bg-dark-800'>
|
|
||||||
<p className='text-xs font-bold uppercase tracking-widest text-slate-400'>
|
|
||||||
To
|
|
||||||
</p>
|
|
||||||
<p className='font-bold text-slate-900 dark:text-white'>
|
|
||||||
{selected.customer?.email}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className='rounded-2xl bg-slate-50 p-4 dark:bg-dark-800'>
|
|
||||||
<p className='text-xs font-bold uppercase tracking-widest text-slate-400'>
|
|
||||||
Subject
|
|
||||||
</p>
|
|
||||||
<p className='font-bold text-slate-900 dark:text-white'>
|
|
||||||
{selected.email_subject}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className='rounded-2xl border border-slate-200 bg-white p-5 text-sm leading-6 text-slate-700 shadow-inner dark:border-dark-700 dark:bg-dark-900 dark:text-slate-200'>
|
|
||||||
{(selected.email_body || '')
|
|
||||||
.split('\n')
|
|
||||||
.map((line, index) => (
|
|
||||||
<p
|
|
||||||
key={`${selected.id}-${index}-${line || 'space'}`}
|
|
||||||
className={line ? '' : 'h-4'}
|
|
||||||
>
|
|
||||||
{line}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className='rounded-2xl bg-gradient-to-r from-indigo-600 to-emerald-500 p-4 text-white'>
|
|
||||||
<p className='text-xs font-bold uppercase tracking-widest text-white/70'>
|
|
||||||
Review link / hosted form
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href={selected.review_link || '#'}
|
|
||||||
target='_blank'
|
|
||||||
className='break-all font-bold underline underline-offset-4'
|
|
||||||
>
|
|
||||||
{selected.review_link}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className='rounded-2xl border border-dashed border-slate-200 p-8 text-center text-slate-500'>
|
|
||||||
Select a request to preview the outgoing message.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardBox>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='mt-6 grid gap-6 lg:grid-cols-2'>
|
|
||||||
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
|
|
||||||
<div className='mb-5 flex items-center justify-between'>
|
|
||||||
<div>
|
|
||||||
<p className='text-sm font-bold uppercase tracking-[0.25em] text-sky-500'>
|
|
||||||
Webhook intake
|
|
||||||
</p>
|
|
||||||
<h3 className='text-2xl font-black text-slate-900 dark:text-white'>
|
|
||||||
Recent payment events
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{recentEvents.length === 0 ? (
|
|
||||||
<div className='rounded-2xl border border-dashed border-slate-200 p-8 text-center text-slate-500'>
|
|
||||||
No provider webhooks received yet.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className='space-y-3'>
|
|
||||||
{recentEvents.map((event) => (
|
|
||||||
<div
|
|
||||||
key={event.id}
|
|
||||||
className='rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-dark-700 dark:bg-dark-800'
|
|
||||||
>
|
|
||||||
<div className='flex items-start justify-between gap-3'>
|
|
||||||
<div>
|
|
||||||
<p className='font-black text-slate-900 dark:text-white'>
|
|
||||||
{(event.provider || 'provider').toUpperCase()} ·{' '}
|
|
||||||
{event.provider_event_type ||
|
|
||||||
event.event_type ||
|
|
||||||
'unknown event'}
|
|
||||||
</p>
|
|
||||||
<p className='text-sm text-slate-500'>
|
|
||||||
{event.business?.name || 'Business'} ·{' '}
|
|
||||||
{formatDate(event.createdAt)}
|
|
||||||
</p>
|
|
||||||
{event.processing_error && (
|
|
||||||
<p className='mt-1 text-xs text-amber-700'>
|
|
||||||
{event.processing_error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`rounded-full px-3 py-1 text-xs font-bold ${event.processed ? 'bg-emerald-100 text-emerald-800' : 'bg-amber-100 text-amber-800'}`}
|
|
||||||
>
|
|
||||||
{event.processed ? 'processed' : 'pending'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardBox>
|
|
||||||
|
|
||||||
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
|
|
||||||
<div className='mb-5 flex items-center justify-between'>
|
|
||||||
<div>
|
|
||||||
<p className='text-sm font-bold uppercase tracking-[0.25em] text-emerald-500'>
|
|
||||||
Payments
|
|
||||||
</p>
|
|
||||||
<h3 className='text-2xl font-black text-slate-900 dark:text-white'>
|
|
||||||
Recent transactions
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{recentTransactions.length === 0 ? (
|
|
||||||
<div className='rounded-2xl border border-dashed border-slate-200 p-8 text-center text-slate-500'>
|
|
||||||
No transactions created from webhooks yet.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className='space-y-3'>
|
|
||||||
{recentTransactions.map((transaction) => (
|
|
||||||
<div
|
|
||||||
key={transaction.id}
|
|
||||||
className='rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-dark-700 dark:bg-dark-800'
|
|
||||||
>
|
|
||||||
<div className='flex items-start justify-between gap-3'>
|
|
||||||
<div>
|
|
||||||
<p className='font-black text-slate-900 dark:text-white'>
|
|
||||||
{formatAmount(
|
|
||||||
transaction.amount,
|
|
||||||
transaction.currency,
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<p className='text-sm text-slate-500'>
|
|
||||||
{transaction.payment_provider || 'provider'} ·{' '}
|
|
||||||
{transaction.customer?.email ||
|
|
||||||
transaction.receipt_email ||
|
|
||||||
'No email'}{' '}
|
|
||||||
· {formatDate(transaction.paid_at)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className='rounded-full bg-slate-900 px-3 py-1 text-xs font-bold text-white dark:bg-white dark:text-slate-900'>
|
|
||||||
{transaction.currency || 'USD'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardBox>
|
|
||||||
</div>
|
|
||||||
</SectionMain>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ReviewFlowWorkspace.getLayout = function getLayout(page: ReactElement) {
|
|
||||||
return <LayoutAuthenticated portal='customer'>{page}</LayoutAuthenticated>;
|
|
||||||
};
|
|
||||||
@ -264,7 +264,6 @@ const EditRoles = () => {
|
|||||||
EditRoles.getLayout = function getLayout(page: ReactElement) {
|
EditRoles.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'UPDATE_ROLES'}
|
permission={'UPDATE_ROLES'}
|
||||||
|
|
||||||
|
|||||||
@ -261,7 +261,6 @@ const EditRolesPage = () => {
|
|||||||
EditRolesPage.getLayout = function getLayout(page: ReactElement) {
|
EditRolesPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'UPDATE_ROLES'}
|
permission={'UPDATE_ROLES'}
|
||||||
|
|
||||||
|
|||||||
@ -150,7 +150,6 @@ const RolesTablesPage = () => {
|
|||||||
RolesTablesPage.getLayout = function getLayout(page: ReactElement) {
|
RolesTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'READ_ROLES'}
|
permission={'READ_ROLES'}
|
||||||
|
|
||||||
|
|||||||
@ -183,7 +183,6 @@ const RolesNew = () => {
|
|||||||
RolesNew.getLayout = function getLayout(page: ReactElement) {
|
RolesNew.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'CREATE_ROLES'}
|
permission={'CREATE_ROLES'}
|
||||||
|
|
||||||
|
|||||||
@ -148,7 +148,6 @@ const RolesTablesPage = () => {
|
|||||||
RolesTablesPage.getLayout = function getLayout(page: ReactElement) {
|
RolesTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'READ_ROLES'}
|
permission={'READ_ROLES'}
|
||||||
|
|
||||||
|
|||||||
@ -292,7 +292,6 @@ const RolesView = () => {
|
|||||||
RolesView.getLayout = function getLayout(page: ReactElement) {
|
RolesView.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'READ_ROLES'}
|
permission={'READ_ROLES'}
|
||||||
|
|
||||||
|
|||||||
@ -665,7 +665,6 @@ const EditStripe_events = () => {
|
|||||||
EditStripe_events.getLayout = function getLayout(page: ReactElement) {
|
EditStripe_events.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'UPDATE_STRIPE_EVENTS'}
|
permission={'UPDATE_STRIPE_EVENTS'}
|
||||||
|
|
||||||
|
|||||||
@ -662,7 +662,6 @@ const EditStripe_eventsPage = () => {
|
|||||||
EditStripe_eventsPage.getLayout = function getLayout(page: ReactElement) {
|
EditStripe_eventsPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'UPDATE_STRIPE_EVENTS'}
|
permission={'UPDATE_STRIPE_EVENTS'}
|
||||||
|
|
||||||
|
|||||||
@ -154,7 +154,6 @@ const Stripe_eventsTablesPage = () => {
|
|||||||
Stripe_eventsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
Stripe_eventsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'READ_STRIPE_EVENTS'}
|
permission={'READ_STRIPE_EVENTS'}
|
||||||
|
|
||||||
|
|||||||
@ -482,7 +482,6 @@ const Stripe_eventsNew = () => {
|
|||||||
Stripe_eventsNew.getLayout = function getLayout(page: ReactElement) {
|
Stripe_eventsNew.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'CREATE_STRIPE_EVENTS'}
|
permission={'CREATE_STRIPE_EVENTS'}
|
||||||
|
|
||||||
|
|||||||
@ -156,7 +156,6 @@ const Stripe_eventsTablesPage = () => {
|
|||||||
Stripe_eventsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
Stripe_eventsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'READ_STRIPE_EVENTS'}
|
permission={'READ_STRIPE_EVENTS'}
|
||||||
|
|
||||||
|
|||||||
@ -380,7 +380,6 @@ const Stripe_eventsView = () => {
|
|||||||
Stripe_eventsView.getLayout = function getLayout(page: ReactElement) {
|
Stripe_eventsView.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'READ_STRIPE_EVENTS'}
|
permission={'READ_STRIPE_EVENTS'}
|
||||||
|
|
||||||
|
|||||||
@ -1,412 +0,0 @@
|
|||||||
import {
|
|
||||||
mdiArrowUpBoldCircleOutline,
|
|
||||||
mdiCheckCircleOutline,
|
|
||||||
mdiCreditCardOutline,
|
|
||||||
mdiRefresh,
|
|
||||||
} from '@mdi/js'
|
|
||||||
import axios from 'axios'
|
|
||||||
import Head from 'next/head'
|
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
import React, { ReactElement, useEffect, useState } from 'react'
|
|
||||||
import BaseButton from '../components/BaseButton'
|
|
||||||
import CardBox from '../components/CardBox'
|
|
||||||
import SectionMain from '../components/SectionMain'
|
|
||||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
|
|
||||||
import LayoutAuthenticated from '../layouts/Authenticated'
|
|
||||||
import { getPageTitle } from '../config'
|
|
||||||
import { SubscriptionPlan } from '../subscriptionPlans'
|
|
||||||
import { getBusinessProfileNoun, getBusinessProfileUsageLabel } from '../helpers/businessPlanLabels'
|
|
||||||
|
|
||||||
type SubscriptionStatusResponse = {
|
|
||||||
subscription: {
|
|
||||||
planId: string
|
|
||||||
planName: string
|
|
||||||
status: string
|
|
||||||
effectiveStatus: string
|
|
||||||
isActive: boolean
|
|
||||||
trialEndsAt?: string | null
|
|
||||||
trialDaysLeft?: number | null
|
|
||||||
priceMonthly: number
|
|
||||||
currency: string
|
|
||||||
stripeCustomerLinked?: boolean
|
|
||||||
stripeSubscriptionLinked?: boolean
|
|
||||||
currentPeriodEndsAt?: string | null
|
|
||||||
}
|
|
||||||
billing?: {
|
|
||||||
checkoutReady: boolean
|
|
||||||
portalReady: boolean
|
|
||||||
webhookReady: boolean
|
|
||||||
hasStripeCustomer: boolean
|
|
||||||
hasStripeSubscription: boolean
|
|
||||||
missingConfiguration: string[]
|
|
||||||
}
|
|
||||||
usage: {
|
|
||||||
monthlyReviewRequests: number
|
|
||||||
businesses: number
|
|
||||||
teamMembers: number
|
|
||||||
paymentConnectors: number
|
|
||||||
periodStart?: string
|
|
||||||
periodEnd?: string
|
|
||||||
}
|
|
||||||
limits: SubscriptionPlan['limits']
|
|
||||||
plans: SubscriptionPlan[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const usageLabels: Array<{
|
|
||||||
key: keyof SubscriptionStatusResponse['usage']
|
|
||||||
limitKey: keyof SubscriptionPlan['limits']
|
|
||||||
label: string
|
|
||||||
}> = [
|
|
||||||
{ key: 'monthlyReviewRequests', limitKey: 'monthlyReviewRequests', label: 'Review requests this month' },
|
|
||||||
{ key: 'businesses', limitKey: 'businesses', label: 'Business profiles' },
|
|
||||||
{ key: 'teamMembers', limitKey: 'teamMembers', label: 'Team members' },
|
|
||||||
{ key: 'paymentConnectors', limitKey: 'paymentConnectors', label: 'Connected payment providers' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function formatDate(value?: string | null) {
|
|
||||||
if (!value) return 'Not set'
|
|
||||||
|
|
||||||
return new Intl.DateTimeFormat('en', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
}).format(new Date(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatLimit(value: number) {
|
|
||||||
return value.toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRequestErrorMessage(requestError: unknown, fallback: string) {
|
|
||||||
if (axios.isAxiosError(requestError) && requestError.response?.data) {
|
|
||||||
const data = requestError.response.data
|
|
||||||
|
|
||||||
if (typeof data === 'string') {
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof data?.message === 'string') {
|
|
||||||
return data.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SubscriptionPage() {
|
|
||||||
const router = useRouter()
|
|
||||||
const [status, setStatus] = useState<SubscriptionStatusResponse | null>(null)
|
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
|
||||||
const [billingActionPlanId, setBillingActionPlanId] = useState('')
|
|
||||||
const [isOpeningPortal, setIsOpeningPortal] = useState(false)
|
|
||||||
const [message, setMessage] = useState('')
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
|
|
||||||
const loadStatus = async () => {
|
|
||||||
setIsLoading(true)
|
|
||||||
try {
|
|
||||||
const response = await axios.get('/subscription/me')
|
|
||||||
setStatus(response.data)
|
|
||||||
setError('')
|
|
||||||
} catch (requestError) {
|
|
||||||
console.error('Failed to load subscription status:', requestError)
|
|
||||||
setError('Could not load your subscription status. Please refresh and try again.')
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadStatus()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!router.isReady) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (router.query.checkout === 'success') {
|
|
||||||
setMessage('Thanks — Stripe is confirming your subscription. This page will update after the webhook is received.')
|
|
||||||
loadStatus()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (router.query.checkout === 'cancelled') {
|
|
||||||
setMessage('Checkout was cancelled. You can restart checkout whenever you are ready.')
|
|
||||||
}
|
|
||||||
}, [router.isReady, router.query.checkout])
|
|
||||||
|
|
||||||
const startCheckout = async (planId: string) => {
|
|
||||||
setBillingActionPlanId(planId)
|
|
||||||
setError('')
|
|
||||||
setMessage('')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios.post('/subscription/create-checkout-session', { planId })
|
|
||||||
const url = response.data?.url
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
throw new Error('Stripe Checkout did not return a redirect URL.')
|
|
||||||
}
|
|
||||||
|
|
||||||
window.location.href = url
|
|
||||||
} catch (requestError) {
|
|
||||||
console.error('Failed to create Stripe Checkout session:', requestError)
|
|
||||||
setError(getRequestErrorMessage(requestError, 'Could not start Stripe Checkout. Please try again.'))
|
|
||||||
} finally {
|
|
||||||
setBillingActionPlanId('')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openBillingPortal = async () => {
|
|
||||||
setIsOpeningPortal(true)
|
|
||||||
setError('')
|
|
||||||
setMessage('')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios.post('/subscription/create-portal-session')
|
|
||||||
const url = response.data?.url
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
throw new Error('Stripe Customer Portal did not return a redirect URL.')
|
|
||||||
}
|
|
||||||
|
|
||||||
window.location.href = url
|
|
||||||
} catch (requestError) {
|
|
||||||
console.error('Failed to create Stripe Customer Portal session:', requestError)
|
|
||||||
setError(getRequestErrorMessage(requestError, 'Could not open billing management. Please try again.'))
|
|
||||||
} finally {
|
|
||||||
setIsOpeningPortal(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentPlanId = status?.subscription.planId
|
|
||||||
const isPaidStripeSubscription = status?.subscription.status === 'active' && Boolean(status.billing?.hasStripeCustomer)
|
|
||||||
const missingConfiguration = status?.billing?.missingConfiguration || []
|
|
||||||
const overLimitItems = status
|
|
||||||
? usageLabels.filter((item) => {
|
|
||||||
const used = Number(status.usage[item.key]) || 0
|
|
||||||
const limit = Number(status.limits[item.limitKey]) || 0
|
|
||||||
|
|
||||||
return limit > 0 && used > limit
|
|
||||||
})
|
|
||||||
: []
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head>
|
|
||||||
<title>{getPageTitle('Subscription')}</title>
|
|
||||||
</Head>
|
|
||||||
<SectionMain>
|
|
||||||
<SectionTitleLineWithButton
|
|
||||||
icon={mdiCreditCardOutline}
|
|
||||||
title='Subscription and limits'
|
|
||||||
main
|
|
||||||
>
|
|
||||||
<BaseButton
|
|
||||||
icon={mdiRefresh}
|
|
||||||
label='Refresh'
|
|
||||||
color='whiteDark'
|
|
||||||
onClick={loadStatus}
|
|
||||||
/>
|
|
||||||
</SectionTitleLineWithButton>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div className='mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-emerald-900'>
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<div className='mb-6 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-rose-900'>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading && !status ? (
|
|
||||||
<CardBox>Loading subscription details...</CardBox>
|
|
||||||
) : status ? (
|
|
||||||
<>
|
|
||||||
{missingConfiguration.length > 0 && (
|
|
||||||
<CardBox className='mb-6 border-0 bg-amber-50 text-amber-950 shadow-xl ring-1 ring-amber-200 dark:bg-amber-950 dark:text-amber-50 dark:ring-amber-800'>
|
|
||||||
<p className='text-lg font-black'>Stripe setup needed</p>
|
|
||||||
<p className='mt-2 leading-7'>
|
|
||||||
Billing UI is wired, but Checkout will not launch until these backend environment variables are set:
|
|
||||||
{' '}
|
|
||||||
<strong>{missingConfiguration.join(', ')}</strong>.
|
|
||||||
</p>
|
|
||||||
<p className='mt-2 text-sm font-semibold'>
|
|
||||||
Create monthly Stripe Prices for Starter and Pro, paste their Price IDs into the matching variables, add your webhook secret, then reload the backend.
|
|
||||||
</p>
|
|
||||||
</CardBox>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{overLimitItems.length > 0 && (
|
|
||||||
<CardBox className='mb-6 border-0 bg-rose-50 text-rose-950 shadow-xl ring-1 ring-rose-200 dark:bg-rose-950 dark:text-rose-50 dark:ring-rose-800'>
|
|
||||||
<p className='text-lg font-black'>Plan limit attention needed</p>
|
|
||||||
<p className='mt-2 leading-7'>
|
|
||||||
This account is currently over the {status.subscription.planName} limit for{' '}
|
|
||||||
<strong>{overLimitItems.map((item) => item.label.toLowerCase()).join(', ')}</strong>.
|
|
||||||
Existing data stays available, but creating more items in those areas will be blocked until usage is reduced or the account moves to a higher plan.
|
|
||||||
</p>
|
|
||||||
</CardBox>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CardBox className='mb-6 overflow-hidden border-0 bg-gradient-to-br from-slate-950 via-indigo-950 to-emerald-950 text-white shadow-2xl'>
|
|
||||||
<div className='grid gap-6 lg:grid-cols-[1fr_0.8fr] lg:items-center'>
|
|
||||||
<div>
|
|
||||||
<p className='text-sm font-black uppercase tracking-[0.3em] text-emerald-300'>
|
|
||||||
Current plan
|
|
||||||
</p>
|
|
||||||
<h2 className='mt-3 text-4xl font-black tracking-tight md:text-5xl'>
|
|
||||||
{status.subscription.planName}
|
|
||||||
</h2>
|
|
||||||
<p className='mt-3 max-w-2xl text-slate-200'>
|
|
||||||
Status: <strong>{status.subscription.effectiveStatus}</strong>. Trial ends {formatDate(status.subscription.trialEndsAt)}
|
|
||||||
{status.subscription.trialDaysLeft !== null && status.subscription.trialDaysLeft !== undefined
|
|
||||||
? ` (${status.subscription.trialDaysLeft} days left)`
|
|
||||||
: ''}
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
{status.subscription.currentPeriodEndsAt && (
|
|
||||||
<p className='mt-2 text-sm font-semibold text-slate-300'>
|
|
||||||
Current Stripe billing period ends {formatDate(status.subscription.currentPeriodEndsAt)}.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{status.billing?.hasStripeCustomer && (
|
|
||||||
<BaseButton
|
|
||||||
icon={mdiCreditCardOutline}
|
|
||||||
label='Manage billing'
|
|
||||||
color='info'
|
|
||||||
className='mt-6'
|
|
||||||
disabled={isOpeningPortal || Boolean(billingActionPlanId)}
|
|
||||||
onClick={openBillingPortal}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className='rounded-3xl bg-white/10 p-6 ring-1 ring-white/15'>
|
|
||||||
<p className='text-sm font-bold text-slate-300'>Monthly price</p>
|
|
||||||
<p className='mt-2 text-5xl font-black'>${status.subscription.priceMonthly}</p>
|
|
||||||
<p className='mt-1 text-sm text-slate-300'>per month after trial</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
|
|
||||||
<div className='mb-6 grid gap-6 lg:grid-cols-2'>
|
|
||||||
{usageLabels.map((item) => {
|
|
||||||
const used = Number(status.usage[item.key]) || 0
|
|
||||||
const limit = Number(status.limits[item.limitKey]) || 1
|
|
||||||
const percent = Math.min(100, Math.round((used / limit) * 100))
|
|
||||||
const isOverLimit = used > limit
|
|
||||||
const isNearLimit = !isOverLimit && percent >= 80
|
|
||||||
const usageTextClass = isOverLimit
|
|
||||||
? 'font-black text-rose-600'
|
|
||||||
: isNearLimit ? 'font-black text-amber-600' : 'font-black text-emerald-600'
|
|
||||||
const progressClass = isOverLimit
|
|
||||||
? 'h-full rounded-full bg-rose-500'
|
|
||||||
: isNearLimit ? 'h-full rounded-full bg-amber-500' : 'h-full rounded-full bg-emerald-500'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CardBox key={item.key} className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
|
|
||||||
<div className='mb-3 flex items-center justify-between gap-3'>
|
|
||||||
<p className='font-black text-slate-900 dark:text-white'>{item.label}</p>
|
|
||||||
<p className={usageTextClass}>
|
|
||||||
{item.limitKey === 'businesses'
|
|
||||||
? getBusinessProfileUsageLabel(used, limit)
|
|
||||||
: `${formatLimit(used)} / ${formatLimit(limit)}`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className='h-3 overflow-hidden rounded-full bg-slate-100 dark:bg-dark-800'>
|
|
||||||
<div
|
|
||||||
className={progressClass}
|
|
||||||
style={{ width: `${percent}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{isOverLimit && (
|
|
||||||
<p className='mt-2 text-sm font-semibold text-rose-600'>
|
|
||||||
Over this plan limit. Upgrade or reduce usage before adding more.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardBox>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='grid gap-6 lg:grid-cols-2'>
|
|
||||||
{status.plans.map((plan) => {
|
|
||||||
const isCurrent = currentPlanId === plan.id
|
|
||||||
const isPro = plan.id === 'pro'
|
|
||||||
const isBusy = billingActionPlanId === plan.id || isOpeningPortal
|
|
||||||
const buttonLabel = isPaidStripeSubscription
|
|
||||||
? isCurrent ? 'Manage billing' : 'Change in billing portal'
|
|
||||||
: isCurrent ? `Start paid ${plan.name}` : `Checkout for ${plan.name}`
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CardBox
|
|
||||||
key={plan.id}
|
|
||||||
className={`relative overflow-hidden border-0 shadow-2xl ${isPro ? 'ring-2 ring-indigo-600' : 'ring-1 ring-slate-200 dark:ring-dark-700'}`}
|
|
||||||
cardBoxClassName='p-0'
|
|
||||||
>
|
|
||||||
{isPro && (
|
|
||||||
<div className='absolute right-6 top-6 rounded-full bg-indigo-600 px-4 py-1 text-sm font-black text-white'>
|
|
||||||
Pro growth tools
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className='p-8'>
|
|
||||||
<p className='text-sm font-black uppercase tracking-[0.3em] text-slate-400'>Review Flow</p>
|
|
||||||
<h3 className='mt-3 text-3xl font-black text-slate-900 dark:text-white'>{plan.name}</h3>
|
|
||||||
<p className='mt-3 min-h-[56px] leading-7 text-slate-500 dark:text-slate-400'>{plan.tagline}</p>
|
|
||||||
<div className='mt-8 flex items-end gap-2'>
|
|
||||||
<span className='text-5xl font-black tracking-tight text-slate-900 dark:text-white'>${plan.priceMonthly}</span>
|
|
||||||
<span className='pb-2 font-bold text-slate-500'>/month</span>
|
|
||||||
</div>
|
|
||||||
<div className='mt-8 grid gap-3 rounded-3xl bg-slate-50 p-5 dark:bg-dark-800 sm:grid-cols-2'>
|
|
||||||
<div>
|
|
||||||
<p className='text-2xl font-black'>{formatLimit(plan.limits.monthlyReviewRequests)}</p>
|
|
||||||
<p className='text-sm text-slate-500'>requests/month</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className='text-2xl font-black'>{formatLimit(plan.limits.businesses)}</p>
|
|
||||||
<p className='text-sm text-slate-500'>{getBusinessProfileNoun(plan.limits.businesses)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className='text-2xl font-black'>{formatLimit(plan.limits.teamMembers)}</p>
|
|
||||||
<p className='text-sm text-slate-500'>team members</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className='text-2xl font-black'>{formatLimit(plan.limits.paymentConnectors)}</p>
|
|
||||||
<p className='text-sm text-slate-500'>connectors</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<BaseButton
|
|
||||||
icon={isCurrent ? mdiCheckCircleOutline : mdiArrowUpBoldCircleOutline}
|
|
||||||
label={buttonLabel}
|
|
||||||
color={isCurrent ? 'success' : isPro ? 'info' : 'whiteDark'}
|
|
||||||
className='mt-8 w-full'
|
|
||||||
disabled={isBusy}
|
|
||||||
onClick={() => (isPaidStripeSubscription ? openBillingPortal() : startCheckout(plan.id))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={isPro ? 'bg-indigo-950 p-8 text-white' : 'bg-slate-950 p-8 text-white'}>
|
|
||||||
<p className='mb-5 text-sm font-black uppercase tracking-[0.25em] text-emerald-300'>Included</p>
|
|
||||||
<div className='grid gap-3'>
|
|
||||||
{plan.features.map((feature) => (
|
|
||||||
<div key={feature} className='flex items-start gap-3'>
|
|
||||||
<span className='mt-1 text-emerald-300'>✓</span>
|
|
||||||
<span className='font-semibold text-slate-100'>{feature}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</SectionMain>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
SubscriptionPage.getLayout = function getLayout(page: ReactElement) {
|
|
||||||
return <LayoutAuthenticated portal='customer'>{page}</LayoutAuthenticated>
|
|
||||||
}
|
|
||||||
@ -5,7 +5,7 @@ import LayoutGuest from '../layouts/Guest';
|
|||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
|
|
||||||
export default function PrivacyPolicy() {
|
export default function PrivacyPolicy() {
|
||||||
const title = 'Review Flow';
|
const title = 'ReviewFlow';
|
||||||
const [projectUrl, setProjectUrl] = useState('');
|
const [projectUrl, setProjectUrl] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -698,7 +698,6 @@ const EditUsers = () => {
|
|||||||
EditUsers.getLayout = function getLayout(page: ReactElement) {
|
EditUsers.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'UPDATE_USERS'}
|
permission={'UPDATE_USERS'}
|
||||||
|
|
||||||
|
|||||||
@ -695,7 +695,6 @@ const EditUsersPage = () => {
|
|||||||
EditUsersPage.getLayout = function getLayout(page: ReactElement) {
|
EditUsersPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'UPDATE_USERS'}
|
permission={'UPDATE_USERS'}
|
||||||
|
|
||||||
|
|||||||
@ -154,7 +154,6 @@ const UsersTablesPage = () => {
|
|||||||
UsersTablesPage.getLayout = function getLayout(page: ReactElement) {
|
UsersTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'READ_USERS'}
|
permission={'READ_USERS'}
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js
|
|||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import React, { ReactElement } from 'react'
|
import React, { ReactElement } from 'react'
|
||||||
import CardBox from '../../components/CardBox'
|
import CardBox from '../../components/CardBox'
|
||||||
import SubscriptionLimitGate from '../../components/SubscriptionLimitGate'
|
|
||||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||||
import SectionMain from '../../components/SectionMain'
|
import SectionMain from '../../components/SectionMain'
|
||||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||||
@ -181,10 +180,6 @@ const UsersNew = () => {
|
|||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
<SubscriptionLimitGate
|
|
||||||
limitKey='teamMembers'
|
|
||||||
actionLabel='Inviting another team member'
|
|
||||||
/>
|
|
||||||
<CardBox>
|
<CardBox>
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={
|
initialValues={
|
||||||
@ -495,7 +490,6 @@ const UsersNew = () => {
|
|||||||
UsersNew.getLayout = function getLayout(page: ReactElement) {
|
UsersNew.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'CREATE_USERS'}
|
permission={'CREATE_USERS'}
|
||||||
|
|
||||||
|
|||||||
@ -152,7 +152,6 @@ const UsersTablesPage = () => {
|
|||||||
UsersTablesPage.getLayout = function getLayout(page: ReactElement) {
|
UsersTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'READ_USERS'}
|
permission={'READ_USERS'}
|
||||||
|
|
||||||
|
|||||||
@ -407,7 +407,7 @@ const UsersView = () => {
|
|||||||
|
|
||||||
|
|
||||||
<>
|
<>
|
||||||
<p className={'block font-bold mb-2'}>Business profiles owned</p>
|
<p className={'block font-bold mb-2'}>Businesses Owner</p>
|
||||||
<CardBox
|
<CardBox
|
||||||
className='mb-6 border border-gray-300 rounded overflow-hidden'
|
className='mb-6 border border-gray-300 rounded overflow-hidden'
|
||||||
hasTable
|
hasTable
|
||||||
@ -420,53 +420,53 @@ const UsersView = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Business name</th>
|
<th>BusinessName</th>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Google review link</th>
|
<th>GoogleReviewLink</th>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Yelp review link</th>
|
<th>YelpReviewLink</th>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Facebook review link</th>
|
<th>FacebookReviewLink</th>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Review delay days</th>
|
<th>DelayDays</th>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Email subject template</th>
|
<th>EmailSubjectTemplate</th>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Active</th>
|
<th>IsActive</th>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Stripe account reference</th>
|
<th>StripeAccountReference</th>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Stripe connected</th>
|
<th>StripeConnected</th>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Stripe connected at</th>
|
<th>StripeConnectedAt</th>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Default review platform</th>
|
<th>DefaultReviewPlatform</th>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<th>Custom review link</th>
|
<th>CustomReviewLink</th>
|
||||||
|
|
||||||
|
|
||||||
</tr>
|
</tr>
|
||||||
@ -585,7 +585,6 @@ const UsersView = () => {
|
|||||||
UsersView.getLayout = function getLayout(page: ReactElement) {
|
UsersView.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
portal='admin'
|
|
||||||
|
|
||||||
permission={'READ_USERS'}
|
permission={'READ_USERS'}
|
||||||
|
|
||||||
|
|||||||
@ -158,7 +158,7 @@ export const businessesSlice = createSlice({
|
|||||||
|
|
||||||
builder.addCase(deleteItemsByIds.fulfilled, (state) => {
|
builder.addCase(deleteItemsByIds.fulfilled, (state) => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
fulfilledNotify(state, 'Businesses have been deleted');
|
fulfilledNotify(state, 'Businesses has been deleted');
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.addCase(deleteItemsByIds.rejected, (state, action) => {
|
builder.addCase(deleteItemsByIds.rejected, (state, action) => {
|
||||||
@ -173,7 +173,7 @@ export const businessesSlice = createSlice({
|
|||||||
|
|
||||||
builder.addCase(deleteItem.fulfilled, (state) => {
|
builder.addCase(deleteItem.fulfilled, (state) => {
|
||||||
state.loading = false
|
state.loading = false
|
||||||
fulfilledNotify(state, 'Business has been deleted');
|
fulfilledNotify(state, `${'Businesses'.slice(0, -1)} has been deleted`);
|
||||||
})
|
})
|
||||||
|
|
||||||
builder.addCase(deleteItem.rejected, (state, action) => {
|
builder.addCase(deleteItem.rejected, (state, action) => {
|
||||||
@ -192,7 +192,7 @@ export const businessesSlice = createSlice({
|
|||||||
|
|
||||||
builder.addCase(create.fulfilled, (state) => {
|
builder.addCase(create.fulfilled, (state) => {
|
||||||
state.loading = false
|
state.loading = false
|
||||||
fulfilledNotify(state, 'Business has been created');
|
fulfilledNotify(state, `${'Businesses'.slice(0, -1)} has been created`);
|
||||||
})
|
})
|
||||||
|
|
||||||
builder.addCase(update.pending, (state) => {
|
builder.addCase(update.pending, (state) => {
|
||||||
@ -201,7 +201,7 @@ export const businessesSlice = createSlice({
|
|||||||
})
|
})
|
||||||
builder.addCase(update.fulfilled, (state) => {
|
builder.addCase(update.fulfilled, (state) => {
|
||||||
state.loading = false
|
state.loading = false
|
||||||
fulfilledNotify(state, 'Business has been updated');
|
fulfilledNotify(state, `${'Businesses'.slice(0, -1)} has been updated`);
|
||||||
})
|
})
|
||||||
builder.addCase(update.rejected, (state, action) => {
|
builder.addCase(update.rejected, (state, action) => {
|
||||||
state.loading = false
|
state.loading = false
|
||||||
@ -214,7 +214,7 @@ export const businessesSlice = createSlice({
|
|||||||
})
|
})
|
||||||
builder.addCase(uploadCsv.fulfilled, (state) => {
|
builder.addCase(uploadCsv.fulfilled, (state) => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
fulfilledNotify(state, 'Businesses have been uploaded');
|
fulfilledNotify(state, 'Businesses has been uploaded');
|
||||||
})
|
})
|
||||||
builder.addCase(uploadCsv.rejected, (state, action) => {
|
builder.addCase(uploadCsv.rejected, (state, action) => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
|
|||||||
@ -1,81 +0,0 @@
|
|||||||
export type SubscriptionPlan = {
|
|
||||||
id: 'starter' | 'pro';
|
|
||||||
name: string;
|
|
||||||
priceMonthly: number;
|
|
||||||
currency: 'USD';
|
|
||||||
trialDays: number;
|
|
||||||
tagline: string;
|
|
||||||
highlight?: string;
|
|
||||||
ctaLabel: string;
|
|
||||||
limits: {
|
|
||||||
monthlyReviewRequests: number;
|
|
||||||
businesses: number;
|
|
||||||
teamMembers: number;
|
|
||||||
paymentConnectors: number;
|
|
||||||
};
|
|
||||||
features: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const trialDays = 14;
|
|
||||||
|
|
||||||
export const subscriptionPlans: SubscriptionPlan[] = [
|
|
||||||
{
|
|
||||||
id: 'starter',
|
|
||||||
name: 'Starter',
|
|
||||||
priceMonthly: 49,
|
|
||||||
currency: 'USD',
|
|
||||||
trialDays,
|
|
||||||
tagline: 'For small teams that want automated review collection without extra marketing automation.',
|
|
||||||
ctaLabel: 'Start Starter trial',
|
|
||||||
limits: {
|
|
||||||
monthlyReviewRequests: 250,
|
|
||||||
businesses: 1,
|
|
||||||
teamMembers: 2,
|
|
||||||
paymentConnectors: 5,
|
|
||||||
},
|
|
||||||
features: [
|
|
||||||
'Review Flow dashboard',
|
|
||||||
'Manual review request creation',
|
|
||||||
'Hosted public review form',
|
|
||||||
'Customer management',
|
|
||||||
'Business profile management',
|
|
||||||
'Transaction tracking',
|
|
||||||
'Stripe, Square, PayPal, Shopify, and WooCommerce webhook intake',
|
|
||||||
'Review request status tracking',
|
|
||||||
'Email delivery logs',
|
|
||||||
'Basic reporting',
|
|
||||||
'Standard support',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'pro',
|
|
||||||
name: 'Pro',
|
|
||||||
priceMonthly: 99,
|
|
||||||
currency: 'USD',
|
|
||||||
trialDays,
|
|
||||||
tagline: 'For growing businesses that want automation, AI assistance, and reputation marketing tools.',
|
|
||||||
highlight: 'Best value',
|
|
||||||
ctaLabel: 'Start Pro trial',
|
|
||||||
limits: {
|
|
||||||
monthlyReviewRequests: 2500,
|
|
||||||
businesses: 10,
|
|
||||||
teamMembers: 10,
|
|
||||||
paymentConnectors: 5,
|
|
||||||
},
|
|
||||||
features: [
|
|
||||||
'Everything in Starter',
|
|
||||||
'Advanced automation rules',
|
|
||||||
'AI review reply assistant',
|
|
||||||
'Social proof widgets',
|
|
||||||
'Review monitoring workspace',
|
|
||||||
'Referral campaigns',
|
|
||||||
'Repeat booking reminders',
|
|
||||||
'NPS surveys',
|
|
||||||
'Competitor/reputation insights',
|
|
||||||
'Broadcast campaigns',
|
|
||||||
'Advanced reporting',
|
|
||||||
'Branding customization',
|
|
||||||
'Priority support',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
Loading…
x
Reference in New Issue
Block a user