Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dfd47bae8 | ||
|
|
df9c6cb725 | ||
|
|
e4186ae090 |
@ -1,7 +1,5 @@
|
||||
|
||||
const db = require('../models');
|
||||
const FileDBApi = require('./file');
|
||||
const crypto = require('crypto');
|
||||
const Utils = require('../utils');
|
||||
|
||||
|
||||
@ -382,15 +380,12 @@ module.exports = class Review_requestsDBApi {
|
||||
|
||||
offset = currentPage * limit;
|
||||
|
||||
const orderBy = null;
|
||||
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
let include = [
|
||||
|
||||
{
|
||||
model: db.businesses,
|
||||
as: 'business',
|
||||
required: Boolean(filter.business),
|
||||
|
||||
where: filter.business ? {
|
||||
[Op.or]: [
|
||||
@ -408,6 +403,7 @@ module.exports = class Review_requestsDBApi {
|
||||
{
|
||||
model: db.customers,
|
||||
as: 'customer',
|
||||
required: Boolean(filter.customer),
|
||||
|
||||
where: filter.customer ? {
|
||||
[Op.or]: [
|
||||
@ -425,6 +421,7 @@ module.exports = class Review_requestsDBApi {
|
||||
{
|
||||
model: db.transactions,
|
||||
as: 'transaction',
|
||||
required: Boolean(filter.transaction),
|
||||
|
||||
where: filter.transaction ? {
|
||||
[Op.or]: [
|
||||
|
||||
@ -793,6 +793,13 @@ module.exports = class UsersDBApi {
|
||||
firstName: data.firstName,
|
||||
authenticationUid: data.authenticationUid,
|
||||
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 },
|
||||
|
||||
@ -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) {
|
||||
const businesses = sequelize.define(
|
||||
'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: {
|
||||
@ -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: {
|
||||
@ -176,6 +340,14 @@ custom_review_link: {
|
||||
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) {
|
||||
const customers = sequelize.define(
|
||||
'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: {
|
||||
|
||||
@ -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) {
|
||||
const review_requests = sequelize.define(
|
||||
'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: {
|
||||
|
||||
@ -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) {
|
||||
const stripe_events = sequelize.define(
|
||||
'stripe_events',
|
||||
@ -19,6 +13,20 @@ stripe_event_reference: {
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
provider: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
provider_event_type: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
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) {
|
||||
const transactions = sequelize.define(
|
||||
'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: {
|
||||
@ -131,6 +168,14 @@ receipt_email: {
|
||||
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: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
|
||||
@ -6,7 +6,6 @@ const passport = require('passport');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const bodyParser = require('body-parser');
|
||||
const db = require('./db/models');
|
||||
const config = require('./config');
|
||||
const swaggerUI = require('swagger-ui-express');
|
||||
const swaggerJsDoc = require('swagger-jsdoc');
|
||||
@ -16,6 +15,8 @@ const fileRoutes = require('./routes/file');
|
||||
const searchRoutes = require('./routes/search');
|
||||
const sqlRoutes = require('./routes/sql');
|
||||
const pexelsRoutes = require('./routes/pexels');
|
||||
const plansRoutes = require('./routes/plans');
|
||||
const subscriptionRoutes = require('./routes/subscription');
|
||||
|
||||
const openaiRoutes = require('./routes/openai');
|
||||
|
||||
@ -35,6 +36,10 @@ const transactionsRoutes = require('./routes/transactions');
|
||||
|
||||
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 email_delivery_logsRoutes = require('./routes/email_delivery_logs');
|
||||
@ -96,6 +101,8 @@ app.use(bodyParser.json());
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/file', fileRoutes);
|
||||
app.use('/api/pexels', pexelsRoutes);
|
||||
app.use('/api/plans', plansRoutes);
|
||||
app.use('/api/subscription', passport.authenticate('jwt', {session: false}), subscriptionRoutes);
|
||||
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/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/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 db = require('../db/models');
|
||||
const ValidationError = require('./notifications/errors/validation');
|
||||
const ForbiddenError = require('./notifications/errors/forbidden');
|
||||
const bcrypt = require('bcrypt');
|
||||
@ -8,6 +9,7 @@ const PasswordResetEmail = require('./email/list/passwordReset');
|
||||
const EmailSender = require('./email');
|
||||
const config = require('../config');
|
||||
const helpers = require('../helpers');
|
||||
const SubscriptionService = require('./subscription');
|
||||
|
||||
class Auth {
|
||||
static async signup(email, password, options = {}, host) {
|
||||
@ -54,11 +56,16 @@ class Auth {
|
||||
return helpers.jwtSign(data);
|
||||
}
|
||||
|
||||
const subscriptionPayload = SubscriptionService.getSignupSubscriptionPayload(
|
||||
options?.body?.planId || options?.body?.plan,
|
||||
);
|
||||
|
||||
const newUser = await UsersDBApi.createFromAuth(
|
||||
{
|
||||
firstName: email.split('@')[0],
|
||||
password: hashedPassword,
|
||||
email: email,
|
||||
...subscriptionPayload,
|
||||
|
||||
},
|
||||
options,
|
||||
|
||||
@ -6,6 +6,7 @@ const csv = require('csv-parser');
|
||||
const axios = require('axios');
|
||||
const config = require('../config');
|
||||
const stream = require('stream');
|
||||
const SubscriptionService = require('./subscription');
|
||||
|
||||
|
||||
|
||||
@ -15,6 +16,8 @@ module.exports = class BusinessesService {
|
||||
static async create(data, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
await SubscriptionService.assertCanCreateBusinesses(currentUser, 1, { transaction });
|
||||
|
||||
await BusinessesDBApi.create(
|
||||
data,
|
||||
{
|
||||
@ -51,6 +54,8 @@ module.exports = class BusinessesService {
|
||||
.on('error', (error) => reject(error));
|
||||
})
|
||||
|
||||
await SubscriptionService.assertCanCreateBusinesses(req.currentUser, results.length, { transaction });
|
||||
|
||||
await BusinessesDBApi.bulkImport(results, {
|
||||
transaction,
|
||||
ignoreDuplicates: true,
|
||||
|
||||
@ -6,6 +6,7 @@ const csv = require('csv-parser');
|
||||
const axios = require('axios');
|
||||
const config = require('../config');
|
||||
const stream = require('stream');
|
||||
const SubscriptionService = require('./subscription');
|
||||
|
||||
|
||||
|
||||
@ -15,6 +16,8 @@ module.exports = class Review_requestsService {
|
||||
static async create(data, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
await SubscriptionService.assertCanCreateReviewRequests(currentUser, 1, { transaction });
|
||||
|
||||
await Review_requestsDBApi.create(
|
||||
data,
|
||||
{
|
||||
@ -51,6 +54,8 @@ module.exports = class Review_requestsService {
|
||||
.on('error', (error) => reject(error));
|
||||
})
|
||||
|
||||
await SubscriptionService.assertCanCreateReviewRequests(req.currentUser, results.length, { transaction });
|
||||
|
||||
await Review_requestsDBApi.bulkImport(results, {
|
||||
transaction,
|
||||
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">
|
||||
|
||||
<b className="font-black">ReviewFlow</b>
|
||||
<b className="font-black">Review Flow</b>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
@ -152,7 +152,7 @@ export const loadColumns = async (
|
||||
|
||||
type: 'dateTime',
|
||||
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 { useState } from 'react'
|
||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||
import BaseDivider from './BaseDivider'
|
||||
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',
|
||||
headerName: 'StripeEventReference',
|
||||
headerName: 'EventReference',
|
||||
flex: 1,
|
||||
minWidth: 120,
|
||||
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',
|
||||
headerName: 'StripePaymentReference',
|
||||
headerName: 'PaymentReference',
|
||||
flex: 1,
|
||||
minWidth: 120,
|
||||
filterable: false,
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import React, { ReactNode, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import React, { ReactNode, useEffect, useState } from 'react'
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||
import menuAside from '../menuAside'
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import * as icon from '@mdi/js';
|
||||
import { MenuAsideItem } from './interfaces'
|
||||
import { MenuAsideItem } from './interfaces';
|
||||
|
||||
const menuAside: MenuAsideItem[] = [
|
||||
{
|
||||
@ -7,14 +7,26 @@ const menuAside: MenuAsideItem[] = [
|
||||
icon: icon.mdiViewDashboardOutline,
|
||||
label: 'Dashboard',
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
href: '/reviewflow',
|
||||
icon: icon.mdiStarOutline,
|
||||
label: 'Review Flow',
|
||||
},
|
||||
|
||||
{
|
||||
href: '/subscription',
|
||||
icon: icon.mdiCreditCardOutline,
|
||||
label: 'Subscription',
|
||||
},
|
||||
|
||||
{
|
||||
href: '/users/users-list',
|
||||
label: 'Users',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: icon.mdiAccountGroup ?? icon.mdiTable,
|
||||
permissions: 'READ_USERS'
|
||||
permissions: 'READ_USERS',
|
||||
},
|
||||
{
|
||||
href: '/roles/roles-list',
|
||||
@ -22,7 +34,7 @@ const menuAside: MenuAsideItem[] = [
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable,
|
||||
permissions: 'READ_ROLES'
|
||||
permissions: 'READ_ROLES',
|
||||
},
|
||||
{
|
||||
href: '/permissions/permissions-list',
|
||||
@ -30,63 +42,84 @@ const menuAside: MenuAsideItem[] = [
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: icon.mdiShieldAccountOutline ?? icon.mdiTable,
|
||||
permissions: 'READ_PERMISSIONS'
|
||||
permissions: 'READ_PERMISSIONS',
|
||||
},
|
||||
{
|
||||
href: '/businesses/businesses-list',
|
||||
label: 'Businesses',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiStore' in icon ? icon['mdiStore' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_BUSINESSES'
|
||||
icon:
|
||||
'mdiStore' in icon
|
||||
? icon['mdiStore' as keyof typeof icon]
|
||||
: (icon.mdiTable ?? icon.mdiTable),
|
||||
permissions: 'READ_BUSINESSES',
|
||||
},
|
||||
{
|
||||
href: '/customers/customers-list',
|
||||
label: 'Customers',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiAccountMultiple' in icon ? icon['mdiAccountMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_CUSTOMERS'
|
||||
icon:
|
||||
'mdiAccountMultiple' in icon
|
||||
? icon['mdiAccountMultiple' as keyof typeof icon]
|
||||
: (icon.mdiTable ?? icon.mdiTable),
|
||||
permissions: 'READ_CUSTOMERS',
|
||||
},
|
||||
{
|
||||
href: '/transactions/transactions-list',
|
||||
label: 'Transactions',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiCreditCardOutline' in icon ? icon['mdiCreditCardOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_TRANSACTIONS'
|
||||
icon:
|
||||
'mdiCreditCardOutline' in icon
|
||||
? icon['mdiCreditCardOutline' as keyof typeof icon]
|
||||
: (icon.mdiTable ?? icon.mdiTable),
|
||||
permissions: 'READ_TRANSACTIONS',
|
||||
},
|
||||
{
|
||||
href: '/review_requests/review_requests-list',
|
||||
label: 'Review requests',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiEmailFastOutline' in icon ? icon['mdiEmailFastOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_REVIEW_REQUESTS'
|
||||
icon:
|
||||
'mdiEmailFastOutline' in icon
|
||||
? icon['mdiEmailFastOutline' as keyof typeof icon]
|
||||
: (icon.mdiTable ?? icon.mdiTable),
|
||||
permissions: 'READ_REVIEW_REQUESTS',
|
||||
},
|
||||
{
|
||||
href: '/stripe_events/stripe_events-list',
|
||||
label: 'Stripe events',
|
||||
label: 'Payment events',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiWebhook' in icon ? icon['mdiWebhook' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_STRIPE_EVENTS'
|
||||
icon:
|
||||
'mdiWebhook' in icon
|
||||
? icon['mdiWebhook' as keyof typeof icon]
|
||||
: (icon.mdiTable ?? icon.mdiTable),
|
||||
permissions: 'READ_STRIPE_EVENTS',
|
||||
},
|
||||
{
|
||||
href: '/email_delivery_logs/email_delivery_logs-list',
|
||||
label: 'Email delivery logs',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiEmailCheckOutline' in icon ? icon['mdiEmailCheckOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_EMAIL_DELIVERY_LOGS'
|
||||
icon:
|
||||
'mdiEmailCheckOutline' in icon
|
||||
? icon['mdiEmailCheckOutline' as keyof typeof icon]
|
||||
: (icon.mdiTable ?? icon.mdiTable),
|
||||
permissions: 'READ_EMAIL_DELIVERY_LOGS',
|
||||
},
|
||||
{
|
||||
href: '/cron_runs/cron_runs-list',
|
||||
label: 'Cron runs',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiClockOutline' in icon ? icon['mdiClockOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_CRON_RUNS'
|
||||
icon:
|
||||
'mdiClockOutline' in icon
|
||||
? icon['mdiClockOutline' as keyof typeof icon]
|
||||
: (icon.mdiTable ?? icon.mdiTable),
|
||||
permissions: 'READ_CRON_RUNS',
|
||||
},
|
||||
{
|
||||
href: '/profile',
|
||||
@ -94,14 +127,13 @@ const menuAside: MenuAsideItem[] = [
|
||||
icon: icon.mdiAccountCircle,
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
href: '/api-docs',
|
||||
target: '_blank',
|
||||
label: 'Swagger API',
|
||||
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);
|
||||
};
|
||||
|
||||
const title = 'ReviewFlow'
|
||||
const title = 'Review Flow'
|
||||
const description = "Automate review-request emails after Stripe payments with templates, scheduling, and dashboard analytics."
|
||||
const url = "https://flatlogic.com/"
|
||||
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 React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { mdiArrowRight, mdiCheckCircleOutline, mdiLogin, mdiShieldCheckOutline, mdiStarCircleOutline } from '@mdi/js';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import React, { ReactElement } from 'react';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import CardBox from '../components/CardBox';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import { getPageTitle } from '../config';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
||||
import { subscriptionPlans, trialDays } from '../subscriptionPlans';
|
||||
|
||||
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() {
|
||||
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 (
|
||||
<div
|
||||
style={
|
||||
contentPosition === 'background'
|
||||
? {
|
||||
backgroundImage: `${
|
||||
illustrationImage
|
||||
? `url(${illustrationImage.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<div className="min-h-screen bg-[#F7F8FC] text-slate-950">
|
||||
<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>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div
|
||||
className={`flex ${
|
||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||
} min-h-screen w-full`}
|
||||
>
|
||||
{contentType === 'image' && contentPosition !== 'background'
|
||||
? imageBlock(illustrationImage)
|
||||
: null}
|
||||
{contentType === 'video' && contentPosition !== 'background'
|
||||
? videoBlock(illustrationVideo)
|
||||
: null}
|
||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||
<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>
|
||||
<header className="sticky top-0 z-20 border-b border-white/70 bg-white/80 backdrop-blur-xl">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
|
||||
<Link href="/" className="flex items-center gap-3 font-black tracking-tight">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#101828] text-white shadow-lg shadow-indigo-950/20">
|
||||
★
|
||||
</span>
|
||||
<span className="text-xl">Review Flow</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-3">
|
||||
<BaseButton href="/#pricing" label="Pricing" color="whiteDark" />
|
||||
<BaseButton href="/login" icon={mdiLogin} label="Login" color="whiteDark" />
|
||||
<BaseButton href="/reviewflow" icon={mdiArrowRight} label="Admin interface" color="info" />
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section className="relative isolate overflow-hidden px-6 py-20 md:py-28">
|
||||
<div className="absolute left-1/2 top-0 -z-10 h-[640px] w-[920px] -translate-x-1/2 rounded-full bg-[radial-gradient(circle_at_center,#7C3AED_0%,#10B981_38%,transparent_68%)] opacity-20 blur-3xl" />
|
||||
<div className="mx-auto grid max-w-7xl gap-12 lg:grid-cols-[1.05fr_0.95fr] lg:items-center">
|
||||
<div>
|
||||
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm font-bold text-emerald-700">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-500" />
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -163,4 +262,3 @@ export default function Starter() {
|
||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
|
||||
@ -20,7 +20,7 @@ import { findMe, loginUser, resetAction } from '../stores/authSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
import Link from 'next/link';
|
||||
import {toast, ToastContainer} from "react-toastify";
|
||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
|
||||
import { getPexelsImage } from '../helpers/pexels'
|
||||
|
||||
export default function Login() {
|
||||
const router = useRouter();
|
||||
@ -33,9 +33,7 @@ export default function Login() {
|
||||
photographer: undefined,
|
||||
photographer_url: undefined,
|
||||
})
|
||||
const [ illustrationVideo, setIllustrationVideo ] = useState({video_files: []})
|
||||
const [contentType, setContentType] = useState('video');
|
||||
const [contentPosition, setContentPosition] = useState('left');
|
||||
const [contentPosition] = useState<'left' | 'right' | 'background'>('left');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
|
||||
(state) => state.auth,
|
||||
@ -44,15 +42,90 @@ export default function Login() {
|
||||
password: 'fc6e39e3',
|
||||
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( () => {
|
||||
async function fetchData() {
|
||||
const image = await getPexelsImage()
|
||||
const video = await getPexelsVideo()
|
||||
setIllustrationImage(image);
|
||||
setIllustrationVideo(video);
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
@ -115,32 +188,7 @@ export default function Login() {
|
||||
</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 (
|
||||
<div style={contentPosition === 'background' ? {
|
||||
@ -159,8 +207,7 @@ export default function Login() {
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div className={`flex ${contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'} min-h-screen w-full`}>
|
||||
{contentType === 'image' && contentPosition !== 'background' ? imageBlock(illustrationImage) : null}
|
||||
{contentType === 'video' && contentPosition !== 'background' ? videoBlock(illustrationVideo) : null}
|
||||
{contentPosition !== 'background' ? imageBlock(illustrationImage) : null}
|
||||
<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'>
|
||||
@ -257,6 +304,95 @@ export default function Login() {
|
||||
</Form>
|
||||
</Formik>
|
||||
</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>
|
||||
</SectionFullScreen>
|
||||
|
||||
@ -5,7 +5,7 @@ import LayoutGuest from '../layouts/Guest';
|
||||
import { getPageTitle } from '../config';
|
||||
|
||||
export default function PrivacyPolicy() {
|
||||
const title = 'ReviewFlow'
|
||||
const title = 'Review Flow'
|
||||
const [projectUrl, setProjectUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -12,12 +12,15 @@ import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import { useRouter } from 'next/router';
|
||||
import { getPageTitle } from '../config';
|
||||
import { subscriptionPlans } from '../subscriptionPlans';
|
||||
|
||||
import axios from "axios";
|
||||
|
||||
export default function Register() {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
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"});
|
||||
|
||||
|
||||
@ -25,7 +28,7 @@ export default function Register() {
|
||||
setLoading(true)
|
||||
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')
|
||||
setLoading(false)
|
||||
notify('success', 'Please check your email for verification link')
|
||||
@ -44,6 +47,10 @@ export default function Register() {
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<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
|
||||
initialValues={{
|
||||
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';
|
||||
|
||||
export default function PrivacyPolicy() {
|
||||
const title = 'ReviewFlow';
|
||||
const title = 'Review Flow';
|
||||
const [projectUrl, setProjectUrl] = useState('');
|
||||
|
||||
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