Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dfd47bae8 | ||
|
|
df9c6cb725 | ||
|
|
e4186ae090 |
@ -1,7 +1,5 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const FileDBApi = require('./file');
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const Utils = require('../utils');
|
const Utils = require('../utils');
|
||||||
|
|
||||||
|
|
||||||
@ -382,15 +380,12 @@ module.exports = class Review_requestsDBApi {
|
|||||||
|
|
||||||
offset = currentPage * limit;
|
offset = currentPage * limit;
|
||||||
|
|
||||||
const orderBy = null;
|
|
||||||
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
let include = [
|
let include = [
|
||||||
|
|
||||||
{
|
{
|
||||||
model: db.businesses,
|
model: db.businesses,
|
||||||
as: 'business',
|
as: 'business',
|
||||||
|
required: Boolean(filter.business),
|
||||||
|
|
||||||
where: filter.business ? {
|
where: filter.business ? {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
@ -408,6 +403,7 @@ module.exports = class Review_requestsDBApi {
|
|||||||
{
|
{
|
||||||
model: db.customers,
|
model: db.customers,
|
||||||
as: 'customer',
|
as: 'customer',
|
||||||
|
required: Boolean(filter.customer),
|
||||||
|
|
||||||
where: filter.customer ? {
|
where: filter.customer ? {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
@ -425,6 +421,7 @@ module.exports = class Review_requestsDBApi {
|
|||||||
{
|
{
|
||||||
model: db.transactions,
|
model: db.transactions,
|
||||||
as: 'transaction',
|
as: 'transaction',
|
||||||
|
required: Boolean(filter.transaction),
|
||||||
|
|
||||||
where: filter.transaction ? {
|
where: filter.transaction ? {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
|
|||||||
@ -793,6 +793,13 @@ module.exports = class UsersDBApi {
|
|||||||
firstName: data.firstName,
|
firstName: data.firstName,
|
||||||
authenticationUid: data.authenticationUid,
|
authenticationUid: data.authenticationUid,
|
||||||
password: data.password,
|
password: data.password,
|
||||||
|
subscriptionPlanId: data.subscriptionPlanId || 'starter',
|
||||||
|
subscriptionStatus: data.subscriptionStatus || 'trialing',
|
||||||
|
trialStartedAt: data.trialStartedAt || null,
|
||||||
|
trialEndsAt: data.trialEndsAt || null,
|
||||||
|
subscriptionStartedAt: data.subscriptionStartedAt || null,
|
||||||
|
subscriptionEndsAt: data.subscriptionEndsAt || null,
|
||||||
|
subscriptionCanceledAt: data.subscriptionCanceledAt || null,
|
||||||
|
|
||||||
},
|
},
|
||||||
{ transaction },
|
{ transaction },
|
||||||
|
|||||||
@ -0,0 +1,112 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const businessColumns = {
|
||||||
|
stripe_webhook_token: { type: 'TEXT' },
|
||||||
|
square_account_reference: { type: 'TEXT' },
|
||||||
|
square_connected: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
|
||||||
|
square_connected_at: { type: 'DATE' },
|
||||||
|
square_webhook_token: { type: 'TEXT' },
|
||||||
|
paypal_merchant_reference: { type: 'TEXT' },
|
||||||
|
paypal_connected: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
|
||||||
|
paypal_connected_at: { type: 'DATE' },
|
||||||
|
paypal_webhook_token: { type: 'TEXT' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const customerColumns = {
|
||||||
|
square_customer_reference: { type: 'TEXT' },
|
||||||
|
paypal_customer_reference: { type: 'TEXT' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const transactionColumns = {
|
||||||
|
businessId: { type: 'UUID', references: { model: 'businesses', key: 'id' } },
|
||||||
|
payment_provider: { type: 'TEXT' },
|
||||||
|
square_payment_reference: { type: 'TEXT' },
|
||||||
|
paypal_payment_reference: { type: 'TEXT' },
|
||||||
|
provider_event_reference: { type: 'TEXT' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventColumns = {
|
||||||
|
provider: { type: 'TEXT' },
|
||||||
|
provider_event_type: { type: 'TEXT' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeColumnDefinition(Sequelize, definition) {
|
||||||
|
const normalized = { ...definition };
|
||||||
|
|
||||||
|
if (definition.type === 'TEXT') {
|
||||||
|
normalized.type = Sequelize.DataTypes.TEXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.type === 'BOOLEAN') {
|
||||||
|
normalized.type = Sequelize.DataTypes.BOOLEAN;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.type === 'DATE') {
|
||||||
|
normalized.type = Sequelize.DataTypes.DATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.type === 'UUID') {
|
||||||
|
normalized.type = Sequelize.DataTypes.UUID;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addColumnsIfMissing(queryInterface, Sequelize, transaction, tableName, columns) {
|
||||||
|
const table = await queryInterface.describeTable(tableName);
|
||||||
|
|
||||||
|
for (const [columnName, definition] of Object.entries(columns)) {
|
||||||
|
if (!table[columnName]) {
|
||||||
|
await queryInterface.addColumn(
|
||||||
|
tableName,
|
||||||
|
columnName,
|
||||||
|
normalizeColumnDefinition(Sequelize, definition),
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeColumnsIfPresent(queryInterface, transaction, tableName, columns) {
|
||||||
|
const table = await queryInterface.describeTable(tableName);
|
||||||
|
|
||||||
|
for (const columnName of Object.keys(columns).reverse()) {
|
||||||
|
if (table[columnName]) {
|
||||||
|
await queryInterface.removeColumn(tableName, columnName, { transaction });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'businesses', businessColumns);
|
||||||
|
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'customers', customerColumns);
|
||||||
|
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'transactions', transactionColumns);
|
||||||
|
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'stripe_events', eventColumns);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await removeColumnsIfPresent(queryInterface, transaction, 'stripe_events', eventColumns);
|
||||||
|
await removeColumnsIfPresent(queryInterface, transaction, 'transactions', transactionColumns);
|
||||||
|
await removeColumnsIfPresent(queryInterface, transaction, 'customers', customerColumns);
|
||||||
|
await removeColumnsIfPresent(queryInterface, transaction, 'businesses', businessColumns);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,97 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const businessColumns = {
|
||||||
|
shopify_store_reference: { type: 'TEXT' },
|
||||||
|
shopify_connected: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
|
||||||
|
shopify_connected_at: { type: 'DATE' },
|
||||||
|
shopify_webhook_token: { type: 'TEXT' },
|
||||||
|
woocommerce_store_reference: { type: 'TEXT' },
|
||||||
|
woocommerce_connected: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
|
||||||
|
woocommerce_connected_at: { type: 'DATE' },
|
||||||
|
woocommerce_webhook_token: { type: 'TEXT' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const customerColumns = {
|
||||||
|
shopify_customer_reference: { type: 'TEXT' },
|
||||||
|
woocommerce_customer_reference: { type: 'TEXT' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const transactionColumns = {
|
||||||
|
shopify_order_reference: { type: 'TEXT' },
|
||||||
|
woocommerce_order_reference: { type: 'TEXT' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeColumnDefinition(Sequelize, definition) {
|
||||||
|
const normalized = { ...definition };
|
||||||
|
|
||||||
|
if (definition.type === 'TEXT') {
|
||||||
|
normalized.type = Sequelize.DataTypes.TEXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.type === 'BOOLEAN') {
|
||||||
|
normalized.type = Sequelize.DataTypes.BOOLEAN;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.type === 'DATE') {
|
||||||
|
normalized.type = Sequelize.DataTypes.DATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addColumnsIfMissing(queryInterface, Sequelize, transaction, tableName, columns) {
|
||||||
|
const table = await queryInterface.describeTable(tableName);
|
||||||
|
|
||||||
|
for (const [columnName, definition] of Object.entries(columns)) {
|
||||||
|
if (!table[columnName]) {
|
||||||
|
await queryInterface.addColumn(
|
||||||
|
tableName,
|
||||||
|
columnName,
|
||||||
|
normalizeColumnDefinition(Sequelize, definition),
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeColumnsIfPresent(queryInterface, transaction, tableName, columns) {
|
||||||
|
const table = await queryInterface.describeTable(tableName);
|
||||||
|
|
||||||
|
for (const columnName of Object.keys(columns).reverse()) {
|
||||||
|
if (table[columnName]) {
|
||||||
|
await queryInterface.removeColumn(tableName, columnName, { transaction });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'businesses', businessColumns);
|
||||||
|
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'customers', customerColumns);
|
||||||
|
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'transactions', transactionColumns);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await removeColumnsIfPresent(queryInterface, transaction, 'transactions', transactionColumns);
|
||||||
|
await removeColumnsIfPresent(queryInterface, transaction, 'customers', customerColumns);
|
||||||
|
await removeColumnsIfPresent(queryInterface, transaction, 'businesses', businessColumns);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const businessColumns = {
|
||||||
|
review_destination: { type: 'TEXT' },
|
||||||
|
trustpilot_review_link: { type: 'TEXT' },
|
||||||
|
angi_review_link: { type: 'TEXT' },
|
||||||
|
opentable_review_link: { type: 'TEXT' },
|
||||||
|
shopify_hosted_reviews_enabled: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
const reviewRequestColumns = {
|
||||||
|
review_platform: { type: 'TEXT' },
|
||||||
|
review_rating: { type: 'INTEGER' },
|
||||||
|
review_title: { type: 'TEXT' },
|
||||||
|
review_content: { type: 'TEXT' },
|
||||||
|
reviewer_display_name: { type: 'TEXT' },
|
||||||
|
review_payload_json: { type: 'TEXT' },
|
||||||
|
submitted_at: { type: 'DATE' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeColumnDefinition(Sequelize, definition) {
|
||||||
|
const normalized = { ...definition };
|
||||||
|
|
||||||
|
if (definition.type === 'TEXT') {
|
||||||
|
normalized.type = Sequelize.DataTypes.TEXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.type === 'BOOLEAN') {
|
||||||
|
normalized.type = Sequelize.DataTypes.BOOLEAN;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.type === 'DATE') {
|
||||||
|
normalized.type = Sequelize.DataTypes.DATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.type === 'INTEGER') {
|
||||||
|
normalized.type = Sequelize.DataTypes.INTEGER;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addColumnsIfMissing(queryInterface, Sequelize, transaction, tableName, columns) {
|
||||||
|
const table = await queryInterface.describeTable(tableName);
|
||||||
|
|
||||||
|
for (const [columnName, definition] of Object.entries(columns)) {
|
||||||
|
if (!table[columnName]) {
|
||||||
|
await queryInterface.addColumn(
|
||||||
|
tableName,
|
||||||
|
columnName,
|
||||||
|
normalizeColumnDefinition(Sequelize, definition),
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeColumnsIfPresent(queryInterface, transaction, tableName, columns) {
|
||||||
|
const table = await queryInterface.describeTable(tableName);
|
||||||
|
|
||||||
|
for (const columnName of Object.keys(columns).reverse()) {
|
||||||
|
if (table[columnName]) {
|
||||||
|
await queryInterface.removeColumn(tableName, columnName, { transaction });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'businesses', businessColumns);
|
||||||
|
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'review_requests', reviewRequestColumns);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await removeColumnsIfPresent(queryInterface, transaction, 'review_requests', reviewRequestColumns);
|
||||||
|
await removeColumnsIfPresent(queryInterface, transaction, 'businesses', businessColumns);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,87 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const userColumns = {
|
||||||
|
subscriptionPlanId: { type: 'TEXT', allowNull: false, defaultValue: 'starter' },
|
||||||
|
subscriptionStatus: { type: 'TEXT', allowNull: false, defaultValue: 'trialing' },
|
||||||
|
trialStartedAt: { type: 'DATE' },
|
||||||
|
trialEndsAt: { type: 'DATE' },
|
||||||
|
subscriptionStartedAt: { type: 'DATE' },
|
||||||
|
subscriptionEndsAt: { type: 'DATE' },
|
||||||
|
subscriptionCanceledAt: { type: 'DATE' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeColumnDefinition(Sequelize, definition) {
|
||||||
|
const normalized = { ...definition };
|
||||||
|
|
||||||
|
if (definition.type === 'TEXT') {
|
||||||
|
normalized.type = Sequelize.DataTypes.TEXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.type === 'DATE') {
|
||||||
|
normalized.type = Sequelize.DataTypes.DATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addColumnsIfMissing(queryInterface, Sequelize, transaction, tableName, columns) {
|
||||||
|
const table = await queryInterface.describeTable(tableName);
|
||||||
|
|
||||||
|
for (const [columnName, definition] of Object.entries(columns)) {
|
||||||
|
if (!table[columnName]) {
|
||||||
|
await queryInterface.addColumn(
|
||||||
|
tableName,
|
||||||
|
columnName,
|
||||||
|
normalizeColumnDefinition(Sequelize, definition),
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeColumnsIfPresent(queryInterface, transaction, tableName, columns) {
|
||||||
|
const table = await queryInterface.describeTable(tableName);
|
||||||
|
|
||||||
|
for (const columnName of Object.keys(columns).reverse()) {
|
||||||
|
if (table[columnName]) {
|
||||||
|
await queryInterface.removeColumn(tableName, columnName, { transaction });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'users', userColumns);
|
||||||
|
|
||||||
|
await queryInterface.sequelize.query(
|
||||||
|
`UPDATE "users"
|
||||||
|
SET "subscriptionPlanId" = COALESCE("subscriptionPlanId", 'starter'),
|
||||||
|
"subscriptionStatus" = COALESCE("subscriptionStatus", 'trialing'),
|
||||||
|
"trialStartedAt" = COALESCE("trialStartedAt", NOW()),
|
||||||
|
"trialEndsAt" = COALESCE("trialEndsAt", NOW() + INTERVAL '14 days')
|
||||||
|
WHERE "deletedAt" IS NULL`,
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await removeColumnsIfPresent(queryInterface, transaction, 'users', userColumns);
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -1,9 +1,3 @@
|
|||||||
const config = require('../../config');
|
|
||||||
const providers = config.providers;
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const bcrypt = require('bcrypt');
|
|
||||||
const moment = require('moment');
|
|
||||||
|
|
||||||
module.exports = function(sequelize, DataTypes) {
|
module.exports = function(sequelize, DataTypes) {
|
||||||
const businesses = sequelize.define(
|
const businesses = sequelize.define(
|
||||||
'businesses',
|
'businesses',
|
||||||
@ -95,6 +89,138 @@ stripe_connected_at: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
stripe_webhook_token: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
square_account_reference: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
square_connected: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
square_connected_at: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
square_webhook_token: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
paypal_merchant_reference: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
paypal_connected: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
paypal_connected_at: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
paypal_webhook_token: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
shopify_store_reference: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
shopify_connected: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
shopify_connected_at: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
shopify_webhook_token: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
woocommerce_store_reference: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
woocommerce_connected: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
woocommerce_connected_at: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
woocommerce_webhook_token: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
default_review_platform: {
|
default_review_platform: {
|
||||||
@ -124,6 +250,44 @@ custom_review_link: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
review_destination: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
trustpilot_review_link: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
angi_review_link: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
opentable_review_link: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
shopify_hosted_reviews_enabled: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
importHash: {
|
importHash: {
|
||||||
@ -176,6 +340,14 @@ custom_review_link: {
|
|||||||
constraints: false,
|
constraints: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
db.businesses.hasMany(db.transactions, {
|
||||||
|
as: 'transactions_business',
|
||||||
|
foreignKey: {
|
||||||
|
name: 'businessId',
|
||||||
|
},
|
||||||
|
constraints: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,3 @@
|
|||||||
const config = require('../../config');
|
|
||||||
const providers = config.providers;
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const bcrypt = require('bcrypt');
|
|
||||||
const moment = require('moment');
|
|
||||||
|
|
||||||
module.exports = function(sequelize, DataTypes) {
|
module.exports = function(sequelize, DataTypes) {
|
||||||
const customers = sequelize.define(
|
const customers = sequelize.define(
|
||||||
'customers',
|
'customers',
|
||||||
@ -40,6 +34,35 @@ stripe_customer_reference: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
square_customer_reference: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
paypal_customer_reference: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
shopify_customer_reference: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
woocommerce_customer_reference: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
contact_status: {
|
contact_status: {
|
||||||
|
|||||||
@ -1,9 +1,3 @@
|
|||||||
const config = require('../../config');
|
|
||||||
const providers = config.providers;
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const bcrypt = require('bcrypt');
|
|
||||||
const moment = require('moment');
|
|
||||||
|
|
||||||
module.exports = function(sequelize, DataTypes) {
|
module.exports = function(sequelize, DataTypes) {
|
||||||
const review_requests = sequelize.define(
|
const review_requests = sequelize.define(
|
||||||
'review_requests',
|
'review_requests',
|
||||||
@ -113,6 +107,55 @@ tracking_token: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
review_platform: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
review_rating: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
review_title: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
review_content: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
reviewer_display_name: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
review_payload_json: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
submitted_at: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
importHash: {
|
importHash: {
|
||||||
|
|||||||
@ -1,9 +1,3 @@
|
|||||||
const config = require('../../config');
|
|
||||||
const providers = config.providers;
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const bcrypt = require('bcrypt');
|
|
||||||
const moment = require('moment');
|
|
||||||
|
|
||||||
module.exports = function(sequelize, DataTypes) {
|
module.exports = function(sequelize, DataTypes) {
|
||||||
const stripe_events = sequelize.define(
|
const stripe_events = sequelize.define(
|
||||||
'stripe_events',
|
'stripe_events',
|
||||||
@ -19,6 +13,20 @@ stripe_event_reference: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
provider: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
provider_event_type: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
event_type: {
|
event_type: {
|
||||||
|
|||||||
@ -1,9 +1,3 @@
|
|||||||
const config = require('../../config');
|
|
||||||
const providers = config.providers;
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const bcrypt = require('bcrypt');
|
|
||||||
const moment = require('moment');
|
|
||||||
|
|
||||||
module.exports = function(sequelize, DataTypes) {
|
module.exports = function(sequelize, DataTypes) {
|
||||||
const transactions = sequelize.define(
|
const transactions = sequelize.define(
|
||||||
'transactions',
|
'transactions',
|
||||||
@ -19,6 +13,49 @@ stripe_payment_reference: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
payment_provider: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
square_payment_reference: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
paypal_payment_reference: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
shopify_order_reference: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
woocommerce_order_reference: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
provider_event_reference: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
amount: {
|
amount: {
|
||||||
@ -131,6 +168,14 @@ receipt_email: {
|
|||||||
constraints: false,
|
constraints: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
db.transactions.belongsTo(db.businesses, {
|
||||||
|
as: 'business',
|
||||||
|
foreignKey: {
|
||||||
|
name: 'businessId',
|
||||||
|
},
|
||||||
|
constraints: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -104,6 +104,47 @@ 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,
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
importHash: {
|
importHash: {
|
||||||
type: DataTypes.STRING(255),
|
type: DataTypes.STRING(255),
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
|
|||||||
@ -6,7 +6,6 @@ const passport = require('passport');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const bodyParser = require('body-parser');
|
const bodyParser = require('body-parser');
|
||||||
const db = require('./db/models');
|
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
const swaggerUI = require('swagger-ui-express');
|
const swaggerUI = require('swagger-ui-express');
|
||||||
const swaggerJsDoc = require('swagger-jsdoc');
|
const swaggerJsDoc = require('swagger-jsdoc');
|
||||||
@ -16,6 +15,8 @@ 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 openaiRoutes = require('./routes/openai');
|
const openaiRoutes = require('./routes/openai');
|
||||||
|
|
||||||
@ -35,6 +36,10 @@ const transactionsRoutes = require('./routes/transactions');
|
|||||||
|
|
||||||
const review_requestsRoutes = require('./routes/review_requests');
|
const review_requestsRoutes = require('./routes/review_requests');
|
||||||
|
|
||||||
|
const reviewflowRoutes = require('./routes/reviewflow');
|
||||||
|
const reviewflowWebhooksRoutes = require('./routes/reviewflow-webhooks');
|
||||||
|
const reviewflowPublicRoutes = require('./routes/reviewflow-public');
|
||||||
|
|
||||||
const stripe_eventsRoutes = require('./routes/stripe_events');
|
const stripe_eventsRoutes = require('./routes/stripe_events');
|
||||||
|
|
||||||
const email_delivery_logsRoutes = require('./routes/email_delivery_logs');
|
const email_delivery_logsRoutes = require('./routes/email_delivery_logs');
|
||||||
@ -96,6 +101,8 @@ app.use(bodyParser.json());
|
|||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/file', fileRoutes);
|
app.use('/api/file', fileRoutes);
|
||||||
app.use('/api/pexels', pexelsRoutes);
|
app.use('/api/pexels', pexelsRoutes);
|
||||||
|
app.use('/api/plans', plansRoutes);
|
||||||
|
app.use('/api/subscription', passport.authenticate('jwt', {session: false}), subscriptionRoutes);
|
||||||
app.enable('trust proxy');
|
app.enable('trust proxy');
|
||||||
|
|
||||||
|
|
||||||
@ -113,6 +120,12 @@ app.use('/api/transactions', passport.authenticate('jwt', {session: false}), tra
|
|||||||
|
|
||||||
app.use('/api/review_requests', passport.authenticate('jwt', {session: false}), review_requestsRoutes);
|
app.use('/api/review_requests', passport.authenticate('jwt', {session: false}), review_requestsRoutes);
|
||||||
|
|
||||||
|
app.use('/api/reviewflow', passport.authenticate('jwt', {session: false}), reviewflowRoutes);
|
||||||
|
|
||||||
|
app.use('/api/reviewflow-webhooks', reviewflowWebhooksRoutes);
|
||||||
|
|
||||||
|
app.use('/api/reviewflow-public', reviewflowPublicRoutes);
|
||||||
|
|
||||||
app.use('/api/stripe_events', passport.authenticate('jwt', {session: false}), stripe_eventsRoutes);
|
app.use('/api/stripe_events', passport.authenticate('jwt', {session: false}), stripe_eventsRoutes);
|
||||||
|
|
||||||
app.use('/api/email_delivery_logs', passport.authenticate('jwt', {session: false}), email_delivery_logsRoutes);
|
app.use('/api/email_delivery_logs', passport.authenticate('jwt', {session: false}), email_delivery_logsRoutes);
|
||||||
|
|||||||
12
backend/src/routes/plans.js
Normal file
12
backend/src/routes/plans.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { getSubscriptionPlans } = require('../services/subscriptionPlans');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
res.status(200).send({
|
||||||
|
plans: getSubscriptionPlans(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
24
backend/src/routes/reviewflow-public.js
Normal file
24
backend/src/routes/reviewflow-public.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const ReviewFlowService = require('../services/reviewflow');
|
||||||
|
const wrapAsync = require('../helpers').wrapAsync;
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/reviews/:trackingToken', wrapAsync(async (req, res) => {
|
||||||
|
const review = await ReviewFlowService.getHostedReviewRequest(req.params.trackingToken);
|
||||||
|
|
||||||
|
res.status(200).send({ review });
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/reviews/:trackingToken', wrapAsync(async (req, res) => {
|
||||||
|
const review = await ReviewFlowService.submitHostedReview(
|
||||||
|
req.params.trackingToken,
|
||||||
|
req.body || {},
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).send({ review });
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.use('/', require('../helpers').commonErrorHandler);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
30
backend/src/routes/reviewflow-webhooks.js
Normal file
30
backend/src/routes/reviewflow-webhooks.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const ReviewFlowService = require('../services/reviewflow');
|
||||||
|
const wrapAsync = require('../helpers').wrapAsync;
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.post('/:provider/:businessId/:secretToken', wrapAsync(async (req, res) => {
|
||||||
|
const result = await ReviewFlowService.processPaymentWebhook(
|
||||||
|
req.params.provider,
|
||||||
|
req.params.businessId,
|
||||||
|
req.params.secretToken,
|
||||||
|
req.body,
|
||||||
|
req.headers,
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).send({ received: true, ...result });
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.get('/:provider/:businessId/:secretToken', wrapAsync(async (req, res) => {
|
||||||
|
ReviewFlowService.getProviderConfig(req.params.provider);
|
||||||
|
|
||||||
|
res.status(200).send({
|
||||||
|
ok: true,
|
||||||
|
message: 'ReviewFlow webhook URL is reachable. Configure your payment provider to POST JSON events to this same URL.',
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.use('/', require('../helpers').commonErrorHandler);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
302
backend/src/routes/reviewflow.js
Normal file
302
backend/src/routes/reviewflow.js
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const db = require('../db/models');
|
||||||
|
const ReviewFlowService = require('../services/reviewflow');
|
||||||
|
const SubscriptionService = require('../services/subscription');
|
||||||
|
const wrapAsync = require('../helpers').wrapAsync;
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
function normalizeString(value) {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireField(value, message) {
|
||||||
|
if (!normalizeString(value)) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.code = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateUrl(value, message) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value);
|
||||||
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
const validationError = new Error(message);
|
||||||
|
validationError.code = 400;
|
||||||
|
throw validationError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const REVIEW_LINK_FIELDS = {
|
||||||
|
google: 'google_review_link',
|
||||||
|
yelp: 'yelp_review_link',
|
||||||
|
facebook: 'facebook_review_link',
|
||||||
|
trustpilot: 'trustpilot_review_link',
|
||||||
|
angi: 'angi_review_link',
|
||||||
|
opentable: 'opentable_review_link',
|
||||||
|
custom: 'custom_review_link',
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeReviewDestination(value) {
|
||||||
|
const destination = normalizeString(value).toLowerCase();
|
||||||
|
|
||||||
|
if (ReviewFlowService.REVIEW_CHANNELS[destination]) {
|
||||||
|
return destination;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'google';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReviewLinkField(reviewDestination) {
|
||||||
|
return REVIEW_LINK_FIELDS[reviewDestination] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmailBody(customerName, businessName, reviewLink) {
|
||||||
|
const greetingName = customerName || 'there';
|
||||||
|
return [
|
||||||
|
`Hi ${greetingName},`,
|
||||||
|
'',
|
||||||
|
`Thank you for choosing ${businessName}. We would love to hear about your experience.`,
|
||||||
|
'',
|
||||||
|
`Leave a review: ${reviewLink}`,
|
||||||
|
'',
|
||||||
|
`Thank you,`,
|
||||||
|
businessName,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
router.get('/review-channels', wrapAsync(async (req, res) => {
|
||||||
|
res.status(200).send({ channels: ReviewFlowService.serializeReviewChannels() });
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.get('/connectors', wrapAsync(async (req, res) => {
|
||||||
|
const businesses = await ReviewFlowService.listConnectorBusinesses(req.currentUser, req);
|
||||||
|
|
||||||
|
res.status(200).send({ businesses });
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/connectors', wrapAsync(async (req, res) => {
|
||||||
|
const business = await ReviewFlowService.connectProvider(req.currentUser, req.body || {}, req);
|
||||||
|
|
||||||
|
res.status(200).send({ business });
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/connectors/:businessId/:provider/rotate', wrapAsync(async (req, res) => {
|
||||||
|
const business = await ReviewFlowService.rotateWebhookToken(
|
||||||
|
req.currentUser,
|
||||||
|
req.params.businessId,
|
||||||
|
req.params.provider,
|
||||||
|
req,
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).send({ business });
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.get('/summary', wrapAsync(async (req, res) => {
|
||||||
|
const currentUser = req.currentUser;
|
||||||
|
const limit = Math.min(Number(req.query.limit) || 8, 25);
|
||||||
|
|
||||||
|
const requests = await db.review_requests.findAll({
|
||||||
|
where: { createdById: currentUser.id },
|
||||||
|
include: [
|
||||||
|
{ model: db.businesses, as: 'business' },
|
||||||
|
{ model: db.customers, as: 'customer' },
|
||||||
|
{ model: db.transactions, as: 'transaction' },
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [
|
||||||
|
pending,
|
||||||
|
sent,
|
||||||
|
clicked,
|
||||||
|
reviewed,
|
||||||
|
customers,
|
||||||
|
transactions,
|
||||||
|
paymentEvents,
|
||||||
|
recentTransactions,
|
||||||
|
recentEvents,
|
||||||
|
] = await Promise.all([
|
||||||
|
db.review_requests.count({ where: { createdById: currentUser.id, status: 'pending' } }),
|
||||||
|
db.review_requests.count({ where: { createdById: currentUser.id, status: 'sent' } }),
|
||||||
|
db.review_requests.count({ where: { createdById: currentUser.id, status: 'clicked' } }),
|
||||||
|
db.review_requests.count({ where: { createdById: currentUser.id, status: 'reviewed' } }),
|
||||||
|
db.customers.count({ where: { createdById: currentUser.id } }),
|
||||||
|
db.transactions.count({ where: { createdById: currentUser.id } }),
|
||||||
|
db.stripe_events.count({ where: { createdById: currentUser.id } }),
|
||||||
|
db.transactions.findAll({
|
||||||
|
where: { createdById: currentUser.id },
|
||||||
|
include: [
|
||||||
|
{ model: db.businesses, as: 'business' },
|
||||||
|
{ model: db.customers, as: 'customer' },
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
limit: 6,
|
||||||
|
}),
|
||||||
|
db.stripe_events.findAll({
|
||||||
|
where: { createdById: currentUser.id },
|
||||||
|
include: [
|
||||||
|
{ model: db.businesses, as: 'business' },
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
limit: 6,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.status(200).send({
|
||||||
|
stats: { pending, sent, clicked, reviewed, customers, transactions, paymentEvents },
|
||||||
|
requests,
|
||||||
|
recentTransactions,
|
||||||
|
recentEvents,
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/request', wrapAsync(async (req, res) => {
|
||||||
|
const currentUser = req.currentUser;
|
||||||
|
const body = req.body || {};
|
||||||
|
const businessName = normalizeString(body.businessName);
|
||||||
|
const reviewLink = normalizeString(body.reviewLink);
|
||||||
|
const reviewDestination = normalizeReviewDestination(body.reviewDestination || body.reviewPlatform || 'google');
|
||||||
|
const isHostedReviewDestination = reviewDestination === 'shopify_hosted';
|
||||||
|
const reviewLinkField = getReviewLinkField(reviewDestination);
|
||||||
|
const customerEmail = normalizeString(body.customerEmail).toLowerCase();
|
||||||
|
const customerName = normalizeString(body.customerName);
|
||||||
|
const phone = normalizeString(body.phone);
|
||||||
|
const delayDays = Math.max(0, Math.min(Number(body.delayDays) || 0, 30));
|
||||||
|
|
||||||
|
requireField(businessName, 'Business name is required.');
|
||||||
|
if (!isHostedReviewDestination) {
|
||||||
|
requireField(reviewLink, 'Review link is required.');
|
||||||
|
}
|
||||||
|
requireField(customerEmail, 'Customer email is required.');
|
||||||
|
|
||||||
|
if (!EMAIL_PATTERN.test(customerEmail)) {
|
||||||
|
const error = new Error('Enter a valid customer email address.');
|
||||||
|
error.code = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reviewLink) {
|
||||||
|
validateUrl(reviewLink, 'Enter a valid review destination URL.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await SubscriptionService.assertCanCreateReviewRequests(currentUser, 1);
|
||||||
|
|
||||||
|
const existingBusiness = await db.businesses.findOne({
|
||||||
|
where: { name: businessName, createdById: currentUser.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingBusiness) {
|
||||||
|
await SubscriptionService.assertCanCreateBusinesses(currentUser, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduledFor = new Date(Date.now() + delayDays * 24 * 60 * 60 * 1000);
|
||||||
|
const trackingToken = crypto.randomBytes(18).toString('hex');
|
||||||
|
const effectiveReviewLink = isHostedReviewDestination
|
||||||
|
? ReviewFlowService.getHostedReviewUrl(req, trackingToken)
|
||||||
|
: reviewLink;
|
||||||
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const businessDefaults = {
|
||||||
|
name: businessName,
|
||||||
|
review_destination: reviewDestination,
|
||||||
|
shopify_hosted_reviews_enabled: isHostedReviewDestination,
|
||||||
|
delay_days: delayDays,
|
||||||
|
email_subject_template: `How was your experience with ${businessName}?`,
|
||||||
|
email_body_template: buildEmailBody('{customerName}', businessName, '{reviewLink}'),
|
||||||
|
is_active: true,
|
||||||
|
createdById: currentUser.id,
|
||||||
|
updatedById: currentUser.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (reviewLink && reviewLinkField) {
|
||||||
|
businessDefaults[reviewLinkField] = reviewLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [business] = await db.businesses.findOrCreate({
|
||||||
|
where: { name: businessName, createdById: currentUser.id },
|
||||||
|
defaults: businessDefaults,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
const businessUpdates = {
|
||||||
|
review_destination: reviewDestination,
|
||||||
|
shopify_hosted_reviews_enabled: business.shopify_hosted_reviews_enabled || isHostedReviewDestination,
|
||||||
|
delay_days: delayDays,
|
||||||
|
is_active: true,
|
||||||
|
updatedById: currentUser.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (reviewLink && reviewLinkField) {
|
||||||
|
businessUpdates[reviewLinkField] = reviewLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
await business.update(businessUpdates, { transaction });
|
||||||
|
|
||||||
|
const [customer] = await db.customers.findOrCreate({
|
||||||
|
where: { email: customerEmail, createdById: currentUser.id },
|
||||||
|
defaults: {
|
||||||
|
email: customerEmail,
|
||||||
|
name: customerName || null,
|
||||||
|
phone: phone || null,
|
||||||
|
contact_status: 'active',
|
||||||
|
businessId: business.id,
|
||||||
|
createdById: currentUser.id,
|
||||||
|
updatedById: currentUser.id,
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
await customer.update({
|
||||||
|
name: customerName || customer.name,
|
||||||
|
phone: phone || customer.phone,
|
||||||
|
contact_status: customer.contact_status || 'active',
|
||||||
|
businessId: business.id,
|
||||||
|
updatedById: currentUser.id,
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
const emailSubject = `How was your experience with ${businessName}?`;
|
||||||
|
const reviewRequest = await db.review_requests.create({
|
||||||
|
status: 'pending',
|
||||||
|
scheduled_for: scheduledFor,
|
||||||
|
email_subject: emailSubject,
|
||||||
|
email_body: buildEmailBody(customerName, businessName, effectiveReviewLink),
|
||||||
|
review_link: effectiveReviewLink,
|
||||||
|
tracking_token: trackingToken,
|
||||||
|
review_platform: reviewDestination,
|
||||||
|
businessId: business.id,
|
||||||
|
customerId: customer.id,
|
||||||
|
createdById: currentUser.id,
|
||||||
|
updatedById: currentUser.id,
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
|
||||||
|
const createdRequest = await db.review_requests.findByPk(reviewRequest.id, {
|
||||||
|
include: [
|
||||||
|
{ model: db.businesses, as: 'business' },
|
||||||
|
{ model: db.customers, as: 'customer' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).send({ request: createdRequest });
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.use('/', require('../helpers').commonErrorHandler);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
21
backend/src/routes/subscription.js
Normal file
21
backend/src/routes/subscription.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const SubscriptionService = require('../services/subscription');
|
||||||
|
const wrapAsync = require('../helpers').wrapAsync;
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
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.use('/', require('../helpers').commonErrorHandler);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@ -1,4 +1,5 @@
|
|||||||
const UsersDBApi = require('../db/api/users');
|
const UsersDBApi = require('../db/api/users');
|
||||||
|
const db = require('../db/models');
|
||||||
const ValidationError = require('./notifications/errors/validation');
|
const ValidationError = require('./notifications/errors/validation');
|
||||||
const ForbiddenError = require('./notifications/errors/forbidden');
|
const ForbiddenError = require('./notifications/errors/forbidden');
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
@ -8,6 +9,7 @@ const PasswordResetEmail = require('./email/list/passwordReset');
|
|||||||
const EmailSender = require('./email');
|
const EmailSender = require('./email');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const helpers = require('../helpers');
|
const helpers = require('../helpers');
|
||||||
|
const SubscriptionService = require('./subscription');
|
||||||
|
|
||||||
class Auth {
|
class Auth {
|
||||||
static async signup(email, password, options = {}, host) {
|
static async signup(email, password, options = {}, host) {
|
||||||
@ -54,11 +56,16 @@ class Auth {
|
|||||||
return helpers.jwtSign(data);
|
return helpers.jwtSign(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const subscriptionPayload = SubscriptionService.getSignupSubscriptionPayload(
|
||||||
|
options?.body?.planId || options?.body?.plan,
|
||||||
|
);
|
||||||
|
|
||||||
const newUser = await UsersDBApi.createFromAuth(
|
const newUser = await UsersDBApi.createFromAuth(
|
||||||
{
|
{
|
||||||
firstName: email.split('@')[0],
|
firstName: email.split('@')[0],
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
email: email,
|
email: email,
|
||||||
|
...subscriptionPayload,
|
||||||
|
|
||||||
},
|
},
|
||||||
options,
|
options,
|
||||||
|
|||||||
@ -6,6 +6,7 @@ const csv = require('csv-parser');
|
|||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const stream = require('stream');
|
const stream = require('stream');
|
||||||
|
const SubscriptionService = require('./subscription');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -15,6 +16,8 @@ module.exports = class BusinessesService {
|
|||||||
static async create(data, currentUser) {
|
static async create(data, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
|
await SubscriptionService.assertCanCreateBusinesses(currentUser, 1, { transaction });
|
||||||
|
|
||||||
await BusinessesDBApi.create(
|
await BusinessesDBApi.create(
|
||||||
data,
|
data,
|
||||||
{
|
{
|
||||||
@ -51,6 +54,8 @@ module.exports = class BusinessesService {
|
|||||||
.on('error', (error) => reject(error));
|
.on('error', (error) => reject(error));
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await SubscriptionService.assertCanCreateBusinesses(req.currentUser, results.length, { transaction });
|
||||||
|
|
||||||
await BusinessesDBApi.bulkImport(results, {
|
await BusinessesDBApi.bulkImport(results, {
|
||||||
transaction,
|
transaction,
|
||||||
ignoreDuplicates: true,
|
ignoreDuplicates: true,
|
||||||
|
|||||||
@ -6,6 +6,7 @@ const csv = require('csv-parser');
|
|||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const stream = require('stream');
|
const stream = require('stream');
|
||||||
|
const SubscriptionService = require('./subscription');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -15,6 +16,8 @@ module.exports = class Review_requestsService {
|
|||||||
static async create(data, currentUser) {
|
static async create(data, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
|
await SubscriptionService.assertCanCreateReviewRequests(currentUser, 1, { transaction });
|
||||||
|
|
||||||
await Review_requestsDBApi.create(
|
await Review_requestsDBApi.create(
|
||||||
data,
|
data,
|
||||||
{
|
{
|
||||||
@ -51,6 +54,8 @@ module.exports = class Review_requestsService {
|
|||||||
.on('error', (error) => reject(error));
|
.on('error', (error) => reject(error));
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await SubscriptionService.assertCanCreateReviewRequests(req.currentUser, results.length, { transaction });
|
||||||
|
|
||||||
await Review_requestsDBApi.bulkImport(results, {
|
await Review_requestsDBApi.bulkImport(results, {
|
||||||
transaction,
|
transaction,
|
||||||
ignoreDuplicates: true,
|
ignoreDuplicates: true,
|
||||||
|
|||||||
1209
backend/src/services/reviewflow.js
Normal file
1209
backend/src/services/reviewflow.js
Normal file
File diff suppressed because it is too large
Load Diff
325
backend/src/services/subscription.js
Normal file
325
backend/src/services/subscription.js
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
const db = require('../db/models');
|
||||||
|
const {
|
||||||
|
TRIAL_DAYS,
|
||||||
|
getSubscriptionPlanById,
|
||||||
|
getSubscriptionPlans,
|
||||||
|
} = require('./subscriptionPlans');
|
||||||
|
|
||||||
|
const DEFAULT_PLAN_ID = 'starter';
|
||||||
|
const DEFAULT_STATUS = 'trialing';
|
||||||
|
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const PAYMENT_CONNECTOR_FIELDS = [
|
||||||
|
'stripe_connected',
|
||||||
|
'square_connected',
|
||||||
|
'paypal_connected',
|
||||||
|
'shopify_connected',
|
||||||
|
'woocommerce_connected',
|
||||||
|
];
|
||||||
|
|
||||||
|
function httpError(message, code = 403) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.code = code;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlanId(planId) {
|
||||||
|
const normalized = typeof planId === 'string' ? planId.trim().toLowerCase() : '';
|
||||||
|
|
||||||
|
return getSubscriptionPlanById(normalized) ? normalized : DEFAULT_PLAN_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlan(planId) {
|
||||||
|
return getSubscriptionPlanById(normalizePlanId(planId)) || getSubscriptionPlanById(DEFAULT_PLAN_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(date, days) {
|
||||||
|
return new Date(date.getTime() + days * DAY_IN_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTrialWindow(referenceDate = new Date()) {
|
||||||
|
const trialStartedAt = new Date(referenceDate);
|
||||||
|
|
||||||
|
return {
|
||||||
|
trialStartedAt,
|
||||||
|
trialEndsAt: addDays(trialStartedAt, TRIAL_DAYS),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentMonthRange(referenceDate = new Date()) {
|
||||||
|
const periodStart = new Date(Date.UTC(
|
||||||
|
referenceDate.getUTCFullYear(),
|
||||||
|
referenceDate.getUTCMonth(),
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
));
|
||||||
|
const periodEnd = new Date(Date.UTC(
|
||||||
|
referenceDate.getUTCFullYear(),
|
||||||
|
referenceDate.getUTCMonth() + 1,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
));
|
||||||
|
|
||||||
|
return { periodStart, periodEnd };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateOrNull(value) {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEffectiveSubscription(user, referenceDate = new Date()) {
|
||||||
|
const plan = getPlan(user?.subscriptionPlanId);
|
||||||
|
const status = user?.subscriptionStatus || DEFAULT_STATUS;
|
||||||
|
const trialStartedAt = toDateOrNull(user?.trialStartedAt);
|
||||||
|
const trialEndsAt = toDateOrNull(user?.trialEndsAt);
|
||||||
|
const isTrialActive = status === 'trialing' && (!trialEndsAt || trialEndsAt.getTime() >= referenceDate.getTime());
|
||||||
|
const isActive = status === 'active' || isTrialActive;
|
||||||
|
const effectiveStatus = status === 'trialing' && !isTrialActive ? 'expired' : status;
|
||||||
|
const trialDaysLeft = trialEndsAt
|
||||||
|
? Math.max(0, Math.ceil((trialEndsAt.getTime() - referenceDate.getTime()) / DAY_IN_MS))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
plan,
|
||||||
|
planId: plan.id,
|
||||||
|
status,
|
||||||
|
effectiveStatus,
|
||||||
|
isActive,
|
||||||
|
trialStartedAt,
|
||||||
|
trialEndsAt,
|
||||||
|
trialDaysLeft,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLimitMessage(plan, usageCount, limit, unit, resetDate) {
|
||||||
|
return `${plan.name} includes ${limit.toLocaleString()} ${unit}. You have already used ${usageCount.toLocaleString()}. Upgrade to Pro or wait until ${resetDate.toISOString().slice(0, 10)} for the monthly limit to reset.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = 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 businesses = await db.businesses.findAll({
|
||||||
|
where: { createdById: userId },
|
||||||
|
attributes: ['id', ...PAYMENT_CONNECTOR_FIELDS],
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
const monthlyReviewRequests = await db.review_requests.count({
|
||||||
|
where: {
|
||||||
|
createdById: userId,
|
||||||
|
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: 1,
|
||||||
|
paymentConnectors,
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getStatus(currentUserOrId, options = {}) {
|
||||||
|
const user = await getUserRecord(currentUserOrId, options);
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
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 existingSubscription = getEffectiveSubscription(user, now);
|
||||||
|
const needsNewTrial = existingSubscription.effectiveStatus === 'expired' || !user.trialStartedAt || !user.trialEndsAt;
|
||||||
|
const trialWindow = needsNewTrial ? buildTrialWindow(now) : {
|
||||||
|
trialStartedAt: user.trialStartedAt,
|
||||||
|
trialEndsAt: user.trialEndsAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
await user.update({
|
||||||
|
subscriptionPlanId: normalizePlanId(planId),
|
||||||
|
subscriptionStatus: user.subscriptionStatus === 'active' ? 'active' : DEFAULT_STATUS,
|
||||||
|
trialStartedAt: trialWindow.trialStartedAt,
|
||||||
|
trialEndsAt: trialWindow.trialEndsAt,
|
||||||
|
updatedById: currentUser.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.getStatus(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async canCreateReviewRequests(currentUserOrId, quantity = 1, options = {}) {
|
||||||
|
const user = await getUserRecord(currentUserOrId, options);
|
||||||
|
const subscription = getEffectiveSubscription(user);
|
||||||
|
|
||||||
|
if (!subscription.isActive) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
code: 403,
|
||||||
|
message: 'Your Review Flow trial has ended. Choose a plan to keep creating review requests.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage = await this.getUsageForUserId(user.id, options);
|
||||||
|
const limit = subscription.plan.limits.monthlyReviewRequests;
|
||||||
|
|
||||||
|
if (usage.monthlyReviewRequests + quantity > limit) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
code: 403,
|
||||||
|
message: getLimitMessage(
|
||||||
|
subscription.plan,
|
||||||
|
usage.monthlyReviewRequests,
|
||||||
|
limit,
|
||||||
|
'review requests per month',
|
||||||
|
usage.periodEnd,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allowed: true, usage, subscription };
|
||||||
|
}
|
||||||
|
|
||||||
|
static async assertCanCreateReviewRequests(currentUserOrId, quantity = 1, options = {}) {
|
||||||
|
const result = await this.canCreateReviewRequests(currentUserOrId, quantity, options);
|
||||||
|
|
||||||
|
if (!result.allowed) {
|
||||||
|
throw httpError(result.message, result.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async canCreateBusinesses(currentUserOrId, quantity = 1, options = {}) {
|
||||||
|
const user = await getUserRecord(currentUserOrId, options);
|
||||||
|
const subscription = getEffectiveSubscription(user);
|
||||||
|
|
||||||
|
if (!subscription.isActive) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
code: 403,
|
||||||
|
message: 'Your Review Flow trial has ended. Choose a plan to keep adding businesses.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage = await this.getUsageForUserId(user.id, options);
|
||||||
|
const limit = subscription.plan.limits.businesses;
|
||||||
|
|
||||||
|
if (usage.businesses + quantity > limit) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
code: 403,
|
||||||
|
message: getLimitMessage(subscription.plan, usage.businesses, limit, 'businesses/locations', usage.periodEnd),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 assertFeatureAccess(currentUserOrId, featureKey, options = {}) {
|
||||||
|
const user = await getUserRecord(currentUserOrId, options);
|
||||||
|
const subscription = getEffectiveSubscription(user);
|
||||||
|
|
||||||
|
if (!subscription.isActive) {
|
||||||
|
throw httpError('Your Review Flow trial has ended. Choose a plan to keep using this feature.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subscription.plan.includedFeatureKeys.includes(featureKey)) {
|
||||||
|
throw httpError(`${subscription.plan.name} does not include this feature. Upgrade to Pro to unlock it.`, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
113
backend/src/services/subscriptionPlans.js
Normal file
113
backend/src/services/subscriptionPlans.js
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
const TRIAL_DAYS = 14;
|
||||||
|
|
||||||
|
const subscriptionPlans = [
|
||||||
|
{
|
||||||
|
id: 'starter',
|
||||||
|
name: 'Starter',
|
||||||
|
priceMonthly: 49,
|
||||||
|
currency: 'USD',
|
||||||
|
trialDays: TRIAL_DAYS,
|
||||||
|
tagline: 'For small teams that want automated review collection without extra marketing automation.',
|
||||||
|
limits: {
|
||||||
|
monthlyReviewRequests: 250,
|
||||||
|
businesses: 1,
|
||||||
|
teamMembers: 2,
|
||||||
|
paymentConnectors: 5,
|
||||||
|
},
|
||||||
|
features: [
|
||||||
|
'Review Flow dashboard',
|
||||||
|
'Manual review request creation',
|
||||||
|
'Hosted public review form',
|
||||||
|
'Customer management',
|
||||||
|
'Business/location management',
|
||||||
|
'Transaction tracking',
|
||||||
|
'Stripe, Square, PayPal, Shopify, and WooCommerce webhook intake',
|
||||||
|
'Review request status tracking',
|
||||||
|
'Email delivery logs',
|
||||||
|
'Basic reporting',
|
||||||
|
'Standard support',
|
||||||
|
],
|
||||||
|
includedFeatureKeys: [
|
||||||
|
'reviewflow_dashboard',
|
||||||
|
'manual_review_requests',
|
||||||
|
'hosted_review_form',
|
||||||
|
'customer_management',
|
||||||
|
'business_management',
|
||||||
|
'transaction_tracking',
|
||||||
|
'payment_webhooks',
|
||||||
|
'review_status_tracking',
|
||||||
|
'email_delivery_logs',
|
||||||
|
'basic_reporting',
|
||||||
|
'standard_support',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pro',
|
||||||
|
name: 'Pro',
|
||||||
|
priceMonthly: 99,
|
||||||
|
currency: 'USD',
|
||||||
|
trialDays: TRIAL_DAYS,
|
||||||
|
tagline: 'For growing businesses that want automation, AI assistance, and reputation marketing tools.',
|
||||||
|
limits: {
|
||||||
|
monthlyReviewRequests: 2500,
|
||||||
|
businesses: 10,
|
||||||
|
teamMembers: 10,
|
||||||
|
paymentConnectors: 5,
|
||||||
|
},
|
||||||
|
features: [
|
||||||
|
'Everything in Starter',
|
||||||
|
'Advanced automation rules',
|
||||||
|
'AI review reply assistant',
|
||||||
|
'Social proof widgets',
|
||||||
|
'Review monitoring workspace',
|
||||||
|
'Referral campaigns',
|
||||||
|
'Repeat booking reminders',
|
||||||
|
'NPS surveys',
|
||||||
|
'Competitor/reputation insights',
|
||||||
|
'Broadcast campaigns',
|
||||||
|
'Advanced reporting',
|
||||||
|
'Branding customization',
|
||||||
|
'Priority support',
|
||||||
|
],
|
||||||
|
includedFeatureKeys: [
|
||||||
|
'reviewflow_dashboard',
|
||||||
|
'manual_review_requests',
|
||||||
|
'hosted_review_form',
|
||||||
|
'customer_management',
|
||||||
|
'business_management',
|
||||||
|
'transaction_tracking',
|
||||||
|
'payment_webhooks',
|
||||||
|
'review_status_tracking',
|
||||||
|
'email_delivery_logs',
|
||||||
|
'basic_reporting',
|
||||||
|
'standard_support',
|
||||||
|
'advanced_automation',
|
||||||
|
'ai_review_replies',
|
||||||
|
'social_proof_widgets',
|
||||||
|
'review_monitoring',
|
||||||
|
'referral_campaigns',
|
||||||
|
'repeat_booking_reminders',
|
||||||
|
'nps_surveys',
|
||||||
|
'competitor_insights',
|
||||||
|
'broadcast_campaigns',
|
||||||
|
'advanced_reporting',
|
||||||
|
'branding_customization',
|
||||||
|
'priority_support',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const getSubscriptionPlans = () => subscriptionPlans.map((plan) => ({
|
||||||
|
...plan,
|
||||||
|
limits: { ...plan.limits },
|
||||||
|
features: [...plan.features],
|
||||||
|
includedFeatureKeys: [...plan.includedFeatureKeys],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const getSubscriptionPlanById = (planId) => getSubscriptionPlans().find((plan) => plan.id === planId);
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
TRIAL_DAYS,
|
||||||
|
getSubscriptionPlanById,
|
||||||
|
getSubscriptionPlans,
|
||||||
|
};
|
||||||
@ -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">ReviewFlow</b>
|
<b className="font-black">Review Flow</b>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -152,7 +152,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
type: 'dateTime',
|
type: 'dateTime',
|
||||||
valueGetter: (params: GridValueGetterParams) =>
|
valueGetter: (params: GridValueGetterParams) =>
|
||||||
new Date(params.row.last_transaction_at),
|
params.row.last_transaction_at ? new Date(params.row.last_transaction_at) : null,
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import React, {useEffect, useRef} from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState } from 'react'
|
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
|
|||||||
1308
frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx
Normal file
1308
frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -63,9 +63,39 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
field: 'provider',
|
||||||
|
headerName: 'Provider',
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 120,
|
||||||
|
filterable: false,
|
||||||
|
headerClassName: 'datagrid--header',
|
||||||
|
cellClassName: 'datagrid--cell',
|
||||||
|
|
||||||
|
|
||||||
|
editable: hasUpdatePermission,
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
field: 'provider_event_type',
|
||||||
|
headerName: 'ProviderEventType',
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 160,
|
||||||
|
filterable: false,
|
||||||
|
headerClassName: 'datagrid--header',
|
||||||
|
cellClassName: 'datagrid--cell',
|
||||||
|
|
||||||
|
|
||||||
|
editable: hasUpdatePermission,
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
field: 'stripe_event_reference',
|
field: 'stripe_event_reference',
|
||||||
headerName: 'StripeEventReference',
|
headerName: 'EventReference',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
|
|||||||
@ -63,9 +63,24 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
field: 'payment_provider',
|
||||||
|
headerName: 'Provider',
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 120,
|
||||||
|
filterable: false,
|
||||||
|
headerClassName: 'datagrid--header',
|
||||||
|
cellClassName: 'datagrid--cell',
|
||||||
|
|
||||||
|
|
||||||
|
editable: hasUpdatePermission,
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
field: 'stripe_payment_reference',
|
field: 'stripe_payment_reference',
|
||||||
headerName: 'StripePaymentReference',
|
headerName: 'PaymentReference',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import React, { ReactNode, useEffect } from 'react'
|
import React, { ReactNode, useEffect, useState } from 'react'
|
||||||
import { useState } from 'react'
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import * as icon from '@mdi/js';
|
import * as icon from '@mdi/js';
|
||||||
import { MenuAsideItem } from './interfaces'
|
import { MenuAsideItem } from './interfaces';
|
||||||
|
|
||||||
const menuAside: MenuAsideItem[] = [
|
const menuAside: MenuAsideItem[] = [
|
||||||
{
|
{
|
||||||
@ -7,14 +7,26 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
icon: icon.mdiViewDashboardOutline,
|
icon: icon.mdiViewDashboardOutline,
|
||||||
label: 'Dashboard',
|
label: 'Dashboard',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
href: '/reviewflow',
|
||||||
|
icon: icon.mdiStarOutline,
|
||||||
|
label: 'Review Flow',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
href: '/subscription',
|
||||||
|
icon: icon.mdiCreditCardOutline,
|
||||||
|
label: 'Subscription',
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
href: '/users/users-list',
|
href: '/users/users-list',
|
||||||
label: 'Users',
|
label: 'Users',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
icon: icon.mdiAccountGroup ?? icon.mdiTable,
|
icon: icon.mdiAccountGroup ?? icon.mdiTable,
|
||||||
permissions: 'READ_USERS'
|
permissions: 'READ_USERS',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/roles/roles-list',
|
href: '/roles/roles-list',
|
||||||
@ -22,7 +34,7 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable,
|
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable,
|
||||||
permissions: 'READ_ROLES'
|
permissions: 'READ_ROLES',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/permissions/permissions-list',
|
href: '/permissions/permissions-list',
|
||||||
@ -30,63 +42,84 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
icon: icon.mdiShieldAccountOutline ?? icon.mdiTable,
|
icon: icon.mdiShieldAccountOutline ?? icon.mdiTable,
|
||||||
permissions: 'READ_PERMISSIONS'
|
permissions: 'READ_PERMISSIONS',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/businesses/businesses-list',
|
href: '/businesses/businesses-list',
|
||||||
label: 'Businesses',
|
label: 'Businesses',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
icon: 'mdiStore' in icon ? icon['mdiStore' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
icon:
|
||||||
permissions: 'READ_BUSINESSES'
|
'mdiStore' in icon
|
||||||
|
? icon['mdiStore' as keyof typeof icon]
|
||||||
|
: (icon.mdiTable ?? icon.mdiTable),
|
||||||
|
permissions: 'READ_BUSINESSES',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/customers/customers-list',
|
href: '/customers/customers-list',
|
||||||
label: 'Customers',
|
label: 'Customers',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
icon: 'mdiAccountMultiple' in icon ? icon['mdiAccountMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
icon:
|
||||||
permissions: 'READ_CUSTOMERS'
|
'mdiAccountMultiple' in icon
|
||||||
|
? icon['mdiAccountMultiple' as keyof typeof icon]
|
||||||
|
: (icon.mdiTable ?? icon.mdiTable),
|
||||||
|
permissions: 'READ_CUSTOMERS',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/transactions/transactions-list',
|
href: '/transactions/transactions-list',
|
||||||
label: 'Transactions',
|
label: 'Transactions',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
icon: 'mdiCreditCardOutline' in icon ? icon['mdiCreditCardOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
icon:
|
||||||
permissions: 'READ_TRANSACTIONS'
|
'mdiCreditCardOutline' in icon
|
||||||
|
? icon['mdiCreditCardOutline' as keyof typeof icon]
|
||||||
|
: (icon.mdiTable ?? icon.mdiTable),
|
||||||
|
permissions: 'READ_TRANSACTIONS',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/review_requests/review_requests-list',
|
href: '/review_requests/review_requests-list',
|
||||||
label: 'Review requests',
|
label: 'Review requests',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
icon: 'mdiEmailFastOutline' in icon ? icon['mdiEmailFastOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
icon:
|
||||||
permissions: 'READ_REVIEW_REQUESTS'
|
'mdiEmailFastOutline' in icon
|
||||||
|
? icon['mdiEmailFastOutline' as keyof typeof icon]
|
||||||
|
: (icon.mdiTable ?? icon.mdiTable),
|
||||||
|
permissions: 'READ_REVIEW_REQUESTS',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/stripe_events/stripe_events-list',
|
href: '/stripe_events/stripe_events-list',
|
||||||
label: 'Stripe events',
|
label: 'Payment events',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
icon: 'mdiWebhook' in icon ? icon['mdiWebhook' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
icon:
|
||||||
permissions: 'READ_STRIPE_EVENTS'
|
'mdiWebhook' in icon
|
||||||
|
? icon['mdiWebhook' as keyof typeof icon]
|
||||||
|
: (icon.mdiTable ?? icon.mdiTable),
|
||||||
|
permissions: 'READ_STRIPE_EVENTS',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/email_delivery_logs/email_delivery_logs-list',
|
href: '/email_delivery_logs/email_delivery_logs-list',
|
||||||
label: 'Email delivery logs',
|
label: 'Email delivery logs',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
icon: 'mdiEmailCheckOutline' in icon ? icon['mdiEmailCheckOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
icon:
|
||||||
permissions: 'READ_EMAIL_DELIVERY_LOGS'
|
'mdiEmailCheckOutline' in icon
|
||||||
|
? icon['mdiEmailCheckOutline' as keyof typeof icon]
|
||||||
|
: (icon.mdiTable ?? icon.mdiTable),
|
||||||
|
permissions: 'READ_EMAIL_DELIVERY_LOGS',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/cron_runs/cron_runs-list',
|
href: '/cron_runs/cron_runs-list',
|
||||||
label: 'Cron runs',
|
label: 'Cron runs',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
icon: 'mdiClockOutline' in icon ? icon['mdiClockOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
icon:
|
||||||
permissions: 'READ_CRON_RUNS'
|
'mdiClockOutline' in icon
|
||||||
|
? icon['mdiClockOutline' as keyof typeof icon]
|
||||||
|
: (icon.mdiTable ?? icon.mdiTable),
|
||||||
|
permissions: 'READ_CRON_RUNS',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/profile',
|
href: '/profile',
|
||||||
@ -94,14 +127,13 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
icon: icon.mdiAccountCircle,
|
icon: icon.mdiAccountCircle,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
{
|
{
|
||||||
href: '/api-docs',
|
href: '/api-docs',
|
||||||
target: '_blank',
|
target: '_blank',
|
||||||
label: 'Swagger API',
|
label: 'Swagger API',
|
||||||
icon: icon.mdiFileCode,
|
icon: icon.mdiFileCode,
|
||||||
permissions: 'READ_API_DOCS'
|
permissions: 'READ_API_DOCS',
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
export default menuAside
|
export default menuAside;
|
||||||
|
|||||||
@ -149,7 +149,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
|
|||||||
setStepsEnabled(false);
|
setStepsEnabled(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const title = 'ReviewFlow'
|
const title = 'Review Flow'
|
||||||
const description = "Automate review-request emails after Stripe payments with templates, scheduling, and dashboard analytics."
|
const 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"
|
||||||
|
|||||||
64
frontend/src/pages/connect.tsx
Normal file
64
frontend/src/pages/connect.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { mdiConnection, mdiOpenInNew, mdiWebhook } from '@mdi/js';
|
||||||
|
import Head from 'next/head';
|
||||||
|
import React, { ReactElement } from 'react';
|
||||||
|
import BaseButton from '../components/BaseButton';
|
||||||
|
import PaymentProviderConnectors from '../components/ReviewFlow/PaymentProviderConnectors';
|
||||||
|
import SectionMain from '../components/SectionMain';
|
||||||
|
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||||
|
import { getPageTitle } from '../config';
|
||||||
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
|
|
||||||
|
export default function ConnectPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>{getPageTitle('Connect')}</title>
|
||||||
|
</Head>
|
||||||
|
<SectionMain>
|
||||||
|
<SectionTitleLineWithButton icon={mdiConnection} title='Connect' main>
|
||||||
|
<BaseButton
|
||||||
|
href='/reviewflow'
|
||||||
|
icon={mdiOpenInNew}
|
||||||
|
label='Review Flow'
|
||||||
|
color='whiteDark'
|
||||||
|
/>
|
||||||
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
|
<div className='mb-6 overflow-hidden rounded-3xl bg-gradient-to-br from-slate-950 via-blue-950 to-indigo-950 p-6 text-white shadow-2xl'>
|
||||||
|
<div className='grid gap-6 lg:grid-cols-[1.15fr_0.85fr] lg:items-center'>
|
||||||
|
<div>
|
||||||
|
<p className='mb-3 inline-flex rounded-full bg-white/10 px-4 py-1 text-sm font-semibold text-sky-200 ring-1 ring-white/20'>
|
||||||
|
Trigger setup · destination clarity
|
||||||
|
</p>
|
||||||
|
<h2 className='max-w-3xl text-4xl font-black tracking-tight md:text-5xl'>
|
||||||
|
Connect order/payment triggers without confusing local review channels.
|
||||||
|
</h2>
|
||||||
|
<p className='mt-4 max-w-2xl text-base text-slate-200 md:text-lg'>
|
||||||
|
Use this page to generate secure webhook URLs for payment and ecommerce triggers. Review destinations stay separate: local businesses use Google, Facebook, Yelp, Angi, or OpenTable links, ecommerce brands can use Trustpilot, and Shopify can use a hosted product-review form.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='rounded-3xl bg-white/10 p-5 ring-1 ring-white/15 backdrop-blur'>
|
||||||
|
<div className='mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-sky-400/20 text-sky-100'>
|
||||||
|
<BaseButton icon={mdiWebhook} color='info' roundedFull />
|
||||||
|
</div>
|
||||||
|
<h3 className='text-xl font-black'>How connection works</h3>
|
||||||
|
<p className='mt-2 text-sm leading-6 text-slate-200'>
|
||||||
|
Pick the order/payment trigger first, then choose where reviews should land. Shopify is both an ecommerce trigger and a hosted Review Flow product-review destination.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PaymentProviderConnectors
|
||||||
|
eyebrow='Provider connections'
|
||||||
|
title='Connect triggers and choose review destinations'
|
||||||
|
description='Choose Stripe, PayPal, Square, Shopify, or WooCommerce as the order/payment trigger. Then choose a separate review destination so local and ecommerce customers see the right experience.'
|
||||||
|
/>
|
||||||
|
</SectionMain>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ConnectPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
|
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||||
|
};
|
||||||
@ -1,161 +1,260 @@
|
|||||||
|
import { mdiArrowRight, mdiCheckCircleOutline, mdiLogin, mdiShieldCheckOutline, mdiStarCircleOutline } from '@mdi/js';
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import type { ReactElement } from 'react';
|
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import React, { ReactElement } from 'react';
|
||||||
import BaseButton from '../components/BaseButton';
|
import BaseButton from '../components/BaseButton';
|
||||||
import CardBox from '../components/CardBox';
|
import CardBox from '../components/CardBox';
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import BaseDivider from '../components/BaseDivider';
|
|
||||||
import BaseButtons from '../components/BaseButtons';
|
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import { useAppSelector } from '../stores/hooks';
|
import { subscriptionPlans, trialDays } from '../subscriptionPlans';
|
||||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
|
||||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
|
||||||
|
|
||||||
|
const metrics = [
|
||||||
|
['7 days', 'default review delay'],
|
||||||
|
['5 sources', 'Stripe, Square, PayPal, Shopify, WooCommerce'],
|
||||||
|
['4 states', 'pending, sent, clicked, reviewed'],
|
||||||
|
];
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
['Capture', 'Receive Stripe, Square, PayPal, Shopify, or WooCommerce webhooks as soon as checkout happens.'],
|
||||||
|
['Schedule', 'Create the customer, transaction, and review request automatically with your preferred delay.'],
|
||||||
|
['Track', 'Follow pending, sent, clicked, and reviewed requests from one workspace.'],
|
||||||
|
];
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
'Business review links and templates',
|
||||||
|
'Webhook-created customers and transactions',
|
||||||
|
'Readable queue with message preview',
|
||||||
|
'Admin CRUD and API docs still available',
|
||||||
|
];
|
||||||
|
|
||||||
export default function Starter() {
|
export default function Starter() {
|
||||||
const [illustrationImage, setIllustrationImage] = useState({
|
|
||||||
src: undefined,
|
|
||||||
photographer: undefined,
|
|
||||||
photographer_url: undefined,
|
|
||||||
})
|
|
||||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
|
||||||
const [contentType, setContentType] = useState('video');
|
|
||||||
const [contentPosition, setContentPosition] = useState('left');
|
|
||||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
|
||||||
|
|
||||||
const title = 'ReviewFlow'
|
|
||||||
|
|
||||||
// Fetch Pexels image/video
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchData() {
|
|
||||||
const image = await getPexelsImage();
|
|
||||||
const video = await getPexelsVideo();
|
|
||||||
setIllustrationImage(image);
|
|
||||||
setIllustrationVideo(video);
|
|
||||||
}
|
|
||||||
fetchData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const imageBlock = (image) => (
|
|
||||||
<div
|
|
||||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
|
||||||
style={{
|
|
||||||
backgroundImage: `${
|
|
||||||
image
|
|
||||||
? `url(${image?.src?.original})`
|
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
|
||||||
}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
|
||||||
<a
|
|
||||||
className='text-[8px]'
|
|
||||||
href={image?.photographer_url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
>
|
|
||||||
Photo by {image?.photographer} on Pexels
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const videoBlock = (video) => {
|
|
||||||
if (video?.video_files?.length > 0) {
|
|
||||||
return (
|
|
||||||
<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
|
<div className="min-h-screen bg-[#F7F8FC] text-slate-950">
|
||||||
style={
|
|
||||||
contentPosition === 'background'
|
|
||||||
? {
|
|
||||||
backgroundImage: `${
|
|
||||||
illustrationImage
|
|
||||||
? `url(${illustrationImage.src?.original})`
|
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
|
||||||
}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}
|
|
||||||
: {}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Starter Page')}</title>
|
<title>{getPageTitle('Review Flow')}</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Review Flow helps businesses queue and track review requests after customer purchases or visits."
|
||||||
|
/>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<header className="sticky top-0 z-20 border-b border-white/70 bg-white/80 backdrop-blur-xl">
|
||||||
<div
|
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
|
||||||
className={`flex ${
|
<Link href="/" className="flex items-center gap-3 font-black tracking-tight">
|
||||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#101828] text-white shadow-lg shadow-indigo-950/20">
|
||||||
} min-h-screen w-full`}
|
★
|
||||||
>
|
</span>
|
||||||
{contentType === 'image' && contentPosition !== 'background'
|
<span className="text-xl">Review Flow</span>
|
||||||
? imageBlock(illustrationImage)
|
</Link>
|
||||||
: null}
|
<nav className="flex items-center gap-3">
|
||||||
{contentType === 'video' && contentPosition !== 'background'
|
<BaseButton href="/#pricing" label="Pricing" color="whiteDark" />
|
||||||
? videoBlock(illustrationVideo)
|
<BaseButton href="/login" icon={mdiLogin} label="Login" color="whiteDark" />
|
||||||
: null}
|
<BaseButton href="/reviewflow" icon={mdiArrowRight} label="Admin interface" color="info" />
|
||||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
</nav>
|
||||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
|
||||||
<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>
|
||||||
</div>
|
</header>
|
||||||
</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="Login to admin" color="whiteDark" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-10 grid max-w-2xl gap-3 sm:grid-cols-3">
|
||||||
|
{metrics.map(([value, label]) => (
|
||||||
|
<div key={label} className="rounded-3xl border border-white bg-white/80 p-5 shadow-xl shadow-slate-200/60">
|
||||||
|
<p className="text-3xl font-black text-slate-950">{value}</p>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">{label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">businesses/locations</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-black text-slate-950">{plan.limits.teamMembers}</p>
|
||||||
|
<p className="text-sm text-slate-500">team members</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-black text-slate-950">{plan.limits.paymentConnectors}</p>
|
||||||
|
<p className="text-sm text-slate-500">payment connectors</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BaseButton
|
||||||
|
href={`/register?plan=${plan.id}`}
|
||||||
|
icon={mdiArrowRight}
|
||||||
|
label={plan.ctaLabel}
|
||||||
|
color={isPro ? 'info' : 'whiteDark'}
|
||||||
|
className="mt-8 w-full shadow-xl shadow-indigo-600/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={isPro ? 'bg-indigo-950 p-8 text-white' : 'bg-slate-950 p-8 text-white'}>
|
||||||
|
<p className="mb-5 text-sm font-black uppercase tracking-[0.25em] text-emerald-300">Included features</p>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{plan.features.map((feature) => (
|
||||||
|
<div key={feature} className="flex items-start gap-3">
|
||||||
|
<span className="mt-1 text-emerald-300">
|
||||||
|
<svg className="h-5 w-5" viewBox="0 0 24 24">
|
||||||
|
<path fill="currentColor" d={mdiCheckCircleOutline} />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-slate-100">{feature}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bg-[#101828] px-6 py-20 text-white">
|
||||||
|
<div className="mx-auto grid max-w-7xl gap-10 lg:grid-cols-[0.8fr_1.2fr] lg:items-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-black uppercase tracking-[0.3em] text-emerald-300">First MVP slice</p>
|
||||||
|
<h2 className="mt-4 text-4xl font-black tracking-tight md:text-5xl">A complete thin workflow, not just a screen.</h2>
|
||||||
|
<p className="mt-5 leading-8 text-slate-300">
|
||||||
|
The admin workspace lets a user connect payment webhooks, receive events, create transactions and customers, queue review requests, browse recent activity, and inspect the generated message.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{features.map((feature) => (
|
||||||
|
<div key={feature} className="flex items-start gap-3 rounded-3xl bg-white/10 p-5 ring-1 ring-white/10">
|
||||||
|
<span className="mt-1 text-emerald-300"><svg className="h-5 w-5" viewBox="0 0 24 24"><path fill="currentColor" d={mdiCheckCircleOutline} /></svg></span>
|
||||||
|
<span className="font-semibold text-slate-100">{feature}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="bg-white px-6 py-8">
|
||||||
|
<div className="mx-auto flex max-w-7xl flex-col gap-4 text-sm text-slate-500 md:flex-row md:items-center md:justify-between">
|
||||||
|
<p>© 2026 Review Flow. All rights reserved.</p>
|
||||||
|
<div className="flex gap-5">
|
||||||
|
<Link href="/privacy-policy/" className="hover:text-slate-950">Privacy Policy</Link>
|
||||||
|
<Link href="/terms-of-use/" className="hover:text-slate-950">Terms of Use</Link>
|
||||||
|
<Link href="/login" className="font-bold text-slate-950">Login</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -163,4 +262,3 @@ export default function Starter() {
|
|||||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,7 @@ import { findMe, loginUser, resetAction } from '../stores/authSlice';
|
|||||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {toast, ToastContainer} from "react-toastify";
|
import {toast, ToastContainer} from "react-toastify";
|
||||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
|
import { getPexelsImage } from '../helpers/pexels'
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -33,9 +33,7 @@ export default function Login() {
|
|||||||
photographer: undefined,
|
photographer: undefined,
|
||||||
photographer_url: undefined,
|
photographer_url: undefined,
|
||||||
})
|
})
|
||||||
const [ illustrationVideo, setIllustrationVideo ] = useState({video_files: []})
|
const [contentPosition] = useState<'left' | 'right' | 'background'>('left');
|
||||||
const [contentType, setContentType] = useState('video');
|
|
||||||
const [contentPosition, setContentPosition] = useState('left');
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
|
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
|
||||||
(state) => state.auth,
|
(state) => state.auth,
|
||||||
@ -44,15 +42,90 @@ export default function Login() {
|
|||||||
password: 'fc6e39e3',
|
password: 'fc6e39e3',
|
||||||
remember: true })
|
remember: true })
|
||||||
|
|
||||||
const title = 'ReviewFlow'
|
const title = 'Review Flow'
|
||||||
|
|
||||||
// Fetch Pexels image/video
|
const appHighlights = [
|
||||||
|
'Automated review requests after payments, jobs, or service milestones.',
|
||||||
|
'Customer, business, transaction, and delivery follow-up data in one admin workspace.',
|
||||||
|
'Dashboards, CRM records, payment events, email logs, and admin controls already built in.',
|
||||||
|
];
|
||||||
|
|
||||||
|
const competitorAdvantages = [
|
||||||
|
{
|
||||||
|
title: 'Built around review operations',
|
||||||
|
description:
|
||||||
|
'Review Flow combines CRM records, payments, follow-up, review requests, and reputation workflows in one focused system.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Designed for logistics teams',
|
||||||
|
description:
|
||||||
|
'Transportation teams can manage businesses, customers, transactions, payment events, and review requests without jumping tools.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Clear Starter and Pro tiers',
|
||||||
|
description:
|
||||||
|
'Starter is $49/month for the core review workflow. Pro is $99/month for higher limits, automation, AI, and reputation marketing tools.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const pricingPlans = [
|
||||||
|
{
|
||||||
|
name: 'Starter',
|
||||||
|
price: '$49',
|
||||||
|
description:
|
||||||
|
'Best for small teams that need the core Review Flow workflow and simple monthly limits.',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Core review workflow',
|
||||||
|
features: [
|
||||||
|
'Review Flow workspace for creating, scheduling, and tracking review requests.',
|
||||||
|
'Manual review request creation and hosted public review forms.',
|
||||||
|
'Customer, business, transaction, and delivery follow-up records.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Starter limits',
|
||||||
|
features: [
|
||||||
|
'250 review requests per month.',
|
||||||
|
'1 business or location.',
|
||||||
|
'2 team members.',
|
||||||
|
'Stripe, Square, PayPal, Shopify, and WooCommerce webhook intake.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Pro',
|
||||||
|
price: '$99',
|
||||||
|
description:
|
||||||
|
'Best for growing teams that want higher limits, automation, AI assistance, and reputation marketing tools.',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Everything in Starter',
|
||||||
|
features: [
|
||||||
|
'2,500 review requests per month.',
|
||||||
|
'10 businesses or locations.',
|
||||||
|
'10 team members.',
|
||||||
|
'Priority support and advanced reporting.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Growth tools',
|
||||||
|
features: [
|
||||||
|
'Advanced automation rules.',
|
||||||
|
'AI review reply assistant.',
|
||||||
|
'Social proof widgets, referral campaigns, repeat booking reminders, NPS surveys, and broadcasts.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Fetch Pexels image
|
||||||
useEffect( () => {
|
useEffect( () => {
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
const image = await getPexelsImage()
|
const image = await getPexelsImage()
|
||||||
const video = await getPexelsVideo()
|
|
||||||
setIllustrationImage(image);
|
setIllustrationImage(image);
|
||||||
setIllustrationVideo(video);
|
|
||||||
}
|
}
|
||||||
fetchData();
|
fetchData();
|
||||||
}, []);
|
}, []);
|
||||||
@ -115,32 +188,7 @@ export default function Login() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
const videoBlock = (video) => {
|
|
||||||
if (video?.video_files?.length > 0) {
|
|
||||||
return (
|
|
||||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
|
||||||
<video
|
|
||||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
>
|
|
||||||
<source src={video.video_files[0]?.link} type='video/mp4'/>
|
|
||||||
Your browser does not support the video tag.
|
|
||||||
</video>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
|
||||||
<a
|
|
||||||
className='text-[8px]'
|
|
||||||
href={video.user.url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
>
|
|
||||||
Video by {video.user.name} on Pexels
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={contentPosition === 'background' ? {
|
<div style={contentPosition === 'background' ? {
|
||||||
@ -159,8 +207,7 @@ export default function Login() {
|
|||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<SectionFullScreen bg='violet'>
|
||||||
<div className={`flex ${contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'} min-h-screen w-full`}>
|
<div className={`flex ${contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'} min-h-screen w-full`}>
|
||||||
{contentType === 'image' && contentPosition !== 'background' ? imageBlock(illustrationImage) : null}
|
{contentPosition !== 'background' ? imageBlock(illustrationImage) : null}
|
||||||
{contentType === 'video' && contentPosition !== 'background' ? videoBlock(illustrationVideo) : null}
|
|
||||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||||
|
|
||||||
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'>
|
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'>
|
||||||
@ -257,6 +304,95 @@ export default function Login() {
|
|||||||
</Form>
|
</Form>
|
||||||
</Formik>
|
</Formik>
|
||||||
</CardBox>
|
</CardBox>
|
||||||
|
|
||||||
|
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||||
|
<div className='space-y-8'>
|
||||||
|
<div>
|
||||||
|
<p className='text-sm font-semibold uppercase tracking-[0.2em] text-blue-600'>About Us</p>
|
||||||
|
<h3 className='mt-2 text-3xl font-semibold text-gray-900 dark:text-white'>
|
||||||
|
Review management built for transportation teams.
|
||||||
|
</h3>
|
||||||
|
<p className='mt-4 text-base leading-7 text-gray-600 dark:text-slate-300'>
|
||||||
|
Review Flow helps logistics and transportation businesses turn completed jobs, payments,
|
||||||
|
and customer interactions into organized review requests. Your team can manage customer
|
||||||
|
records, monitor follow-up, and keep reputation-building work moving from one secure
|
||||||
|
admin panel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='grid gap-3 md:grid-cols-3'>
|
||||||
|
{appHighlights.map((highlight) => (
|
||||||
|
<div
|
||||||
|
key={highlight}
|
||||||
|
className='rounded-2xl border border-blue-100 bg-blue-50/70 p-4 text-sm leading-6 text-blue-900 dark:border-dark-700 dark:bg-dark-800 dark:text-slate-200'
|
||||||
|
>
|
||||||
|
{highlight}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className='text-xl font-semibold text-gray-900 dark:text-white'>Why we'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>
|
||||||
|
|||||||
@ -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 = 'ReviewFlow'
|
const title = 'Review Flow'
|
||||||
const [projectUrl, setProjectUrl] = useState('');
|
const [projectUrl, setProjectUrl] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -12,12 +12,15 @@ import BaseDivider from '../components/BaseDivider';
|
|||||||
import BaseButtons from '../components/BaseButtons';
|
import BaseButtons from '../components/BaseButtons';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
|
import { subscriptionPlans } from '../subscriptionPlans';
|
||||||
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
export default function Register() {
|
export default function Register() {
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const selectedPlanId = typeof router.query.plan === 'string' ? router.query.plan : 'starter';
|
||||||
|
const selectedPlan = subscriptionPlans.find((plan) => plan.id === selectedPlanId) || subscriptionPlans[0];
|
||||||
const notify = (type, msg) => toast( msg, {type, position: "bottom-center"});
|
const notify = (type, msg) => toast( msg, {type, position: "bottom-center"});
|
||||||
|
|
||||||
|
|
||||||
@ -25,7 +28,7 @@ export default function Register() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const { data: response } = await axios.post('/auth/signup',value);
|
const { data: response } = await axios.post('/auth/signup',{ ...value, planId: selectedPlan.id });
|
||||||
await router.push('/login')
|
await router.push('/login')
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
notify('success', 'Please check your email for verification link')
|
notify('success', 'Please check your email for verification link')
|
||||||
@ -44,6 +47,10 @@ export default function Register() {
|
|||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<SectionFullScreen bg='violet'>
|
||||||
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
|
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
|
||||||
|
<div className='mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-emerald-900'>
|
||||||
|
<p className='font-black'>{selectedPlan.name} trial</p>
|
||||||
|
<p className='text-sm'>${selectedPlan.priceMonthly}/month after the {selectedPlan.trialDays}-day free trial. You can change plans from Subscription after signup.</p>
|
||||||
|
</div>
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
email: '',
|
email: '',
|
||||||
|
|||||||
344
frontend/src/pages/review/[trackingToken].tsx
Normal file
344
frontend/src/pages/review/[trackingToken].tsx
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
import {
|
||||||
|
mdiArrowLeft,
|
||||||
|
mdiCheckCircleOutline,
|
||||||
|
mdiStar,
|
||||||
|
} from '@mdi/js';
|
||||||
|
import axios from 'axios';
|
||||||
|
import Head from 'next/head';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import React, { FormEvent, ReactElement, useEffect, useState } from 'react';
|
||||||
|
import BaseButton from '../../components/BaseButton';
|
||||||
|
import CardBox from '../../components/CardBox';
|
||||||
|
import LayoutGuest from '../../layouts/Guest';
|
||||||
|
import { getPageTitle } from '../../config';
|
||||||
|
|
||||||
|
interface HostedReviewProduct {
|
||||||
|
name?: string;
|
||||||
|
sku?: string;
|
||||||
|
quantity?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HostedReviewPayload {
|
||||||
|
provider?: string;
|
||||||
|
order?: {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
orderNumber?: string;
|
||||||
|
};
|
||||||
|
products?: HostedReviewProduct[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HostedReviewRequest {
|
||||||
|
id: string;
|
||||||
|
status?: string;
|
||||||
|
review_platform?: string;
|
||||||
|
review_rating?: number | null;
|
||||||
|
review_title?: string | null;
|
||||||
|
review_content?: string | null;
|
||||||
|
reviewer_display_name?: string | null;
|
||||||
|
review_payload?: HostedReviewPayload | null;
|
||||||
|
business?: {
|
||||||
|
name?: string;
|
||||||
|
} | null;
|
||||||
|
customer?: {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
} | null;
|
||||||
|
transaction?: {
|
||||||
|
payment_provider?: string;
|
||||||
|
description?: string;
|
||||||
|
amount?: string | number;
|
||||||
|
currency?: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratingOptions = [1, 2, 3, 4, 5];
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown) {
|
||||||
|
if (axios.isAxiosError(error) && error.response?.data) {
|
||||||
|
const responseData = error.response.data;
|
||||||
|
|
||||||
|
if (typeof responseData === 'string') {
|
||||||
|
return responseData;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof responseData === 'object' && 'message' in responseData) {
|
||||||
|
return String(responseData.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Something went wrong. Please try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAmount(amount?: string | number, currency?: string) {
|
||||||
|
const numericAmount = Number(amount);
|
||||||
|
|
||||||
|
if (!Number.isFinite(numericAmount)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.NumberFormat('en', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency || 'USD',
|
||||||
|
}).format(numericAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HostedReviewPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const trackingToken = Array.isArray(router.query.trackingToken)
|
||||||
|
? router.query.trackingToken[0]
|
||||||
|
: router.query.trackingToken;
|
||||||
|
const [review, setReview] = useState<HostedReviewRequest | null>(null);
|
||||||
|
const [rating, setRating] = useState(5);
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [reviewerName, setReviewerName] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!trackingToken) return;
|
||||||
|
|
||||||
|
const loadReview = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(
|
||||||
|
`/reviewflow-public/reviews/${trackingToken}`,
|
||||||
|
);
|
||||||
|
const loadedReview = response.data.review as HostedReviewRequest;
|
||||||
|
setReview(loadedReview);
|
||||||
|
setRating(loadedReview.review_rating || 5);
|
||||||
|
setTitle(loadedReview.review_title || '');
|
||||||
|
setContent(loadedReview.review_content || '');
|
||||||
|
setReviewerName(
|
||||||
|
loadedReview.reviewer_display_name || loadedReview.customer?.name || '',
|
||||||
|
);
|
||||||
|
setIsSubmitted(loadedReview.status === 'reviewed');
|
||||||
|
} catch (requestError) {
|
||||||
|
console.error('Failed to load hosted review request:', requestError);
|
||||||
|
setError(getErrorMessage(requestError));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadReview();
|
||||||
|
}, [trackingToken]);
|
||||||
|
|
||||||
|
const submitReview = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!trackingToken) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`/reviewflow-public/reviews/${trackingToken}`,
|
||||||
|
{
|
||||||
|
rating,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
reviewerName,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
setReview(response.data.review);
|
||||||
|
setIsSubmitted(true);
|
||||||
|
} catch (requestError) {
|
||||||
|
console.error('Failed to submit hosted review:', requestError);
|
||||||
|
setError(getErrorMessage(requestError));
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const businessName = review?.business?.name || 'this business';
|
||||||
|
const products = review?.review_payload?.products || [];
|
||||||
|
const orderName =
|
||||||
|
review?.review_payload?.order?.name || review?.transaction?.description || '';
|
||||||
|
const amount = formatAmount(
|
||||||
|
review?.transaction?.amount,
|
||||||
|
review?.transaction?.currency,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>{getPageTitle(`Review ${businessName}`)}</title>
|
||||||
|
</Head>
|
||||||
|
<main className='min-h-screen bg-gradient-to-br from-slate-950 via-indigo-950 to-emerald-950 px-4 py-10 text-slate-100'>
|
||||||
|
<div className='mx-auto max-w-3xl'>
|
||||||
|
<div className='mb-6 text-center'>
|
||||||
|
<p className='text-sm font-bold uppercase tracking-[0.3em] text-emerald-300'>
|
||||||
|
Review Flow
|
||||||
|
</p>
|
||||||
|
<h1 className='mt-3 text-4xl font-black tracking-tight md:text-5xl'>
|
||||||
|
Share your experience with {businessName}
|
||||||
|
</h1>
|
||||||
|
<p className='mt-3 text-base text-slate-300'>
|
||||||
|
Your feedback helps the team improve and helps future customers know what to expect.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardBox className='border-0 bg-white text-slate-800 shadow-2xl dark:bg-slate-900 dark:text-slate-100'>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className='rounded-2xl border border-dashed border-slate-200 p-8 text-center text-slate-500 dark:border-slate-700'>
|
||||||
|
Loading your review form...
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className='space-y-4 rounded-2xl border border-rose-200 bg-rose-50 p-6 text-rose-900'>
|
||||||
|
<p className='font-black'>We could not load this review request.</p>
|
||||||
|
<p>{error}</p>
|
||||||
|
<BaseButton
|
||||||
|
href='/'
|
||||||
|
icon={mdiArrowLeft}
|
||||||
|
label='Back to website'
|
||||||
|
color='whiteDark'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : isSubmitted ? (
|
||||||
|
<div className='rounded-3xl bg-emerald-50 p-8 text-center text-emerald-950'>
|
||||||
|
<BaseButton icon={mdiCheckCircleOutline} color='success' roundedFull />
|
||||||
|
<h2 className='mt-4 text-3xl font-black'>Thank you for your review!</h2>
|
||||||
|
<p className='mt-3 text-emerald-800'>
|
||||||
|
Your feedback was submitted successfully.
|
||||||
|
</p>
|
||||||
|
{review?.review_rating && (
|
||||||
|
<div className='mt-5 flex justify-center gap-1 text-amber-500'>
|
||||||
|
{ratingOptions.map((option) => (
|
||||||
|
<span key={option}>{option <= Number(review.review_rating || 0) ? '★' : '☆'}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={submitReview} className='space-y-6'>
|
||||||
|
<div className='rounded-2xl bg-slate-50 p-4 dark:bg-slate-800'>
|
||||||
|
<p className='text-xs font-bold uppercase tracking-widest text-slate-400'>
|
||||||
|
Review context
|
||||||
|
</p>
|
||||||
|
<h2 className='mt-1 text-2xl font-black text-slate-900 dark:text-white'>
|
||||||
|
{businessName}
|
||||||
|
</h2>
|
||||||
|
{(orderName || amount || review?.transaction?.payment_provider) && (
|
||||||
|
<p className='mt-1 text-sm text-slate-500 dark:text-slate-300'>
|
||||||
|
{review?.transaction?.payment_provider || 'Order'}
|
||||||
|
{orderName ? ` · ${orderName}` : ''}
|
||||||
|
{amount ? ` · ${amount}` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{products.length > 0 && (
|
||||||
|
<div className='mt-4 grid gap-2'>
|
||||||
|
{products.map((product, index) => (
|
||||||
|
<div
|
||||||
|
key={`${product.name || 'product'}-${index}`}
|
||||||
|
className='rounded-xl bg-white p-3 text-sm ring-1 ring-slate-200 dark:bg-slate-900 dark:ring-slate-700'
|
||||||
|
>
|
||||||
|
<p className='font-bold text-slate-900 dark:text-white'>
|
||||||
|
{product.name || 'Purchased item'}
|
||||||
|
</p>
|
||||||
|
<p className='text-slate-500'>
|
||||||
|
{product.sku ? `SKU ${product.sku}` : 'Shopify product'}
|
||||||
|
{product.quantity ? ` · Qty ${product.quantity}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className='mb-2 block text-sm font-black text-slate-900 dark:text-white'>
|
||||||
|
Your rating
|
||||||
|
</label>
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
{ratingOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type='button'
|
||||||
|
onClick={() => setRating(option)}
|
||||||
|
className={`inline-flex h-12 w-12 items-center justify-center rounded-2xl border text-lg font-black transition ${
|
||||||
|
option <= rating
|
||||||
|
? 'border-amber-300 bg-amber-100 text-amber-600'
|
||||||
|
: 'border-slate-200 bg-white text-slate-400 dark:border-slate-700 dark:bg-slate-900'
|
||||||
|
}`}
|
||||||
|
aria-label={`${option} star rating`}
|
||||||
|
>
|
||||||
|
<svg viewBox='0 0 24 24' className='h-5 w-5' aria-hidden='true'>
|
||||||
|
<path fill='currentColor' d={mdiStar} />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className='mb-2 block text-sm font-black text-slate-900 dark:text-white'>
|
||||||
|
Review title <span className='font-normal text-slate-400'>(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={title}
|
||||||
|
onChange={(event) => setTitle(event.target.value)}
|
||||||
|
className='h-11 w-full rounded-xl border border-slate-300 px-3 py-2 text-slate-900 outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 dark:border-slate-700 dark:bg-slate-900 dark:text-white'
|
||||||
|
placeholder='What stood out?'
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className='mb-2 block text-sm font-black text-slate-900 dark:text-white'>
|
||||||
|
Your review
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
value={content}
|
||||||
|
onChange={(event) => setContent(event.target.value)}
|
||||||
|
className='min-h-36 w-full rounded-xl border border-slate-300 px-3 py-2 text-slate-900 outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 dark:border-slate-700 dark:bg-slate-900 dark:text-white'
|
||||||
|
placeholder='Tell us about your experience...'
|
||||||
|
maxLength={5000}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className='mb-2 block text-sm font-black text-slate-900 dark:text-white'>
|
||||||
|
Display name <span className='font-normal text-slate-400'>(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={reviewerName}
|
||||||
|
onChange={(event) => setReviewerName(event.target.value)}
|
||||||
|
className='h-11 w-full rounded-xl border border-slate-300 px-3 py-2 text-slate-900 outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 dark:border-slate-700 dark:bg-slate-900 dark:text-white'
|
||||||
|
placeholder='Your name'
|
||||||
|
maxLength={120}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className='rounded-2xl border border-rose-200 bg-rose-50 p-4 text-rose-900'>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<BaseButton
|
||||||
|
type='submit'
|
||||||
|
icon={mdiCheckCircleOutline}
|
||||||
|
label={isSubmitting ? 'Submitting...' : 'Submit review'}
|
||||||
|
color='success'
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</CardBox>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
HostedReviewPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
|
};
|
||||||
897
frontend/src/pages/reviewflow.tsx
Normal file
897
frontend/src/pages/reviewflow.tsx
Normal file
@ -0,0 +1,897 @@
|
|||||||
|
import {
|
||||||
|
mdiAccountPlusOutline,
|
||||||
|
mdiCreditCardOutline,
|
||||||
|
mdiEmailOutline,
|
||||||
|
mdiOpenInNew,
|
||||||
|
mdiRefresh,
|
||||||
|
mdiSend,
|
||||||
|
mdiStarCircleOutline,
|
||||||
|
mdiWebhook,
|
||||||
|
} from '@mdi/js';
|
||||||
|
import axios from 'axios';
|
||||||
|
import Head from 'next/head';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import React, {
|
||||||
|
FormEvent,
|
||||||
|
ReactElement,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import PaymentProviderConnectors, {
|
||||||
|
ConnectorFormValues,
|
||||||
|
} from '../components/ReviewFlow/PaymentProviderConnectors';
|
||||||
|
import BaseButton from '../components/BaseButton';
|
||||||
|
import CardBox from '../components/CardBox';
|
||||||
|
import FormField from '../components/FormField';
|
||||||
|
import SectionMain from '../components/SectionMain';
|
||||||
|
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||||
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
|
import { getPageTitle } from '../config';
|
||||||
|
|
||||||
|
interface ReviewBusiness {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
google_review_link?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewCustomer {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewTransaction {
|
||||||
|
id: string;
|
||||||
|
payment_provider?: string;
|
||||||
|
amount?: string | number;
|
||||||
|
currency?: string;
|
||||||
|
paid_at?: string;
|
||||||
|
receipt_email?: string;
|
||||||
|
description?: string;
|
||||||
|
business?: ReviewBusiness;
|
||||||
|
customer?: ReviewCustomer;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewEvent {
|
||||||
|
id: string;
|
||||||
|
provider?: string;
|
||||||
|
provider_event_type?: string;
|
||||||
|
event_type?: string;
|
||||||
|
processed?: boolean;
|
||||||
|
processing_error?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
business?: ReviewBusiness;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewRequest {
|
||||||
|
id: string;
|
||||||
|
status?: string;
|
||||||
|
scheduled_for?: string;
|
||||||
|
email_subject?: string;
|
||||||
|
email_body?: string;
|
||||||
|
review_link?: string;
|
||||||
|
review_platform?: string;
|
||||||
|
review_rating?: number;
|
||||||
|
createdAt?: string;
|
||||||
|
business?: ReviewBusiness;
|
||||||
|
customer?: ReviewCustomer;
|
||||||
|
transaction?: ReviewTransaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SummaryResponse {
|
||||||
|
stats: {
|
||||||
|
pending: number;
|
||||||
|
sent: number;
|
||||||
|
clicked: number;
|
||||||
|
reviewed: number;
|
||||||
|
customers: number;
|
||||||
|
transactions: number;
|
||||||
|
paymentEvents: number;
|
||||||
|
};
|
||||||
|
requests: ReviewRequest[];
|
||||||
|
recentTransactions?: ReviewTransaction[];
|
||||||
|
recentEvents?: ReviewEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubscriptionStatusResponse {
|
||||||
|
subscription: {
|
||||||
|
planId: string;
|
||||||
|
planName: string;
|
||||||
|
effectiveStatus: string;
|
||||||
|
isActive: boolean;
|
||||||
|
trialEndsAt?: string | null;
|
||||||
|
trialDaysLeft?: number | null;
|
||||||
|
};
|
||||||
|
usage: {
|
||||||
|
monthlyReviewRequests: number;
|
||||||
|
businesses: number;
|
||||||
|
teamMembers: number;
|
||||||
|
paymentConnectors: number;
|
||||||
|
};
|
||||||
|
limits: {
|
||||||
|
monthlyReviewRequests: number;
|
||||||
|
businesses: number;
|
||||||
|
teamMembers: number;
|
||||||
|
paymentConnectors: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultForm = {
|
||||||
|
businessName: 'Review Flow Studio',
|
||||||
|
reviewDestination: 'google',
|
||||||
|
reviewLink: 'https://g.page/r/example/review',
|
||||||
|
delayDays: '7',
|
||||||
|
customerName: '',
|
||||||
|
customerEmail: '',
|
||||||
|
phone: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const reviewDestinationOptions = [
|
||||||
|
{ key: 'google', label: 'Google', requiresLink: true },
|
||||||
|
{ key: 'facebook', label: 'Facebook', requiresLink: true },
|
||||||
|
{ key: 'yelp', label: 'Yelp', requiresLink: true },
|
||||||
|
{ key: 'angi', label: 'Angi', requiresLink: true },
|
||||||
|
{ key: 'opentable', label: 'OpenTable', requiresLink: true },
|
||||||
|
{ key: 'trustpilot', label: 'Trustpilot', requiresLink: true },
|
||||||
|
{ key: 'shopify_hosted', label: 'Shopify hosted product review', requiresLink: false },
|
||||||
|
{ key: 'custom', label: 'Custom review page', requiresLink: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusStyles: Record<string, string> = {
|
||||||
|
pending: 'bg-amber-100 text-amber-800 ring-amber-200',
|
||||||
|
sent: 'bg-sky-100 text-sky-800 ring-sky-200',
|
||||||
|
clicked: 'bg-violet-100 text-violet-800 ring-violet-200',
|
||||||
|
reviewed: 'bg-emerald-100 text-emerald-800 ring-emerald-200',
|
||||||
|
failed: 'bg-rose-100 text-rose-800 ring-rose-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
const proFeaturePrompts = [
|
||||||
|
['Advanced automation', 'Create rules for timing, destinations, and follow-up behavior.'],
|
||||||
|
['AI reply assistant', 'Draft thoughtful review replies faster from one workspace.'],
|
||||||
|
['Reputation marketing', 'Unlock widgets, referral campaigns, NPS surveys, and broadcasts.'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatDate(value?: string | null) {
|
||||||
|
if (!value) return 'Not scheduled';
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('en', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAuthToken() {
|
||||||
|
return (
|
||||||
|
typeof window !== 'undefined' && Boolean(localStorage.getItem('token'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUnauthorizedError(error: unknown) {
|
||||||
|
return axios.isAxiosError(error) && error.response?.status === 401;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAmount(amount?: string | number, currency?: string) {
|
||||||
|
const numericAmount = Number(amount);
|
||||||
|
|
||||||
|
if (!Number.isFinite(numericAmount)) {
|
||||||
|
return 'Amount pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.NumberFormat('en', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency || 'USD',
|
||||||
|
}).format(numericAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReviewFlowWorkspace() {
|
||||||
|
const [form, setForm] = useState(defaultForm);
|
||||||
|
const [summary, setSummary] = useState<SummaryResponse | null>(null);
|
||||||
|
const [selected, setSelected] = useState<ReviewRequest | null>(null);
|
||||||
|
const [created, setCreated] = useState<ReviewRequest | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [subscriptionStatus, setSubscriptionStatus] =
|
||||||
|
useState<SubscriptionStatusResponse | null>(null);
|
||||||
|
const [isClientReady, setIsClientReady] = useState(false);
|
||||||
|
|
||||||
|
const requests = summary?.requests ?? [];
|
||||||
|
const recentTransactions = summary?.recentTransactions ?? [];
|
||||||
|
const recentEvents = summary?.recentEvents ?? [];
|
||||||
|
const stats = summary?.stats ?? {
|
||||||
|
pending: 0,
|
||||||
|
sent: 0,
|
||||||
|
clicked: 0,
|
||||||
|
reviewed: 0,
|
||||||
|
customers: 0,
|
||||||
|
transactions: 0,
|
||||||
|
paymentEvents: 0,
|
||||||
|
};
|
||||||
|
const selectedReviewDestination =
|
||||||
|
reviewDestinationOptions.find(
|
||||||
|
(destination) => destination.key === form.reviewDestination,
|
||||||
|
) || reviewDestinationOptions[0];
|
||||||
|
const isHostedReviewDestination = !selectedReviewDestination.requiresLink;
|
||||||
|
|
||||||
|
const previewDate = useMemo(() => {
|
||||||
|
if (!isClientReady) return 'after the selected delay';
|
||||||
|
|
||||||
|
const days = Math.max(0, Number(form.delayDays) || 0);
|
||||||
|
return formatDate(
|
||||||
|
new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
);
|
||||||
|
}, [form.delayDays, isClientReady]);
|
||||||
|
|
||||||
|
const loadSummary = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/reviewflow/summary');
|
||||||
|
setSummary(response.data);
|
||||||
|
if (!selected && response.data.requests?.length) {
|
||||||
|
setSelected(response.data.requests[0]);
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
|
} catch (requestError) {
|
||||||
|
if (!isUnauthorizedError(requestError)) {
|
||||||
|
console.error('Failed to load Review Flow summary:', requestError);
|
||||||
|
setError(
|
||||||
|
'Could not load your review queue. Please refresh or try again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSubscriptionStatus = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/subscription/me');
|
||||||
|
setSubscriptionStatus(response.data);
|
||||||
|
} catch (requestError) {
|
||||||
|
if (!isUnauthorizedError(requestError)) {
|
||||||
|
console.error('Failed to load subscription status:', requestError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsClientReady(true);
|
||||||
|
|
||||||
|
if (!hasAuthToken()) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSummary();
|
||||||
|
loadSubscriptionStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateForm = (key: keyof typeof defaultForm, value: string) => {
|
||||||
|
setForm((current) => ({ ...current, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError('');
|
||||||
|
setCreated(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/reviewflow/request', {
|
||||||
|
...form,
|
||||||
|
reviewLink: isHostedReviewDestination ? '' : form.reviewLink,
|
||||||
|
delayDays: Number(form.delayDays),
|
||||||
|
});
|
||||||
|
const newRequest = response.data.request;
|
||||||
|
setCreated(newRequest);
|
||||||
|
setSelected(newRequest);
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
customerName: '',
|
||||||
|
customerEmail: '',
|
||||||
|
phone: '',
|
||||||
|
}));
|
||||||
|
await Promise.all([loadSummary(), loadSubscriptionStatus()]);
|
||||||
|
} catch (requestError) {
|
||||||
|
console.error('Failed to create review request:', requestError);
|
||||||
|
if (axios.isAxiosError(requestError) && requestError.response?.data) {
|
||||||
|
setError(String(requestError.response.data));
|
||||||
|
} else {
|
||||||
|
setError(
|
||||||
|
'Could not create the review request. Please check the fields and try again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProviderConnected = async (
|
||||||
|
_business: unknown,
|
||||||
|
connectorForm: ConnectorFormValues,
|
||||||
|
) => {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
businessName: connectorForm.businessName,
|
||||||
|
reviewDestination: connectorForm.reviewDestination,
|
||||||
|
reviewLink: connectorForm.reviewLink,
|
||||||
|
delayDays: connectorForm.delayDays,
|
||||||
|
}));
|
||||||
|
await Promise.all([loadSummary(), loadSubscriptionStatus()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentSubscription = subscriptionStatus?.subscription;
|
||||||
|
const currentUsage = subscriptionStatus?.usage;
|
||||||
|
const currentLimits = subscriptionStatus?.limits;
|
||||||
|
const reviewRequestsUsed = currentUsage?.monthlyReviewRequests ?? 0;
|
||||||
|
const reviewRequestsLimit = currentLimits?.monthlyReviewRequests ?? 0;
|
||||||
|
const reviewRequestsRemaining = Math.max(
|
||||||
|
0,
|
||||||
|
reviewRequestsLimit - reviewRequestsUsed,
|
||||||
|
);
|
||||||
|
const reviewRequestsPercent = reviewRequestsLimit
|
||||||
|
? Math.min(100, Math.round((reviewRequestsUsed / reviewRequestsLimit) * 100))
|
||||||
|
: 0;
|
||||||
|
const businessesUsed = currentUsage?.businesses ?? 0;
|
||||||
|
const businessesLimit = currentLimits?.businesses ?? 0;
|
||||||
|
const businessesRemaining = Math.max(0, businessesLimit - businessesUsed);
|
||||||
|
const isStarterPlan = currentSubscription?.planId === 'starter';
|
||||||
|
const isSubscriptionInactive =
|
||||||
|
currentSubscription && !currentSubscription.isActive;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>{getPageTitle('Review Flow')}</title>
|
||||||
|
</Head>
|
||||||
|
<SectionMain>
|
||||||
|
<SectionTitleLineWithButton
|
||||||
|
icon={mdiStarCircleOutline}
|
||||||
|
title='Review Flow command center'
|
||||||
|
main
|
||||||
|
>
|
||||||
|
<BaseButton
|
||||||
|
href='/review_requests/review_requests-list'
|
||||||
|
icon={mdiOpenInNew}
|
||||||
|
label='Open CRUD'
|
||||||
|
color='whiteDark'
|
||||||
|
/>
|
||||||
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
|
<div className='mb-6 overflow-hidden rounded-3xl bg-gradient-to-br from-slate-950 via-indigo-950 to-emerald-950 p-6 text-white shadow-2xl'>
|
||||||
|
<div className='grid gap-6 lg:grid-cols-[1.2fr_0.8fr] lg:items-center'>
|
||||||
|
<div>
|
||||||
|
<p className='mb-3 inline-flex rounded-full bg-white/10 px-4 py-1 text-sm font-semibold text-emerald-200 ring-1 ring-white/20'>
|
||||||
|
Clean workflow · trigger → customer → right review destination
|
||||||
|
</p>
|
||||||
|
<h2 className='max-w-3xl text-4xl font-black tracking-tight md:text-5xl'>
|
||||||
|
Keep ecommerce triggers and local review destinations cleanly separated.
|
||||||
|
</h2>
|
||||||
|
<p className='mt-4 max-w-2xl text-base text-slate-200 md:text-lg'>
|
||||||
|
Stripe, Square, PayPal, Shopify, and WooCommerce create customers and transactions from webhooks. Google, Facebook, Yelp, Angi, OpenTable, Trustpilot, and Shopify hosted reviews are treated as review destinations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='grid grid-cols-2 gap-3'>
|
||||||
|
{[
|
||||||
|
['Events', stats.paymentEvents],
|
||||||
|
['Payments', stats.transactions],
|
||||||
|
['Pending', stats.pending],
|
||||||
|
['Customers', stats.customers],
|
||||||
|
['Clicked', stats.clicked],
|
||||||
|
['Reviewed', stats.reviewed],
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
className='rounded-2xl bg-white/10 p-4 ring-1 ring-white/15 backdrop-blur'
|
||||||
|
>
|
||||||
|
<div className='text-3xl font-black'>{value}</div>
|
||||||
|
<div className='text-sm text-slate-300'>{label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentSubscription && currentUsage && currentLimits && (
|
||||||
|
<div className={`mb-6 rounded-3xl border p-5 shadow-xl ${isSubscriptionInactive ? 'border-rose-200 bg-rose-50 text-rose-950' : 'border-slate-200 bg-white text-slate-900 dark:border-dark-700 dark:bg-dark-900 dark:text-white'}`}>
|
||||||
|
<div className='grid gap-5 lg:grid-cols-[0.9fr_1.1fr] lg:items-center'>
|
||||||
|
<div>
|
||||||
|
<p className='text-sm font-black uppercase tracking-[0.25em] text-emerald-500'>
|
||||||
|
Plan and usage
|
||||||
|
</p>
|
||||||
|
<h3 className='mt-2 text-2xl font-black'>
|
||||||
|
{currentSubscription.planName} · {currentSubscription.effectiveStatus}
|
||||||
|
</h3>
|
||||||
|
<p className='mt-2 text-sm text-slate-500 dark:text-slate-400'>
|
||||||
|
{currentSubscription.trialDaysLeft !== null &&
|
||||||
|
currentSubscription.trialDaysLeft !== undefined
|
||||||
|
? `${currentSubscription.trialDaysLeft} trial days left. `
|
||||||
|
: ''}
|
||||||
|
{reviewRequestsRemaining.toLocaleString()} review requests and {businessesRemaining.toLocaleString()} business slots remaining on this plan.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-3 md:grid-cols-[1fr_auto] md:items-center'>
|
||||||
|
<div>
|
||||||
|
<div className='mb-2 flex items-center justify-between text-sm font-bold'>
|
||||||
|
<span>Monthly review requests</span>
|
||||||
|
<span>{reviewRequestsUsed.toLocaleString()} / {reviewRequestsLimit.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className='h-3 overflow-hidden rounded-full bg-slate-100 dark:bg-dark-800'>
|
||||||
|
<div
|
||||||
|
className={reviewRequestsPercent >= 80 ? 'h-full rounded-full bg-amber-500' : 'h-full rounded-full bg-emerald-500'}
|
||||||
|
style={{ width: `${reviewRequestsPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<BaseButton
|
||||||
|
href='/subscription'
|
||||||
|
icon={mdiCreditCardOutline}
|
||||||
|
label={isStarterPlan ? 'Upgrade / manage' : 'Manage plan'}
|
||||||
|
color={isStarterPlan ? 'info' : 'whiteDark'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{created && (
|
||||||
|
<div className='mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-emerald-900'>
|
||||||
|
<strong>Review request queued.</strong> {created.customer?.email} is
|
||||||
|
scheduled for {formatDate(created.scheduled_for)}.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className='mb-6 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-rose-900'>
|
||||||
|
<p>{error}</p>
|
||||||
|
{error.includes('Upgrade to Pro') && (
|
||||||
|
<BaseButton
|
||||||
|
href='/subscription'
|
||||||
|
icon={mdiCreditCardOutline}
|
||||||
|
label='Manage subscription'
|
||||||
|
color='danger'
|
||||||
|
className='mt-3'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PaymentProviderConnectors
|
||||||
|
className='mb-6'
|
||||||
|
onConnected={handleProviderConnected}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isStarterPlan && (
|
||||||
|
<CardBox className='mb-6 border-0 bg-gradient-to-br from-indigo-950 to-slate-950 text-white shadow-2xl'>
|
||||||
|
<div className='grid gap-6 lg:grid-cols-[0.8fr_1.2fr] lg:items-center'>
|
||||||
|
<div>
|
||||||
|
<p className='text-sm font-black uppercase tracking-[0.25em] text-emerald-300'>
|
||||||
|
Pro upgrade prompts
|
||||||
|
</p>
|
||||||
|
<h3 className='mt-2 text-3xl font-black'>
|
||||||
|
Unlock advanced reputation growth tools.
|
||||||
|
</h3>
|
||||||
|
<p className='mt-3 text-slate-300'>
|
||||||
|
Starter keeps the core review workflow running. Pro raises limits and unlocks the next automation, AI, and marketing modules as they are enabled.
|
||||||
|
</p>
|
||||||
|
<BaseButton
|
||||||
|
href='/subscription'
|
||||||
|
icon={mdiOpenInNew}
|
||||||
|
label='Upgrade to Pro'
|
||||||
|
color='info'
|
||||||
|
className='mt-5'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-3 md:grid-cols-3'>
|
||||||
|
{proFeaturePrompts.map(([title, copy]) => (
|
||||||
|
<div key={title} className='rounded-2xl bg-white/10 p-4 ring-1 ring-white/15'>
|
||||||
|
<p className='font-black'>{title}</p>
|
||||||
|
<p className='mt-2 text-sm leading-6 text-slate-300'>{copy}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='grid gap-6 xl:grid-cols-[0.95fr_1.05fr]'>
|
||||||
|
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
|
||||||
|
<div className='mb-6 flex items-start justify-between gap-4'>
|
||||||
|
<div>
|
||||||
|
<p className='text-sm font-bold uppercase tracking-[0.25em] text-emerald-500'>
|
||||||
|
Manual fallback
|
||||||
|
</p>
|
||||||
|
<h3 className='text-2xl font-black text-slate-900 dark:text-white'>
|
||||||
|
Queue a review request
|
||||||
|
</h3>
|
||||||
|
<p className='mt-2 text-sm text-slate-500 dark:text-slate-400'>
|
||||||
|
Use this when a payment did not come through a webhook, or
|
||||||
|
when you want to test the review queue manually.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='rounded-2xl bg-emerald-100 p-3 text-emerald-700'>
|
||||||
|
<BaseButton
|
||||||
|
icon={mdiAccountPlusOutline}
|
||||||
|
color='success'
|
||||||
|
roundedFull
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
<BaseButton
|
||||||
|
href='/review_requests/review_requests-list'
|
||||||
|
label='All'
|
||||||
|
color='whiteDark'
|
||||||
|
small
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
<BaseButton
|
||||||
|
href='/stripe_events/stripe_events-list'
|
||||||
|
icon={mdiWebhook}
|
||||||
|
label='Events'
|
||||||
|
color='whiteDark'
|
||||||
|
small
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
<BaseButton
|
||||||
|
href='/transactions/transactions-list'
|
||||||
|
icon={mdiCreditCardOutline}
|
||||||
|
label='Payments'
|
||||||
|
color='whiteDark'
|
||||||
|
small
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{recentTransactions.length === 0 ? (
|
||||||
|
<div className='rounded-2xl border border-dashed border-slate-200 p-8 text-center text-slate-500'>
|
||||||
|
No transactions created from webhooks yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='space-y-3'>
|
||||||
|
{recentTransactions.map((transaction) => (
|
||||||
|
<div
|
||||||
|
key={transaction.id}
|
||||||
|
className='rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-dark-700 dark:bg-dark-800'
|
||||||
|
>
|
||||||
|
<div className='flex items-start justify-between gap-3'>
|
||||||
|
<div>
|
||||||
|
<p className='font-black text-slate-900 dark:text-white'>
|
||||||
|
{formatAmount(
|
||||||
|
transaction.amount,
|
||||||
|
transaction.currency,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className='text-sm text-slate-500'>
|
||||||
|
{transaction.payment_provider || 'provider'} ·{' '}
|
||||||
|
{transaction.customer?.email ||
|
||||||
|
transaction.receipt_email ||
|
||||||
|
'No email'}{' '}
|
||||||
|
· {formatDate(transaction.paid_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className='rounded-full bg-slate-900 px-3 py-1 text-xs font-bold text-white dark:bg-white dark:text-slate-900'>
|
||||||
|
{transaction.currency || 'USD'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardBox>
|
||||||
|
</div>
|
||||||
|
</SectionMain>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReviewFlowWorkspace.getLayout = function getLayout(page: ReactElement) {
|
||||||
|
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||||
|
};
|
||||||
275
frontend/src/pages/subscription.tsx
Normal file
275
frontend/src/pages/subscription.tsx
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
import {
|
||||||
|
mdiArrowUpBoldCircleOutline,
|
||||||
|
mdiCheckCircleOutline,
|
||||||
|
mdiCreditCardOutline,
|
||||||
|
mdiRefresh,
|
||||||
|
} from '@mdi/js'
|
||||||
|
import axios from 'axios'
|
||||||
|
import Head from 'next/head'
|
||||||
|
import React, { ReactElement, useEffect, useState } from 'react'
|
||||||
|
import BaseButton from '../components/BaseButton'
|
||||||
|
import CardBox from '../components/CardBox'
|
||||||
|
import SectionMain from '../components/SectionMain'
|
||||||
|
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
|
||||||
|
import LayoutAuthenticated from '../layouts/Authenticated'
|
||||||
|
import { getPageTitle } from '../config'
|
||||||
|
import { SubscriptionPlan } from '../subscriptionPlans'
|
||||||
|
|
||||||
|
type SubscriptionStatusResponse = {
|
||||||
|
subscription: {
|
||||||
|
planId: string
|
||||||
|
planName: string
|
||||||
|
status: string
|
||||||
|
effectiveStatus: string
|
||||||
|
isActive: boolean
|
||||||
|
trialEndsAt?: string | null
|
||||||
|
trialDaysLeft?: number | null
|
||||||
|
priceMonthly: number
|
||||||
|
currency: string
|
||||||
|
}
|
||||||
|
usage: {
|
||||||
|
monthlyReviewRequests: number
|
||||||
|
businesses: number
|
||||||
|
teamMembers: number
|
||||||
|
paymentConnectors: number
|
||||||
|
periodStart?: string
|
||||||
|
periodEnd?: string
|
||||||
|
}
|
||||||
|
limits: SubscriptionPlan['limits']
|
||||||
|
plans: SubscriptionPlan[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const usageLabels: Array<{
|
||||||
|
key: keyof SubscriptionStatusResponse['usage']
|
||||||
|
limitKey: keyof SubscriptionPlan['limits']
|
||||||
|
label: string
|
||||||
|
}> = [
|
||||||
|
{ key: 'monthlyReviewRequests', limitKey: 'monthlyReviewRequests', label: 'Review requests this month' },
|
||||||
|
{ key: 'businesses', limitKey: 'businesses', label: 'Businesses / locations' },
|
||||||
|
{ key: 'teamMembers', limitKey: 'teamMembers', label: 'Team members' },
|
||||||
|
{ key: 'paymentConnectors', limitKey: 'paymentConnectors', label: 'Connected payment providers' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatDate(value?: string | null) {
|
||||||
|
if (!value) return 'Not set'
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('en', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
}).format(new Date(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLimit(value: number) {
|
||||||
|
return value.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubscriptionPage() {
|
||||||
|
const [status, setStatus] = useState<SubscriptionStatusResponse | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [selectingPlanId, setSelectingPlanId] = useState('')
|
||||||
|
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()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const selectPlan = async (planId: string) => {
|
||||||
|
setSelectingPlanId(planId)
|
||||||
|
setError('')
|
||||||
|
setMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/subscription/select-plan', { planId })
|
||||||
|
setStatus(response.data)
|
||||||
|
setMessage(`Your trial plan is now ${response.data.subscription.planName}.`)
|
||||||
|
} catch (requestError) {
|
||||||
|
console.error('Failed to select subscription plan:', requestError)
|
||||||
|
if (axios.isAxiosError(requestError) && requestError.response?.data) {
|
||||||
|
setError(String(requestError.response.data))
|
||||||
|
} else {
|
||||||
|
setError('Could not update your plan. Please try again.')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSelectingPlanId('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPlanId = status?.subscription.planId
|
||||||
|
|
||||||
|
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 ? (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
</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 isNearLimit = percent >= 80
|
||||||
|
|
||||||
|
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={isNearLimit ? 'font-black text-amber-600' : 'font-black text-emerald-600'}>
|
||||||
|
{formatLimit(used)} / {formatLimit(limit)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='h-3 overflow-hidden rounded-full bg-slate-100 dark:bg-dark-800'>
|
||||||
|
<div
|
||||||
|
className={isNearLimit ? 'h-full rounded-full bg-amber-500' : 'h-full rounded-full bg-emerald-500'}
|
||||||
|
style={{ width: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</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'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardBox
|
||||||
|
key={plan.id}
|
||||||
|
className={`relative overflow-hidden border-0 shadow-2xl ${isPro ? 'ring-2 ring-indigo-600' : 'ring-1 ring-slate-200 dark:ring-dark-700'}`}
|
||||||
|
cardBoxClassName='p-0'
|
||||||
|
>
|
||||||
|
{isPro && (
|
||||||
|
<div className='absolute right-6 top-6 rounded-full bg-indigo-600 px-4 py-1 text-sm font-black text-white'>
|
||||||
|
Pro growth tools
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className='p-8'>
|
||||||
|
<p className='text-sm font-black uppercase tracking-[0.3em] text-slate-400'>Review Flow</p>
|
||||||
|
<h3 className='mt-3 text-3xl font-black text-slate-900 dark:text-white'>{plan.name}</h3>
|
||||||
|
<p className='mt-3 min-h-[56px] leading-7 text-slate-500 dark:text-slate-400'>{plan.tagline}</p>
|
||||||
|
<div className='mt-8 flex items-end gap-2'>
|
||||||
|
<span className='text-5xl font-black tracking-tight text-slate-900 dark:text-white'>${plan.priceMonthly}</span>
|
||||||
|
<span className='pb-2 font-bold text-slate-500'>/month</span>
|
||||||
|
</div>
|
||||||
|
<div className='mt-8 grid gap-3 rounded-3xl bg-slate-50 p-5 dark:bg-dark-800 sm:grid-cols-2'>
|
||||||
|
<div>
|
||||||
|
<p className='text-2xl font-black'>{formatLimit(plan.limits.monthlyReviewRequests)}</p>
|
||||||
|
<p className='text-sm text-slate-500'>requests/month</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className='text-2xl font-black'>{formatLimit(plan.limits.businesses)}</p>
|
||||||
|
<p className='text-sm text-slate-500'>businesses</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className='text-2xl font-black'>{formatLimit(plan.limits.teamMembers)}</p>
|
||||||
|
<p className='text-sm text-slate-500'>team members</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className='text-2xl font-black'>{formatLimit(plan.limits.paymentConnectors)}</p>
|
||||||
|
<p className='text-sm text-slate-500'>connectors</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<BaseButton
|
||||||
|
icon={isCurrent ? mdiCheckCircleOutline : mdiArrowUpBoldCircleOutline}
|
||||||
|
label={isCurrent ? 'Current plan' : `Switch trial to ${plan.name}`}
|
||||||
|
color={isCurrent ? 'success' : isPro ? 'info' : 'whiteDark'}
|
||||||
|
className='mt-8 w-full'
|
||||||
|
disabled={isCurrent || Boolean(selectingPlanId)}
|
||||||
|
onClick={() => selectPlan(plan.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={isPro ? 'bg-indigo-950 p-8 text-white' : 'bg-slate-950 p-8 text-white'}>
|
||||||
|
<p className='mb-5 text-sm font-black uppercase tracking-[0.25em] text-emerald-300'>Included</p>
|
||||||
|
<div className='grid gap-3'>
|
||||||
|
{plan.features.map((feature) => (
|
||||||
|
<div key={feature} className='flex items-start gap-3'>
|
||||||
|
<span className='mt-1 text-emerald-300'>✓</span>
|
||||||
|
<span className='font-semibold text-slate-100'>{feature}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</SectionMain>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SubscriptionPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
|
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
|
||||||
|
}
|
||||||
@ -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 = 'ReviewFlow';
|
const title = 'Review Flow';
|
||||||
const [projectUrl, setProjectUrl] = useState('');
|
const [projectUrl, setProjectUrl] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
79
frontend/src/subscriptionPlans.ts
Normal file
79
frontend/src/subscriptionPlans.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
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 businesses that want automated review collection up and running quickly.',
|
||||||
|
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 and transaction management',
|
||||||
|
'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 teams that want advanced 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