Compare commits

...

3 Commits

Author SHA1 Message Date
Flatlogic Bot
3dfd47bae8 Autosave: 20260629-071644 2026-06-29 07:16:39 +00:00
Flatlogic Bot
df9c6cb725 Autosave: 20260629-060213 2026-06-29 06:02:08 +00:00
Flatlogic Bot
e4186ae090 Autosave: 20260629-035900 2026-06-29 03:58:56 +00:00
43 changed files with 6330 additions and 253 deletions

View File

@ -1,7 +1,5 @@
const db = require('../models'); const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils'); const Utils = require('../utils');
@ -382,15 +380,12 @@ module.exports = class Review_requestsDBApi {
offset = currentPage * limit; offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
let include = [ let include = [
{ {
model: db.businesses, model: db.businesses,
as: 'business', as: 'business',
required: Boolean(filter.business),
where: filter.business ? { where: filter.business ? {
[Op.or]: [ [Op.or]: [
@ -408,6 +403,7 @@ module.exports = class Review_requestsDBApi {
{ {
model: db.customers, model: db.customers,
as: 'customer', as: 'customer',
required: Boolean(filter.customer),
where: filter.customer ? { where: filter.customer ? {
[Op.or]: [ [Op.or]: [
@ -425,6 +421,7 @@ module.exports = class Review_requestsDBApi {
{ {
model: db.transactions, model: db.transactions,
as: 'transaction', as: 'transaction',
required: Boolean(filter.transaction),
where: filter.transaction ? { where: filter.transaction ? {
[Op.or]: [ [Op.or]: [

View File

@ -793,6 +793,13 @@ module.exports = class UsersDBApi {
firstName: data.firstName, firstName: data.firstName,
authenticationUid: data.authenticationUid, authenticationUid: data.authenticationUid,
password: data.password, password: data.password,
subscriptionPlanId: data.subscriptionPlanId || 'starter',
subscriptionStatus: data.subscriptionStatus || 'trialing',
trialStartedAt: data.trialStartedAt || null,
trialEndsAt: data.trialEndsAt || null,
subscriptionStartedAt: data.subscriptionStartedAt || null,
subscriptionEndsAt: data.subscriptionEndsAt || null,
subscriptionCanceledAt: data.subscriptionCanceledAt || null,
}, },
{ transaction }, { transaction },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -104,6 +104,47 @@ provider: {
}, },
subscriptionPlanId: {
type: DataTypes.TEXT,
allowNull: false,
defaultValue: 'starter',
},
subscriptionStatus: {
type: DataTypes.TEXT,
allowNull: false,
defaultValue: 'trialing',
},
trialStartedAt: {
type: DataTypes.DATE,
},
trialEndsAt: {
type: DataTypes.DATE,
},
subscriptionStartedAt: {
type: DataTypes.DATE,
},
subscriptionEndsAt: {
type: DataTypes.DATE,
},
subscriptionCanceledAt: {
type: DataTypes.DATE,
},
importHash: { importHash: {
type: DataTypes.STRING(255), type: DataTypes.STRING(255),
allowNull: true, allowNull: true,

View File

@ -6,7 +6,6 @@ const passport = require('passport');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const bodyParser = require('body-parser'); const bodyParser = require('body-parser');
const db = require('./db/models');
const config = require('./config'); const config = require('./config');
const swaggerUI = require('swagger-ui-express'); const swaggerUI = require('swagger-ui-express');
const swaggerJsDoc = require('swagger-jsdoc'); const swaggerJsDoc = require('swagger-jsdoc');
@ -16,6 +15,8 @@ const fileRoutes = require('./routes/file');
const searchRoutes = require('./routes/search'); const searchRoutes = require('./routes/search');
const sqlRoutes = require('./routes/sql'); const sqlRoutes = require('./routes/sql');
const pexelsRoutes = require('./routes/pexels'); const pexelsRoutes = require('./routes/pexels');
const plansRoutes = require('./routes/plans');
const subscriptionRoutes = require('./routes/subscription');
const openaiRoutes = require('./routes/openai'); const openaiRoutes = require('./routes/openai');
@ -35,6 +36,10 @@ const transactionsRoutes = require('./routes/transactions');
const review_requestsRoutes = require('./routes/review_requests'); const review_requestsRoutes = require('./routes/review_requests');
const reviewflowRoutes = require('./routes/reviewflow');
const reviewflowWebhooksRoutes = require('./routes/reviewflow-webhooks');
const reviewflowPublicRoutes = require('./routes/reviewflow-public');
const stripe_eventsRoutes = require('./routes/stripe_events'); const stripe_eventsRoutes = require('./routes/stripe_events');
const email_delivery_logsRoutes = require('./routes/email_delivery_logs'); const email_delivery_logsRoutes = require('./routes/email_delivery_logs');
@ -96,6 +101,8 @@ app.use(bodyParser.json());
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
app.use('/api/file', fileRoutes); app.use('/api/file', fileRoutes);
app.use('/api/pexels', pexelsRoutes); app.use('/api/pexels', pexelsRoutes);
app.use('/api/plans', plansRoutes);
app.use('/api/subscription', passport.authenticate('jwt', {session: false}), subscriptionRoutes);
app.enable('trust proxy'); app.enable('trust proxy');
@ -113,6 +120,12 @@ app.use('/api/transactions', passport.authenticate('jwt', {session: false}), tra
app.use('/api/review_requests', passport.authenticate('jwt', {session: false}), review_requestsRoutes); app.use('/api/review_requests', passport.authenticate('jwt', {session: false}), review_requestsRoutes);
app.use('/api/reviewflow', passport.authenticate('jwt', {session: false}), reviewflowRoutes);
app.use('/api/reviewflow-webhooks', reviewflowWebhooksRoutes);
app.use('/api/reviewflow-public', reviewflowPublicRoutes);
app.use('/api/stripe_events', passport.authenticate('jwt', {session: false}), stripe_eventsRoutes); app.use('/api/stripe_events', passport.authenticate('jwt', {session: false}), stripe_eventsRoutes);
app.use('/api/email_delivery_logs', passport.authenticate('jwt', {session: false}), email_delivery_logsRoutes); app.use('/api/email_delivery_logs', passport.authenticate('jwt', {session: false}), email_delivery_logsRoutes);

View File

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

View File

@ -0,0 +1,24 @@
const express = require('express');
const ReviewFlowService = require('../services/reviewflow');
const wrapAsync = require('../helpers').wrapAsync;
const router = express.Router();
router.get('/reviews/:trackingToken', wrapAsync(async (req, res) => {
const review = await ReviewFlowService.getHostedReviewRequest(req.params.trackingToken);
res.status(200).send({ review });
}));
router.post('/reviews/:trackingToken', wrapAsync(async (req, res) => {
const review = await ReviewFlowService.submitHostedReview(
req.params.trackingToken,
req.body || {},
);
res.status(200).send({ review });
}));
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;

View File

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

View File

@ -0,0 +1,302 @@
const express = require('express');
const crypto = require('crypto');
const db = require('../db/models');
const ReviewFlowService = require('../services/reviewflow');
const SubscriptionService = require('../services/subscription');
const wrapAsync = require('../helpers').wrapAsync;
const router = express.Router();
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function normalizeString(value) {
return typeof value === 'string' ? value.trim() : '';
}
function requireField(value, message) {
if (!normalizeString(value)) {
const error = new Error(message);
error.code = 400;
throw error;
}
}
function validateUrl(value, message) {
try {
const parsed = new URL(value);
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error(message);
}
} catch {
const validationError = new Error(message);
validationError.code = 400;
throw validationError;
}
}
const REVIEW_LINK_FIELDS = {
google: 'google_review_link',
yelp: 'yelp_review_link',
facebook: 'facebook_review_link',
trustpilot: 'trustpilot_review_link',
angi: 'angi_review_link',
opentable: 'opentable_review_link',
custom: 'custom_review_link',
};
function normalizeReviewDestination(value) {
const destination = normalizeString(value).toLowerCase();
if (ReviewFlowService.REVIEW_CHANNELS[destination]) {
return destination;
}
return 'google';
}
function getReviewLinkField(reviewDestination) {
return REVIEW_LINK_FIELDS[reviewDestination] || null;
}
function buildEmailBody(customerName, businessName, reviewLink) {
const greetingName = customerName || 'there';
return [
`Hi ${greetingName},`,
'',
`Thank you for choosing ${businessName}. We would love to hear about your experience.`,
'',
`Leave a review: ${reviewLink}`,
'',
`Thank you,`,
businessName,
].join('\n');
}
router.get('/review-channels', wrapAsync(async (req, res) => {
res.status(200).send({ channels: ReviewFlowService.serializeReviewChannels() });
}));
router.get('/connectors', wrapAsync(async (req, res) => {
const businesses = await ReviewFlowService.listConnectorBusinesses(req.currentUser, req);
res.status(200).send({ businesses });
}));
router.post('/connectors', wrapAsync(async (req, res) => {
const business = await ReviewFlowService.connectProvider(req.currentUser, req.body || {}, req);
res.status(200).send({ business });
}));
router.post('/connectors/:businessId/:provider/rotate', wrapAsync(async (req, res) => {
const business = await ReviewFlowService.rotateWebhookToken(
req.currentUser,
req.params.businessId,
req.params.provider,
req,
);
res.status(200).send({ business });
}));
router.get('/summary', wrapAsync(async (req, res) => {
const currentUser = req.currentUser;
const limit = Math.min(Number(req.query.limit) || 8, 25);
const requests = await db.review_requests.findAll({
where: { createdById: currentUser.id },
include: [
{ model: db.businesses, as: 'business' },
{ model: db.customers, as: 'customer' },
{ model: db.transactions, as: 'transaction' },
],
order: [['createdAt', 'DESC']],
limit,
});
const [
pending,
sent,
clicked,
reviewed,
customers,
transactions,
paymentEvents,
recentTransactions,
recentEvents,
] = await Promise.all([
db.review_requests.count({ where: { createdById: currentUser.id, status: 'pending' } }),
db.review_requests.count({ where: { createdById: currentUser.id, status: 'sent' } }),
db.review_requests.count({ where: { createdById: currentUser.id, status: 'clicked' } }),
db.review_requests.count({ where: { createdById: currentUser.id, status: 'reviewed' } }),
db.customers.count({ where: { createdById: currentUser.id } }),
db.transactions.count({ where: { createdById: currentUser.id } }),
db.stripe_events.count({ where: { createdById: currentUser.id } }),
db.transactions.findAll({
where: { createdById: currentUser.id },
include: [
{ model: db.businesses, as: 'business' },
{ model: db.customers, as: 'customer' },
],
order: [['createdAt', 'DESC']],
limit: 6,
}),
db.stripe_events.findAll({
where: { createdById: currentUser.id },
include: [
{ model: db.businesses, as: 'business' },
],
order: [['createdAt', 'DESC']],
limit: 6,
}),
]);
res.status(200).send({
stats: { pending, sent, clicked, reviewed, customers, transactions, paymentEvents },
requests,
recentTransactions,
recentEvents,
});
}));
router.post('/request', wrapAsync(async (req, res) => {
const currentUser = req.currentUser;
const body = req.body || {};
const businessName = normalizeString(body.businessName);
const reviewLink = normalizeString(body.reviewLink);
const reviewDestination = normalizeReviewDestination(body.reviewDestination || body.reviewPlatform || 'google');
const isHostedReviewDestination = reviewDestination === 'shopify_hosted';
const reviewLinkField = getReviewLinkField(reviewDestination);
const customerEmail = normalizeString(body.customerEmail).toLowerCase();
const customerName = normalizeString(body.customerName);
const phone = normalizeString(body.phone);
const delayDays = Math.max(0, Math.min(Number(body.delayDays) || 0, 30));
requireField(businessName, 'Business name is required.');
if (!isHostedReviewDestination) {
requireField(reviewLink, 'Review link is required.');
}
requireField(customerEmail, 'Customer email is required.');
if (!EMAIL_PATTERN.test(customerEmail)) {
const error = new Error('Enter a valid customer email address.');
error.code = 400;
throw error;
}
if (reviewLink) {
validateUrl(reviewLink, 'Enter a valid review destination URL.');
}
await SubscriptionService.assertCanCreateReviewRequests(currentUser, 1);
const existingBusiness = await db.businesses.findOne({
where: { name: businessName, createdById: currentUser.id },
});
if (!existingBusiness) {
await SubscriptionService.assertCanCreateBusinesses(currentUser, 1);
}
const scheduledFor = new Date(Date.now() + delayDays * 24 * 60 * 60 * 1000);
const trackingToken = crypto.randomBytes(18).toString('hex');
const effectiveReviewLink = isHostedReviewDestination
? ReviewFlowService.getHostedReviewUrl(req, trackingToken)
: reviewLink;
const transaction = await db.sequelize.transaction();
try {
const businessDefaults = {
name: businessName,
review_destination: reviewDestination,
shopify_hosted_reviews_enabled: isHostedReviewDestination,
delay_days: delayDays,
email_subject_template: `How was your experience with ${businessName}?`,
email_body_template: buildEmailBody('{customerName}', businessName, '{reviewLink}'),
is_active: true,
createdById: currentUser.id,
updatedById: currentUser.id,
};
if (reviewLink && reviewLinkField) {
businessDefaults[reviewLinkField] = reviewLink;
}
const [business] = await db.businesses.findOrCreate({
where: { name: businessName, createdById: currentUser.id },
defaults: businessDefaults,
transaction,
});
const businessUpdates = {
review_destination: reviewDestination,
shopify_hosted_reviews_enabled: business.shopify_hosted_reviews_enabled || isHostedReviewDestination,
delay_days: delayDays,
is_active: true,
updatedById: currentUser.id,
};
if (reviewLink && reviewLinkField) {
businessUpdates[reviewLinkField] = reviewLink;
}
await business.update(businessUpdates, { transaction });
const [customer] = await db.customers.findOrCreate({
where: { email: customerEmail, createdById: currentUser.id },
defaults: {
email: customerEmail,
name: customerName || null,
phone: phone || null,
contact_status: 'active',
businessId: business.id,
createdById: currentUser.id,
updatedById: currentUser.id,
},
transaction,
});
await customer.update({
name: customerName || customer.name,
phone: phone || customer.phone,
contact_status: customer.contact_status || 'active',
businessId: business.id,
updatedById: currentUser.id,
}, { transaction });
const emailSubject = `How was your experience with ${businessName}?`;
const reviewRequest = await db.review_requests.create({
status: 'pending',
scheduled_for: scheduledFor,
email_subject: emailSubject,
email_body: buildEmailBody(customerName, businessName, effectiveReviewLink),
review_link: effectiveReviewLink,
tracking_token: trackingToken,
review_platform: reviewDestination,
businessId: business.id,
customerId: customer.id,
createdById: currentUser.id,
updatedById: currentUser.id,
}, { transaction });
await transaction.commit();
const createdRequest = await db.review_requests.findByPk(reviewRequest.id, {
include: [
{ model: db.businesses, as: 'business' },
{ model: db.customers, as: 'customer' },
],
});
res.status(201).send({ request: createdRequest });
} catch (error) {
await transaction.rollback();
throw error;
}
}));
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;

View File

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

View File

@ -1,4 +1,5 @@
const UsersDBApi = require('../db/api/users'); const UsersDBApi = require('../db/api/users');
const db = require('../db/models');
const ValidationError = require('./notifications/errors/validation'); const ValidationError = require('./notifications/errors/validation');
const ForbiddenError = require('./notifications/errors/forbidden'); const ForbiddenError = require('./notifications/errors/forbidden');
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
@ -8,6 +9,7 @@ const PasswordResetEmail = require('./email/list/passwordReset');
const EmailSender = require('./email'); const EmailSender = require('./email');
const config = require('../config'); const config = require('../config');
const helpers = require('../helpers'); const helpers = require('../helpers');
const SubscriptionService = require('./subscription');
class Auth { class Auth {
static async signup(email, password, options = {}, host) { static async signup(email, password, options = {}, host) {
@ -54,11 +56,16 @@ class Auth {
return helpers.jwtSign(data); return helpers.jwtSign(data);
} }
const subscriptionPayload = SubscriptionService.getSignupSubscriptionPayload(
options?.body?.planId || options?.body?.plan,
);
const newUser = await UsersDBApi.createFromAuth( const newUser = await UsersDBApi.createFromAuth(
{ {
firstName: email.split('@')[0], firstName: email.split('@')[0],
password: hashedPassword, password: hashedPassword,
email: email, email: email,
...subscriptionPayload,
}, },
options, options,

View File

@ -6,6 +6,7 @@ const csv = require('csv-parser');
const axios = require('axios'); const axios = require('axios');
const config = require('../config'); const config = require('../config');
const stream = require('stream'); const stream = require('stream');
const SubscriptionService = require('./subscription');
@ -15,6 +16,8 @@ module.exports = class BusinessesService {
static async create(data, currentUser) { static async create(data, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
await SubscriptionService.assertCanCreateBusinesses(currentUser, 1, { transaction });
await BusinessesDBApi.create( await BusinessesDBApi.create(
data, data,
{ {
@ -51,6 +54,8 @@ module.exports = class BusinessesService {
.on('error', (error) => reject(error)); .on('error', (error) => reject(error));
}) })
await SubscriptionService.assertCanCreateBusinesses(req.currentUser, results.length, { transaction });
await BusinessesDBApi.bulkImport(results, { await BusinessesDBApi.bulkImport(results, {
transaction, transaction,
ignoreDuplicates: true, ignoreDuplicates: true,

View File

@ -6,6 +6,7 @@ const csv = require('csv-parser');
const axios = require('axios'); const axios = require('axios');
const config = require('../config'); const config = require('../config');
const stream = require('stream'); const stream = require('stream');
const SubscriptionService = require('./subscription');
@ -15,6 +16,8 @@ module.exports = class Review_requestsService {
static async create(data, currentUser) { static async create(data, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
await SubscriptionService.assertCanCreateReviewRequests(currentUser, 1, { transaction });
await Review_requestsDBApi.create( await Review_requestsDBApi.create(
data, data,
{ {
@ -51,6 +54,8 @@ module.exports = class Review_requestsService {
.on('error', (error) => reject(error)); .on('error', (error) => reject(error));
}) })
await SubscriptionService.assertCanCreateReviewRequests(req.currentUser, results.length, { transaction });
await Review_requestsDBApi.bulkImport(results, { await Review_requestsDBApi.bulkImport(results, {
transaction, transaction,
ignoreDuplicates: true, ignoreDuplicates: true,

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,5 @@
import React, {useEffect, useRef} from 'react' import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider' import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon' import BaseIcon from './BaseIcon'

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -1,5 +1,4 @@
import React, { ReactNode, useEffect } from 'react' import React, { ReactNode, useEffect, useState } from 'react'
import { useState } from 'react'
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside' import menuAside from '../menuAside'

View File

@ -1,5 +1,5 @@
import * as icon from '@mdi/js'; import * as icon from '@mdi/js';
import { MenuAsideItem } from './interfaces' import { MenuAsideItem } from './interfaces';
const menuAside: MenuAsideItem[] = [ const menuAside: MenuAsideItem[] = [
{ {
@ -8,13 +8,25 @@ const menuAside: MenuAsideItem[] = [
label: 'Dashboard', label: 'Dashboard',
}, },
{
href: '/reviewflow',
icon: icon.mdiStarOutline,
label: 'Review Flow',
},
{
href: '/subscription',
icon: icon.mdiCreditCardOutline,
label: 'Subscription',
},
{ {
href: '/users/users-list', href: '/users/users-list',
label: 'Users', label: 'Users',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
icon: icon.mdiAccountGroup ?? icon.mdiTable, icon: icon.mdiAccountGroup ?? icon.mdiTable,
permissions: 'READ_USERS' permissions: 'READ_USERS',
}, },
{ {
href: '/roles/roles-list', href: '/roles/roles-list',
@ -22,7 +34,7 @@ const menuAside: MenuAsideItem[] = [
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable, icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable,
permissions: 'READ_ROLES' permissions: 'READ_ROLES',
}, },
{ {
href: '/permissions/permissions-list', href: '/permissions/permissions-list',
@ -30,63 +42,84 @@ const menuAside: MenuAsideItem[] = [
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
icon: icon.mdiShieldAccountOutline ?? icon.mdiTable, icon: icon.mdiShieldAccountOutline ?? icon.mdiTable,
permissions: 'READ_PERMISSIONS' permissions: 'READ_PERMISSIONS',
}, },
{ {
href: '/businesses/businesses-list', href: '/businesses/businesses-list',
label: 'Businesses', label: 'Businesses',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
icon: 'mdiStore' in icon ? icon['mdiStore' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, icon:
permissions: 'READ_BUSINESSES' 'mdiStore' in icon
? icon['mdiStore' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_BUSINESSES',
}, },
{ {
href: '/customers/customers-list', href: '/customers/customers-list',
label: 'Customers', label: 'Customers',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
icon: 'mdiAccountMultiple' in icon ? icon['mdiAccountMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, icon:
permissions: 'READ_CUSTOMERS' 'mdiAccountMultiple' in icon
? icon['mdiAccountMultiple' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_CUSTOMERS',
}, },
{ {
href: '/transactions/transactions-list', href: '/transactions/transactions-list',
label: 'Transactions', label: 'Transactions',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
icon: 'mdiCreditCardOutline' in icon ? icon['mdiCreditCardOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, icon:
permissions: 'READ_TRANSACTIONS' 'mdiCreditCardOutline' in icon
? icon['mdiCreditCardOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_TRANSACTIONS',
}, },
{ {
href: '/review_requests/review_requests-list', href: '/review_requests/review_requests-list',
label: 'Review requests', label: 'Review requests',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
icon: 'mdiEmailFastOutline' in icon ? icon['mdiEmailFastOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, icon:
permissions: 'READ_REVIEW_REQUESTS' 'mdiEmailFastOutline' in icon
? icon['mdiEmailFastOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_REVIEW_REQUESTS',
}, },
{ {
href: '/stripe_events/stripe_events-list', href: '/stripe_events/stripe_events-list',
label: 'Stripe events', label: 'Payment events',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
icon: 'mdiWebhook' in icon ? icon['mdiWebhook' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, icon:
permissions: 'READ_STRIPE_EVENTS' 'mdiWebhook' in icon
? icon['mdiWebhook' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_STRIPE_EVENTS',
}, },
{ {
href: '/email_delivery_logs/email_delivery_logs-list', href: '/email_delivery_logs/email_delivery_logs-list',
label: 'Email delivery logs', label: 'Email delivery logs',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
icon: 'mdiEmailCheckOutline' in icon ? icon['mdiEmailCheckOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, icon:
permissions: 'READ_EMAIL_DELIVERY_LOGS' 'mdiEmailCheckOutline' in icon
? icon['mdiEmailCheckOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_EMAIL_DELIVERY_LOGS',
}, },
{ {
href: '/cron_runs/cron_runs-list', href: '/cron_runs/cron_runs-list',
label: 'Cron runs', label: 'Cron runs',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
icon: 'mdiClockOutline' in icon ? icon['mdiClockOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, icon:
permissions: 'READ_CRON_RUNS' 'mdiClockOutline' in icon
? icon['mdiClockOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_CRON_RUNS',
}, },
{ {
href: '/profile', href: '/profile',
@ -94,14 +127,13 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiAccountCircle, icon: icon.mdiAccountCircle,
}, },
{ {
href: '/api-docs', href: '/api-docs',
target: '_blank', target: '_blank',
label: 'Swagger API', label: 'Swagger API',
icon: icon.mdiFileCode, icon: icon.mdiFileCode,
permissions: 'READ_API_DOCS' permissions: 'READ_API_DOCS',
}, },
] ];
export default menuAside export default menuAside;

View File

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

View File

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

View File

@ -1,161 +1,260 @@
import { mdiArrowRight, mdiCheckCircleOutline, mdiLogin, mdiShieldCheckOutline, mdiStarCircleOutline } from '@mdi/js';
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head'; import Head from 'next/head';
import Link from 'next/link'; import Link from 'next/link';
import React, { ReactElement } from 'react';
import BaseButton from '../components/BaseButton'; import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox'; import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest'; import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config'; import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks'; import { subscriptionPlans, trialDays } from '../subscriptionPlans';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
const metrics = [
['7 days', 'default review delay'],
['5 sources', 'Stripe, Square, PayPal, Shopify, WooCommerce'],
['4 states', 'pending, sent, clicked, reviewed'],
];
const steps = [
['Capture', 'Receive Stripe, Square, PayPal, Shopify, or WooCommerce webhooks as soon as checkout happens.'],
['Schedule', 'Create the customer, transaction, and review request automatically with your preferred delay.'],
['Track', 'Follow pending, sent, clicked, and reviewed requests from one workspace.'],
];
const features = [
'Business review links and templates',
'Webhook-created customers and transactions',
'Readable queue with message preview',
'Admin CRUD and API docs still available',
];
export default function Starter() { export default function Starter() {
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('video');
const [contentPosition, setContentPosition] = useState('left');
const textColor = useAppSelector((state) => state.style.linkColor);
const title = 'ReviewFlow'
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return ( return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'> <div className="min-h-screen bg-[#F7F8FC] text-slate-950">
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<Head> <Head>
<title>{getPageTitle('Starter Page')}</title> <title>{getPageTitle('Review Flow')}</title>
<meta
name="description"
content="Review Flow helps businesses queue and track review requests after customer purchases or visits."
/>
</Head> </Head>
<SectionFullScreen bg='violet'> <header className="sticky top-0 z-20 border-b border-white/70 bg-white/80 backdrop-blur-xl">
<div <div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
className={`flex ${ <Link href="/" className="flex items-center gap-3 font-black tracking-tight">
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row' <span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#101828] text-white shadow-lg shadow-indigo-950/20">
} min-h-screen w-full`}
> </span>
{contentType === 'image' && contentPosition !== 'background' <span className="text-xl">Review Flow</span>
? imageBlock(illustrationImage) </Link>
: null} <nav className="flex items-center gap-3">
{contentType === 'video' && contentPosition !== 'background' <BaseButton href="/#pricing" label="Pricing" color="whiteDark" />
? videoBlock(illustrationVideo) <BaseButton href="/login" icon={mdiLogin} label="Login" color="whiteDark" />
: null} <BaseButton href="/reviewflow" icon={mdiArrowRight} label="Admin interface" color="info" />
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'> </nav>
<CardBox className='w-full md:w-3/5 lg:w-2/3'> </div>
<CardBoxComponentTitle title="Welcome to your ReviewFlow app!"/> </header>
<div className="space-y-3"> <main>
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p> <section className="relative isolate overflow-hidden px-6 py-20 md:py-28">
<p className='text-center text-gray-500'>For guides and documentation please check <div className="absolute left-1/2 top-0 -z-10 h-[640px] w-[920px] -translate-x-1/2 rounded-full bg-[radial-gradient(circle_at_center,#7C3AED_0%,#10B981_38%,transparent_68%)] opacity-20 blur-3xl" />
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p> <div className="mx-auto grid max-w-7xl gap-12 lg:grid-cols-[1.05fr_0.95fr] lg:items-center">
<div>
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm font-bold text-emerald-700">
<span className="h-2 w-2 rounded-full bg-emerald-500" />
Review automation for modern local businesses
</div>
<h1 className="max-w-4xl text-5xl font-black leading-[0.95] tracking-tight text-slate-950 md:text-7xl">
Ask at the perfect moment. Earn more five-star reviews.
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600">
Review Flow turns Stripe, Square, PayPal, Shopify, and WooCommerce webhooks into scheduled review requests with a clean queue, message preview, and admin controls already wired into your app.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<BaseButton href="/reviewflow" icon={mdiStarCircleOutline} label="Open Review Flow" color="info" className="shadow-xl shadow-indigo-600/20" />
<BaseButton href="/login" icon={mdiShieldCheckOutline} label="Login to admin" color="whiteDark" />
</div>
<div className="mt-10 grid max-w-2xl gap-3 sm:grid-cols-3">
{metrics.map(([value, label]) => (
<div key={label} className="rounded-3xl border border-white bg-white/80 p-5 shadow-xl shadow-slate-200/60">
<p className="text-3xl font-black text-slate-950">{value}</p>
<p className="mt-1 text-sm text-slate-500">{label}</p>
</div>
))}
</div>
</div> </div>
<BaseButtons> <CardBox className="border-0 bg-white/90 shadow-2xl shadow-indigo-950/10 ring-1 ring-slate-200/70" cardBoxClassName="p-0">
<BaseButton <div className="rounded-t-3xl bg-[#101828] p-5 text-white">
href='/login' <div className="flex items-center justify-between">
label='Login' <div>
color='info' <p className="text-sm font-bold uppercase tracking-[0.25em] text-emerald-300">Live workflow</p>
className='w-full' <h2 className="mt-2 text-2xl font-black">Review request queued</h2>
/> </div>
<span className="rounded-full bg-emerald-400/20 px-3 py-1 text-sm font-bold text-emerald-200 ring-1 ring-emerald-300/30">
</BaseButtons> pending
</span>
</div>
</div>
<div className="space-y-5 p-6">
<div className="rounded-3xl bg-slate-50 p-5">
<p className="text-xs font-black uppercase tracking-[0.25em] text-slate-400">Customer</p>
<p className="mt-2 text-lg font-black">Maya Chen</p>
<p className="text-slate-500">maya@example.com</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-3xl bg-indigo-50 p-5 text-indigo-950">
<p className="text-sm font-bold text-indigo-500">Scheduled</p>
<p className="text-2xl font-black">+7 days</p>
</div>
<div className="rounded-3xl bg-emerald-50 p-5 text-emerald-950">
<p className="text-sm font-bold text-emerald-600">Destination</p>
<p className="text-2xl font-black">Google</p>
</div>
</div>
<div className="rounded-3xl border border-dashed border-slate-200 p-5">
<p className="font-black">How was your experience with Review Flow Studio?</p>
<p className="mt-2 text-sm leading-6 text-slate-500">
Hi Maya, thank you for choosing us. We would love to hear about your experience.
</p>
</div>
</div>
</CardBox> </CardBox>
</div> </div>
</section>
<section className="px-6 pb-20">
<div className="mx-auto grid max-w-7xl gap-6 lg:grid-cols-3">
{steps.map(([title, copy], index) => (
<div key={title} className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-xl shadow-slate-200/50">
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-950 text-lg font-black text-white">
{index + 1}
</div> </div>
</SectionFullScreen> <h3 className="text-2xl font-black">{title}</h3>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'> <p className="mt-3 leading-7 text-slate-600">{copy}</p>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p> </div>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'> ))}
Privacy Policy </div>
</Link> </section>
<section id="pricing" className="px-6 pb-20">
<div className="mx-auto max-w-7xl">
<div className="mx-auto max-w-3xl text-center">
<p className="text-sm font-black uppercase tracking-[0.3em] text-emerald-600">Simple pricing</p>
<h2 className="mt-4 text-4xl font-black tracking-tight text-slate-950 md:text-5xl">Choose Starter or Pro.</h2>
<p className="mt-5 text-lg leading-8 text-slate-600">
Every plan starts with a {trialDays}-day free trial. Starter covers the core review workflow. Pro adds the advanced automation and reputation marketing tools growing teams need.
</p>
</div> </div>
<div className="mt-12 grid gap-6 lg:grid-cols-2">
{subscriptionPlans.map((plan) => {
const isPro = plan.id === 'pro';
return (
<CardBox
key={plan.id}
className={`relative overflow-hidden border-0 bg-white shadow-2xl ${
isPro ? 'shadow-indigo-950/20 ring-2 ring-indigo-600' : 'shadow-slate-200/70 ring-1 ring-slate-200'
}`}
cardBoxClassName="p-0"
>
{plan.highlight && (
<div className="absolute right-6 top-6 rounded-full bg-indigo-600 px-4 py-1 text-sm font-black text-white shadow-lg shadow-indigo-600/30">
{plan.highlight}
</div>
)}
<div className="p-8">
<p className="text-sm font-black uppercase tracking-[0.3em] text-slate-400">Review Flow</p>
<h3 className="mt-3 text-3xl font-black text-slate-950">{plan.name}</h3>
<p className="mt-3 min-h-[56px] leading-7 text-slate-600">{plan.tagline}</p>
<div className="mt-8 flex items-end gap-2">
<span className="text-5xl font-black tracking-tight text-slate-950">${plan.priceMonthly}</span>
<span className="pb-2 font-bold text-slate-500">/month</span>
</div>
<p className="mt-2 text-sm font-bold text-emerald-600">{plan.trialDays}-day free trial included</p>
<div className="mt-8 grid gap-3 rounded-3xl bg-slate-50 p-5 sm:grid-cols-2">
<div>
<p className="text-2xl font-black text-slate-950">{plan.limits.monthlyReviewRequests.toLocaleString()}</p>
<p className="text-sm text-slate-500">review requests/month</p>
</div>
<div>
<p className="text-2xl font-black text-slate-950">{plan.limits.businesses}</p>
<p className="text-sm text-slate-500">businesses/locations</p>
</div>
<div>
<p className="text-2xl font-black text-slate-950">{plan.limits.teamMembers}</p>
<p className="text-sm text-slate-500">team members</p>
</div>
<div>
<p className="text-2xl font-black text-slate-950">{plan.limits.paymentConnectors}</p>
<p className="text-sm text-slate-500">payment connectors</p>
</div>
</div>
<BaseButton
href={`/register?plan=${plan.id}`}
icon={mdiArrowRight}
label={plan.ctaLabel}
color={isPro ? 'info' : 'whiteDark'}
className="mt-8 w-full shadow-xl shadow-indigo-600/10"
/>
</div>
<div className={isPro ? 'bg-indigo-950 p-8 text-white' : 'bg-slate-950 p-8 text-white'}>
<p className="mb-5 text-sm font-black uppercase tracking-[0.25em] text-emerald-300">Included features</p>
<div className="grid gap-3">
{plan.features.map((feature) => (
<div key={feature} className="flex items-start gap-3">
<span className="mt-1 text-emerald-300">
<svg className="h-5 w-5" viewBox="0 0 24 24">
<path fill="currentColor" d={mdiCheckCircleOutline} />
</svg>
</span>
<span className="font-semibold text-slate-100">{feature}</span>
</div>
))}
</div>
</div>
</CardBox>
);
})}
</div>
</div>
</section>
<section className="bg-[#101828] px-6 py-20 text-white">
<div className="mx-auto grid max-w-7xl gap-10 lg:grid-cols-[0.8fr_1.2fr] lg:items-center">
<div>
<p className="text-sm font-black uppercase tracking-[0.3em] text-emerald-300">First MVP slice</p>
<h2 className="mt-4 text-4xl font-black tracking-tight md:text-5xl">A complete thin workflow, not just a screen.</h2>
<p className="mt-5 leading-8 text-slate-300">
The admin workspace lets a user connect payment webhooks, receive events, create transactions and customers, queue review requests, browse recent activity, and inspect the generated message.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{features.map((feature) => (
<div key={feature} className="flex items-start gap-3 rounded-3xl bg-white/10 p-5 ring-1 ring-white/10">
<span className="mt-1 text-emerald-300"><svg className="h-5 w-5" viewBox="0 0 24 24"><path fill="currentColor" d={mdiCheckCircleOutline} /></svg></span>
<span className="font-semibold text-slate-100">{feature}</span>
</div>
))}
</div>
</div>
</section>
</main>
<footer className="bg-white px-6 py-8">
<div className="mx-auto flex max-w-7xl flex-col gap-4 text-sm text-slate-500 md:flex-row md:items-center md:justify-between">
<p>© 2026 Review Flow. All rights reserved.</p>
<div className="flex gap-5">
<Link href="/privacy-policy/" className="hover:text-slate-950">Privacy Policy</Link>
<Link href="/terms-of-use/" className="hover:text-slate-950">Terms of Use</Link>
<Link href="/login" className="font-bold text-slate-950">Login</Link>
</div>
</div>
</footer>
</div> </div>
); );
} }
@ -163,4 +262,3 @@ export default function Starter() {
Starter.getLayout = function getLayout(page: ReactElement) { Starter.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>; return <LayoutGuest>{page}</LayoutGuest>;
}; };

View File

@ -20,7 +20,7 @@ import { findMe, loginUser, resetAction } from '../stores/authSlice';
import { useAppDispatch, useAppSelector } from '../stores/hooks'; import { useAppDispatch, useAppSelector } from '../stores/hooks';
import Link from 'next/link'; import Link from 'next/link';
import {toast, ToastContainer} from "react-toastify"; import {toast, ToastContainer} from "react-toastify";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels' import { getPexelsImage } from '../helpers/pexels'
export default function Login() { export default function Login() {
const router = useRouter(); const router = useRouter();
@ -33,9 +33,7 @@ export default function Login() {
photographer: undefined, photographer: undefined,
photographer_url: undefined, photographer_url: undefined,
}) })
const [ illustrationVideo, setIllustrationVideo ] = useState({video_files: []}) const [contentPosition] = useState<'left' | 'right' | 'background'>('left');
const [contentType, setContentType] = useState('video');
const [contentPosition, setContentPosition] = useState('left');
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector( const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
(state) => state.auth, (state) => state.auth,
@ -44,15 +42,90 @@ export default function Login() {
password: 'fc6e39e3', password: 'fc6e39e3',
remember: true }) remember: true })
const title = 'ReviewFlow' const title = 'Review Flow'
// Fetch Pexels image/video const appHighlights = [
'Automated review requests after payments, jobs, or service milestones.',
'Customer, business, transaction, and delivery follow-up data in one admin workspace.',
'Dashboards, CRM records, payment events, email logs, and admin controls already built in.',
];
const competitorAdvantages = [
{
title: 'Built around review operations',
description:
'Review Flow combines CRM records, payments, follow-up, review requests, and reputation workflows in one focused system.',
},
{
title: 'Designed for logistics teams',
description:
'Transportation teams can manage businesses, customers, transactions, payment events, and review requests without jumping tools.',
},
{
title: 'Clear Starter and Pro tiers',
description:
'Starter is $49/month for the core review workflow. Pro is $99/month for higher limits, automation, AI, and reputation marketing tools.',
},
];
const pricingPlans = [
{
name: 'Starter',
price: '$49',
description:
'Best for small teams that need the core Review Flow workflow and simple monthly limits.',
sections: [
{
title: 'Core review workflow',
features: [
'Review Flow workspace for creating, scheduling, and tracking review requests.',
'Manual review request creation and hosted public review forms.',
'Customer, business, transaction, and delivery follow-up records.',
],
},
{
title: 'Starter limits',
features: [
'250 review requests per month.',
'1 business or location.',
'2 team members.',
'Stripe, Square, PayPal, Shopify, and WooCommerce webhook intake.',
],
},
],
},
{
name: 'Pro',
price: '$99',
description:
'Best for growing teams that want higher limits, automation, AI assistance, and reputation marketing tools.',
sections: [
{
title: 'Everything in Starter',
features: [
'2,500 review requests per month.',
'10 businesses or locations.',
'10 team members.',
'Priority support and advanced reporting.',
],
},
{
title: 'Growth tools',
features: [
'Advanced automation rules.',
'AI review reply assistant.',
'Social proof widgets, referral campaigns, repeat booking reminders, NPS surveys, and broadcasts.',
],
},
],
},
];
// Fetch Pexels image
useEffect( () => { useEffect( () => {
async function fetchData() { async function fetchData() {
const image = await getPexelsImage() const image = await getPexelsImage()
const video = await getPexelsVideo()
setIllustrationImage(image); setIllustrationImage(image);
setIllustrationVideo(video);
} }
fetchData(); fetchData();
}, []); }, []);
@ -115,32 +188,7 @@ export default function Login() {
</div> </div>
) )
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video.user.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
return ( return (
<div style={contentPosition === 'background' ? { <div style={contentPosition === 'background' ? {
@ -159,8 +207,7 @@ export default function Login() {
<SectionFullScreen bg='violet'> <SectionFullScreen bg='violet'>
<div className={`flex ${contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'} min-h-screen w-full`}> <div className={`flex ${contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'} min-h-screen w-full`}>
{contentType === 'image' && contentPosition !== 'background' ? imageBlock(illustrationImage) : null} {contentPosition !== 'background' ? imageBlock(illustrationImage) : null}
{contentType === 'video' && contentPosition !== 'background' ? videoBlock(illustrationVideo) : null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'> <div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'> <CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'>
@ -257,6 +304,95 @@ export default function Login() {
</Form> </Form>
</Formik> </Formik>
</CardBox> </CardBox>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<div className='space-y-8'>
<div>
<p className='text-sm font-semibold uppercase tracking-[0.2em] text-blue-600'>About Us</p>
<h3 className='mt-2 text-3xl font-semibold text-gray-900 dark:text-white'>
Review management built for transportation teams.
</h3>
<p className='mt-4 text-base leading-7 text-gray-600 dark:text-slate-300'>
Review Flow helps logistics and transportation businesses turn completed jobs, payments,
and customer interactions into organized review requests. Your team can manage customer
records, monitor follow-up, and keep reputation-building work moving from one secure
admin panel.
</p>
</div>
<div className='grid gap-3 md:grid-cols-3'>
{appHighlights.map((highlight) => (
<div
key={highlight}
className='rounded-2xl border border-blue-100 bg-blue-50/70 p-4 text-sm leading-6 text-blue-900 dark:border-dark-700 dark:bg-dark-800 dark:text-slate-200'
>
{highlight}
</div>
))}
</div>
<div>
<h4 className='text-xl font-semibold text-gray-900 dark:text-white'>Why we&apos;re better</h4>
<div className='mt-4 grid gap-4 md:grid-cols-3'>
{competitorAdvantages.map((item) => (
<div key={item.title} className='rounded-2xl border border-gray-200 p-4 dark:border-dark-700'>
<h5 className='font-semibold text-gray-900 dark:text-white'>{item.title}</h5>
<p className='mt-2 text-sm leading-6 text-gray-600 dark:text-slate-300'>
{item.description}
</p>
</div>
))}
</div>
</div>
<div>
<div className='flex flex-col justify-between gap-2 md:flex-row md:items-end'>
<div>
<p className='text-sm font-semibold uppercase tracking-[0.2em] text-blue-600'>Pricing</p>
<h4 className='mt-2 text-2xl font-semibold text-gray-900 dark:text-white'>Simple monthly plans</h4>
</div>
<p className='text-sm text-gray-500 dark:text-slate-400'>Upgrade when your review workflow grows.</p>
</div>
<div className='mt-4 grid gap-4 md:grid-cols-2'>
{pricingPlans.map((plan) => (
<div
key={plan.name}
className='rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-dark-700 dark:bg-dark-900'
>
<div className='flex items-start justify-between gap-3'>
<div>
<h5 className='text-lg font-semibold text-gray-900 dark:text-white'>{plan.name}</h5>
<p className='mt-1 text-sm leading-6 text-gray-600 dark:text-slate-300'>{plan.description}</p>
</div>
<div className='text-right'>
<span className='text-3xl font-bold text-blue-600'>{plan.price}</span>
<span className='block text-xs text-gray-500 dark:text-slate-400'>/month</span>
</div>
</div>
<div className='mt-5 space-y-5'>
{plan.sections.map((section) => (
<div key={section.title}>
<h6 className='text-sm font-semibold uppercase tracking-wide text-gray-900 dark:text-white'>
{section.title}
</h6>
<ul className='mt-2 space-y-2 text-sm text-gray-700 dark:text-slate-300'>
{section.features.map((feature) => (
<li key={feature} className='flex gap-2'>
<span className='font-semibold text-blue-600'></span>
<span>{feature}</span>
</li>
))}
</ul>
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
</CardBox>
</div> </div>
</div> </div>
</SectionFullScreen> </SectionFullScreen>

View File

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

View File

@ -12,12 +12,15 @@ import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons'; import BaseButtons from '../components/BaseButtons';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { getPageTitle } from '../config'; import { getPageTitle } from '../config';
import { subscriptionPlans } from '../subscriptionPlans';
import axios from "axios"; import axios from "axios";
export default function Register() { export default function Register() {
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);
const router = useRouter(); const router = useRouter();
const selectedPlanId = typeof router.query.plan === 'string' ? router.query.plan : 'starter';
const selectedPlan = subscriptionPlans.find((plan) => plan.id === selectedPlanId) || subscriptionPlans[0];
const notify = (type, msg) => toast( msg, {type, position: "bottom-center"}); const notify = (type, msg) => toast( msg, {type, position: "bottom-center"});
@ -25,7 +28,7 @@ export default function Register() {
setLoading(true) setLoading(true)
try { try {
const { data: response } = await axios.post('/auth/signup',value); const { data: response } = await axios.post('/auth/signup',{ ...value, planId: selectedPlan.id });
await router.push('/login') await router.push('/login')
setLoading(false) setLoading(false)
notify('success', 'Please check your email for verification link') notify('success', 'Please check your email for verification link')
@ -44,6 +47,10 @@ export default function Register() {
<SectionFullScreen bg='violet'> <SectionFullScreen bg='violet'>
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'> <CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
<div className='mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-emerald-900'>
<p className='font-black'>{selectedPlan.name} trial</p>
<p className='text-sm'>${selectedPlan.priceMonthly}/month after the {selectedPlan.trialDays}-day free trial. You can change plans from Subscription after signup.</p>
</div>
<Formik <Formik
initialValues={{ initialValues={{
email: '', email: '',

View File

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

View File

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

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

View File

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

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